docs: add comprehensive documentation

This commit is contained in:
2025-07-10 18:05:28 -05:00
parent 1b3f6c8864
commit 96dcbcc318
7 changed files with 92 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use tracing::Level; use tracing::Level;
/// Application environment configuration.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Environment { pub enum Environment {
@@ -8,6 +9,11 @@ pub enum Environment {
Development, 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)] #[derive(Deserialize, Debug)]
pub struct Configuration { pub struct Configuration {
#[serde(default = "default_env")] #[serde(default = "default_env")]
@@ -26,6 +32,10 @@ fn default_env() -> Environment {
} }
impl Configuration { 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] { pub fn socket_addr(&self) -> [u8; 4] {
match self.env { match self.env {
Environment::Production => { 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 { pub fn log_level(&self) -> Level {
match self.env { match self.env {
Environment::Production => Level::INFO, Environment::Production => Level::INFO,

View File

@@ -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 chrono::{DateTime, Duration, Utc};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use regex::Regex; use regex::Regex;
use crate::error::TimeBannerError; use crate::error::TimeBannerError;
/// Extends chrono::Duration with month support using approximate calendar math.
pub trait Months { pub trait Months {
fn months(count: i32) -> Self; fn months(count: i32) -> Self;
} }
impl Months for Duration { 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 { fn months(count: i32) -> Self {
Duration::milliseconds( Duration::milliseconds(
(Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64, (Duration::days(1).num_milliseconds() as f64 * (365.25f64 / 12f64)) as i64,
@@ -17,6 +25,19 @@ impl Months for Duration {
} }
lazy_static! { 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!( static ref FULL_RELATIVE_PATTERN: Regex = Regex::new(concat!(
"(?<sign>[-+])?", "(?<sign>[-+])?",
r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?", r"(?:(?<year>\d+)\s?(?:years?|yrs?|y)\s*)?",
@@ -30,6 +51,16 @@ lazy_static! {
.unwrap(); .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> { pub fn parse_duration(str: &str) -> Result<Duration, String> {
let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap(); let capture = FULL_RELATIVE_PATTERN.captures(str).unwrap();
let mut value = Duration::zero(); let mut value = Duration::zero();
@@ -143,10 +174,17 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
Ok(value) Ok(value)
} }
/// Converts Unix epoch timestamp to UTC DateTime.
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> { pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(epoch, 0) 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> { pub fn parse_time_value(raw_time: &str) -> Result<DateTime<Utc>, TimeBannerError> {
// Handle relative time values (starting with + or -, or duration strings like "1y2d") // Handle relative time values (starting with + or -, or duration strings like "1y2d")
if raw_time.starts_with('+') || raw_time.starts_with('-') { if raw_time.starts_with('+') || raw_time.starts_with('-') {

View File

@@ -1,20 +1,32 @@
use axum::{http::StatusCode, response::Json}; use axum::{http::StatusCode, response::Json};
use serde::Serialize; use serde::Serialize;
/// Application-specific errors that can occur during request processing.
#[derive(Debug)] #[derive(Debug)]
pub enum TimeBannerError { pub enum TimeBannerError {
/// Input parsing errors (invalid time formats, bad parameters, etc.)
ParseError(String), ParseError(String),
/// Template rendering failures
RenderError(String), RenderError(String),
/// SVG to PNG conversion failures
RasterizeError(String), RasterizeError(String),
/// 404 Not Found
NotFound, NotFound,
} }
/// JSON error response format for HTTP clients.
#[derive(Serialize)] #[derive(Serialize)]
pub struct ErrorResponse { pub struct ErrorResponse {
error: String, error: String,
message: 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>) { pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
match error { match error {
TimeBannerError::ParseError(msg) => ( TimeBannerError::ParseError(msg) => (

View File

@@ -1,6 +1,7 @@
use resvg::usvg::fontdb; use resvg::usvg::fontdb;
use resvg::{tiny_skia, usvg}; use resvg::{tiny_skia, usvg};
/// Errors that can occur during SVG rasterization.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RenderError { pub struct RenderError {
pub message: Option<String>, pub message: Option<String>,
@@ -21,6 +22,7 @@ pub struct Rasterizer {
} }
impl Rasterizer { impl Rasterizer {
/// Creates a new rasterizer and loads available fonts.
pub fn new() -> Self { pub fn new() -> Self {
let mut fontdb = fontdb::Database::new(); let mut fontdb = fontdb::Database::new();
fontdb.load_system_fonts(); fontdb.load_system_fonts();
@@ -33,6 +35,7 @@ impl Rasterizer {
Self { font_db: fontdb } Self { font_db: fontdb }
} }
/// Converts SVG data to PNG.
pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> { pub fn render(&self, svg_data: Vec<u8>) -> Result<Vec<u8>, RenderError> {
let tree = { let tree = {
let opt = usvg::Options { let opt = usvg::Options {

View File

@@ -6,6 +6,7 @@ use axum::http::{header, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
/// Output format for rendered time banners.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OutputFormat { pub enum OutputFormat {
Svg, Svg,
@@ -13,6 +14,7 @@ pub enum OutputFormat {
} }
impl OutputFormat { impl OutputFormat {
/// Determines output format from file extension. Defaults to SVG for unknown extensions.
pub fn from_extension(ext: &str) -> Self { pub fn from_extension(ext: &str) -> Self {
match ext { match ext {
"png" => OutputFormat::Png, "png" => OutputFormat::Png,
@@ -29,6 +31,7 @@ impl OutputFormat {
} }
} }
/// Returns the appropriate MIME type for HTTP responses.
pub fn mime_type(&self) -> &'static str { pub fn mime_type(&self) -> &'static str {
match self { match self {
OutputFormat::Svg => "image/svg+xml", 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> { fn handle_rasterize(data: String, format: &OutputFormat) -> Result<Bytes, TimeBannerError> {
match format { match format {
OutputFormat::Svg => Ok(Bytes::from(data)), 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( pub fn render_time_response(
time: DateTime<Utc>, time: DateTime<Utc>,
output_form: OutputForm, output_form: OutputForm,

View File

@@ -6,12 +6,14 @@ use crate::utils::parse_path;
use axum::extract::Path; use axum::extract::Path;
use axum::response::IntoResponse; use axum::response::IntoResponse;
/// Root handler - redirects to current time in relative format.
pub async fn index_handler() -> impl IntoResponse { pub async fn index_handler() -> impl IntoResponse {
let epoch_now = chrono::Utc::now().timestamp(); let epoch_now = chrono::Utc::now().timestamp();
axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response() 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 { pub async fn relative_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(&path); 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() 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 { pub async fn absolute_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(&path); 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() 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 { pub async fn implicit_handler(Path(path): Path<String>) -> impl IntoResponse {
let (raw_time, extension) = parse_path(&path); 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() render_time_response(time, OutputForm::Absolute, extension).into_response()
} }
/// Fallback handler for unmatched routes.
pub async fn fallback_handler() -> impl IntoResponse { pub async fn fallback_handler() -> impl IntoResponse {
get_error_response(TimeBannerError::NotFound).into_response() get_error_response(TimeBannerError::NotFound).into_response()
} }

View File

@@ -6,6 +6,7 @@ use timeago::Formatter;
use crate::render::OutputFormat; use crate::render::OutputFormat;
lazy_static! { lazy_static! {
/// Global Tera template engine instance.
static ref TEMPLATES: Tera = { static ref TEMPLATES: Tera = {
let template_pattern = if cfg!(debug_assertions) { let template_pattern = if cfg!(debug_assertions) {
// Development: templates are in src/templates // Development: templates are in src/templates
@@ -29,26 +30,35 @@ lazy_static! {
}; };
} }
/// Display format for time values.
pub enum OutputForm { pub enum OutputForm {
/// Relative display: "2 hours ago", "in 3 days"
Relative, Relative,
/// Absolute display: "2025-01-17 14:30:00 UTC"
Absolute, Absolute,
} }
/// Timezone specification formats.
pub enum TzForm { pub enum TzForm {
Abbreviation(String), // e.g. "CST" Abbreviation(String), // e.g. "CST"
Iso(String), // e.g. "America/Chicago" Iso(String), // e.g. "America/Chicago"
Offset(i32), // e.g. "-0600" as -21600 Offset(i32), // e.g. "-0600" as -21600
} }
/// Context passed to template renderer containing all necessary data.
pub struct RenderContext { pub struct RenderContext {
pub value: DateTime<Utc>, pub value: DateTime<Utc>,
pub output_form: OutputForm, pub output_form: OutputForm,
pub output_format: OutputFormat, pub output_format: OutputFormat,
/// Target timezone (not yet implemented - defaults to UTC)
pub timezone: Option<TzForm>, pub timezone: Option<TzForm>,
/// Custom time format string (not yet implemented)
pub format: Option<String>, pub format: Option<String>,
/// Reference time for relative calculations (not yet implemented - uses current time)
pub now: Option<i64>, pub now: Option<i64>,
} }
/// Renders a time value using the appropriate template.
pub fn render_template(context: RenderContext) -> Result<String, tera::Error> { pub fn render_template(context: RenderContext) -> Result<String, tera::Error> {
let mut template_context = Context::new(); let mut template_context = Context::new();
let formatter = Formatter::new(); let formatter = Formatter::new();