mirror of
https://github.com/Xevion/time-banner.git
synced 2025-12-06 09:16:43 -06:00
docs: add comprehensive documentation
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use serde::Deserialize;
|
||||
use tracing::Level;
|
||||
|
||||
/// Application environment configuration.
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Environment {
|
||||
@@ -8,6 +9,11 @@ pub enum Environment {
|
||||
Development,
|
||||
}
|
||||
|
||||
/// Main configuration struct parsed from environment variables.
|
||||
///
|
||||
/// Environment variables:
|
||||
/// - `ENV`: "production" or "development" (default: development)
|
||||
/// - `PORT`: TCP port number (default: 3000)
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Configuration {
|
||||
#[serde(default = "default_env")]
|
||||
@@ -26,6 +32,10 @@ fn default_env() -> Environment {
|
||||
}
|
||||
|
||||
impl Configuration {
|
||||
/// Returns the socket address to bind to based on environment.
|
||||
///
|
||||
/// - Production: 0.0.0.0 (all interfaces)
|
||||
/// - Development: 127.0.0.1 (localhost only)
|
||||
pub fn socket_addr(&self) -> [u8; 4] {
|
||||
match self.env {
|
||||
Environment::Production => {
|
||||
@@ -49,6 +59,10 @@ impl Configuration {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate log level for the environment.
|
||||
///
|
||||
/// - Production: INFO
|
||||
/// - Development: DEBUG
|
||||
pub fn log_level(&self) -> Level {
|
||||
match self.env {
|
||||
Environment::Production => Level::INFO,
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
//! Human-readable duration parsing with support for mixed time units.
|
||||
//!
|
||||
//! Parses strings like "1y2mon3w4d5h6m7s", "+1year", or "-3h30m" into chrono Duration objects.
|
||||
//! Time units can appear in any order and use various abbreviations.
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
|
||||
use crate::error::TimeBannerError;
|
||||
|
||||
/// Extends chrono::Duration with month support using approximate calendar math.
|
||||
pub trait Months {
|
||||
fn months(count: i32) -> Self;
|
||||
}
|
||||
|
||||
impl Months for Duration {
|
||||
/// Creates a duration representing the given number of months.
|
||||
/// Uses 365.25/12 ≈ 30.44 days per month for approximation.
|
||||
fn months(count: i32) -> Self {
|
||||
Duration::milliseconds(
|
||||
(Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64,
|
||||
@@ -17,6 +25,19 @@ impl Months for Duration {
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
/// Regex pattern matching duration strings with flexible ordering and abbreviations.
|
||||
///
|
||||
/// Supports:
|
||||
/// - Optional +/- sign
|
||||
/// - Years: y, yr, yrs, year, years
|
||||
/// - Months: mon, month, months
|
||||
/// - Weeks: w, wk, wks, week, weeks
|
||||
/// - Days: d, day, days
|
||||
/// - Hours: h, hr, hrs, hour, hours
|
||||
/// - Minutes: m, min, mins, minute, minutes
|
||||
/// - Seconds: s, sec, secs, second, seconds
|
||||
///
|
||||
/// Time units must appear in descending order of magnitude, e.g. "1y2d" is valid, "1d2y" is not.
|
||||
static ref FULL_RELATIVE_PATTERN: Regex = Regex::new(concat!(
|
||||
"(?<sign>[-+])?",
|
||||
r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?",
|
||||
@@ -30,6 +51,16 @@ lazy_static! {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Parses a human-readable duration string into a chrono Duration.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"1y2d"` → 1 year + 2 days
|
||||
/// - `"+3h30m"` → +3.5 hours
|
||||
/// - `"-1week"` → -7 days
|
||||
/// - `"2months4days"` → ~2.03 months
|
||||
///
|
||||
/// Years include leap year compensation (+6 hours per year).
|
||||
/// Empty strings return zero duration.
|
||||
pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
||||
let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap();
|
||||
let mut value = Duration::zero();
|
||||
@@ -143,10 +174,17 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Converts Unix epoch timestamp to UTC DateTime.
|
||||
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
|
||||
DateTime::from_timestamp(epoch, 0)
|
||||
}
|
||||
|
||||
/// Parses various time value formats into a UTC datetime.
|
||||
///
|
||||
/// Supports:
|
||||
/// - Relative offsets: "+3600", "-1800" (seconds from now)
|
||||
/// - Duration strings: "+1y2d", "-3h30m" (using duration parser)
|
||||
/// - Epoch timestamps: "1752170474" (Unix timestamp)
|
||||
pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
|
||||
// Handle relative time values (starting with + or -, or duration strings like "1y2d")
|
||||
if raw_time.starts_with('+') || raw_time.starts_with('-') {
|
||||
|
||||
12
src/error.rs
12
src/error.rs
@@ -1,20 +1,32 @@
|
||||
use axum::{http::StatusCode, response::Json};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Application-specific errors that can occur during request processing.
|
||||
#[derive(Debug)]
|
||||
pub enum TimeBannerError {
|
||||
/// Input parsing errors (invalid time formats, bad parameters, etc.)
|
||||
ParseError(String),
|
||||
/// Template rendering failures
|
||||
RenderError(String),
|
||||
/// SVG to PNG conversion failures
|
||||
RasterizeError(String),
|
||||
/// 404 Not Found
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// JSON error response format for HTTP clients.
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Converts application errors into standardized HTTP responses with JSON bodies.
|
||||
///
|
||||
/// Returns appropriate status codes:
|
||||
/// - 400 Bad Request: ParseError
|
||||
/// - 500 Internal Server Error: RenderError, RasterizeError
|
||||
/// - 404 Not Found: NotFound
|
||||
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
|
||||
match error {
|
||||
TimeBannerError::ParseError(msg) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use resvg::usvg::fontdb;
|
||||
use resvg::{tiny_skia, usvg};
|
||||
|
||||
/// Errors that can occur during SVG rasterization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderError {
|
||||
pub message: Option<String>,
|
||||
@@ -21,6 +22,7 @@ pub struct Rasterizer {
|
||||
}
|
||||
|
||||
impl Rasterizer {
|
||||
/// Creates a new rasterizer and loads available fonts.
|
||||
pub fn new() -> Self {
|
||||
let mut fontdb = fontdb::Database::new();
|
||||
fontdb.load_system_fonts();
|
||||
@@ -33,6 +35,7 @@ impl Rasterizer {
|
||||
Self { font_db: fontdb }
|
||||
}
|
||||
|
||||
/// Converts SVG data to PNG.
|
||||
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
|
||||
let tree = {
|
||||
let opt = usvg::Options {
|
||||
|
||||
@@ -6,6 +6,7 @@ use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Output format for rendered time banners.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OutputFormat {
|
||||
Svg,
|
||||
@@ -13,6 +14,7 @@ pub enum OutputFormat {
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Determines output format from file extension. Defaults to SVG for unknown extensions.
|
||||
pub fn from_extension(ext: &str) -> Self {
|
||||
match ext {
|
||||
"png" => OutputFormat::Png,
|
||||
@@ -29,6 +31,7 @@ impl OutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the appropriate MIME type for HTTP responses.
|
||||
pub fn mime_type(&self) -> &'static str {
|
||||
match self {
|
||||
OutputFormat::Svg => "image/svg+xml",
|
||||
@@ -37,6 +40,7 @@ impl OutputFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts SVG to the requested format. PNG requires rasterization.
|
||||
fn handle_rasterize(data: String, format: &OutputFormat) -> Result<Bytes, TimeBannerError> {
|
||||
match format {
|
||||
OutputFormat::Svg => Ok(Bytes::from(data)),
|
||||
@@ -53,6 +57,12 @@ fn handle_rasterize(data: String, format: &OutputFormat) -> Result<Bytes, TimeBa
|
||||
}
|
||||
}
|
||||
|
||||
/// Main rendering pipeline: template → SVG → optional rasterization → HTTP response.
|
||||
///
|
||||
/// Takes a timestamp, display format, and file extension, then:
|
||||
/// 1. Renders the time using a template
|
||||
/// 2. Converts to the requested format (SVG or PNG)
|
||||
/// 3. Returns an HTTP response with appropriate headers
|
||||
pub fn render_time_response(
|
||||
time: DateTime<Utc>,
|
||||
output_form: OutputForm,
|
||||
|
||||
@@ -6,12 +6,14 @@ use crate::utils::parse_path;
|
||||
use axum::extract::Path;
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
/// Root handler - redirects to current time in relative format.
|
||||
pub async fn index_handler() -> impl IntoResponse {
|
||||
let epoch_now = chrono::Utc::now().timestamp();
|
||||
|
||||
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
|
||||
}
|
||||
|
||||
/// Handles `/relative/{time}` - displays time in relative format ("2 hours ago").
|
||||
pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
let (raw_time, extension) = parse_path(&path);
|
||||
|
||||
@@ -23,6 +25,7 @@ pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
render_time_response(time, OutputForm::Relative, extension).into_response()
|
||||
}
|
||||
|
||||
/// Handles `/absolute/{time}` - displays time in absolute format ("2025-01-17 14:30:00 UTC").
|
||||
pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
let (raw_time, extension) = parse_path(&path);
|
||||
|
||||
@@ -34,7 +37,7 @@ pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
render_time_response(time, OutputForm::Absolute, extension).into_response()
|
||||
}
|
||||
|
||||
// Handler for implicit absolute time (no /absolute/ prefix)
|
||||
/// Handles `/{time}` - implicit absolute time display (same as absolute_handler).
|
||||
pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
let (raw_time, extension) = parse_path(&path);
|
||||
|
||||
@@ -46,6 +49,7 @@ pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
|
||||
render_time_response(time, OutputForm::Absolute, extension).into_response()
|
||||
}
|
||||
|
||||
/// Fallback handler for unmatched routes.
|
||||
pub async fn fallback_handler() -> impl IntoResponse {
|
||||
get_error_response(TimeBannerError::NotFound).into_response()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use timeago::Formatter;
|
||||
use crate::render::OutputFormat;
|
||||
|
||||
lazy_static! {
|
||||
/// Global Tera template engine instance.
|
||||
static ref TEMPLATES: Tera = {
|
||||
let template_pattern = if cfg!(debug_assertions) {
|
||||
// Development: templates are in src/templates
|
||||
@@ -29,26 +30,35 @@ lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
/// Display format for time values.
|
||||
pub enum OutputForm {
|
||||
/// Relative display: "2 hours ago", "in 3 days"
|
||||
Relative,
|
||||
/// Absolute display: "2025-01-17 14:30:00 UTC"
|
||||
Absolute,
|
||||
}
|
||||
|
||||
/// Timezone specification formats.
|
||||
pub enum TzForm {
|
||||
Abbreviation(String), // e.g. "CST"
|
||||
Iso(String), // e.g. "America/Chicago"
|
||||
Offset(i32), // e.g. "-0600" as -21600
|
||||
}
|
||||
|
||||
/// Context passed to template renderer containing all necessary data.
|
||||
pub struct RenderContext {
|
||||
pub value: DateTime<Utc>,
|
||||
pub output_form: OutputForm,
|
||||
pub output_format: OutputFormat,
|
||||
/// Target timezone (not yet implemented - defaults to UTC)
|
||||
pub timezone: Option<TzForm>,
|
||||
/// Custom time format string (not yet implemented)
|
||||
pub format: Option<String>,
|
||||
/// Reference time for relative calculations (not yet implemented - uses current time)
|
||||
pub now: Option<i64>,
|
||||
}
|
||||
|
||||
/// Renders a time value using the appropriate template.
|
||||
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
|
||||
let mut template_context = Context::new();
|
||||
let formatter = Formatter::new();
|
||||
|
||||
Reference in New Issue
Block a user