From 1b3f6c88646a439dfebe2a698d7618ee4948b840 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 10 Jul 2025 18:05:07 -0500 Subject: [PATCH] feat: enhance duration parsing and error handling, add utility functions --- build.rs | 4 ---- src/duration.rs | 43 ++++++++++++++++++++++++++++++++++-- src/error.rs | 50 ++++++++++++++++++++++++------------------ src/main.rs | 1 + src/raster.rs | 2 +- src/render.rs | 9 ++++++++ src/routes.rs | 58 +++---------------------------------------------- src/template.rs | 6 ++--- src/utils.rs | 49 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 135 insertions(+), 87 deletions(-) create mode 100644 src/utils.rs diff --git a/build.rs b/build.rs index 970f920..8bc9ab1 100644 --- a/build.rs +++ b/build.rs @@ -152,10 +152,6 @@ fn generate_timezone_map() -> Result<(), BuildError> { Some((abbreviation, offset)) => { builder.entry(abbreviation.clone(), offset.to_string()); processed_count += 1; - // println!( - // "cargo:warning=Processed timezone: {} -> {} seconds", - // abbreviation, offset - // ); } None => { skipped_count += 1; diff --git a/src/duration.rs b/src/duration.rs index 2b42b13..a4a9d15 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -1,7 +1,9 @@ -use chrono::Duration; +use chrono::{DateTime, Duration, Utc}; use lazy_static::lazy_static; use regex::Regex; +use crate::error::TimeBannerError; + pub trait Months { fn months(count: i32) -> Self; } @@ -37,7 +39,7 @@ pub fn parse_duration(str: &str) -> Result { Ok(year) => { Duration::days(year * 365) + (if year > 0 { - Duration::hours(6) * year as i32 + Duration::hours(6) * year as i32 // Leap year compensation } else { Duration::zero() }) @@ -141,6 +143,43 @@ pub fn parse_duration(str: &str) -> Result { Ok(value) } +pub fn parse_epoch_into_datetime(epoch: i64) -> Option> { + DateTime::from_timestamp(epoch, 0) +} + +pub fn parse_time_value(raw_time: &str) -> Result, TimeBannerError> { + // Handle relative time values (starting with + or -, or duration strings like "1y2d") + if raw_time.starts_with('+') || raw_time.starts_with('-') { + let now = Utc::now(); + + // Try parsing as simple offset seconds first + if let Ok(offset_seconds) = raw_time.parse::() { + return Ok(now + Duration::seconds(offset_seconds)); + } + + // Try parsing as duration string (e.g., "+1y2d", "-3h30m") + if let Ok(duration) = parse_duration(raw_time) { + return Ok(now + duration); + } + + return Err(TimeBannerError::ParseError(format!( + "Could not parse relative time: {}", + raw_time + ))); + } + + // Try to parse as epoch timestamp + if let Ok(epoch) = raw_time.parse::() { + return parse_epoch_into_datetime(epoch) + .ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string())); + } + + Err(TimeBannerError::ParseError(format!( + "Could not parse time value: {}", + raw_time + ))) +} + #[cfg(test)] mod tests { use crate::duration::{parse_duration, Months}; diff --git a/src/error.rs b/src/error.rs index 87024df..f08e140 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ -use axum::http::StatusCode; -use axum::Json; -use serde::{Deserialize, Serialize}; +use axum::{http::StatusCode, response::Json}; +use serde::Serialize; +#[derive(Debug)] pub enum TimeBannerError { ParseError(String), RenderError(String), @@ -9,33 +9,41 @@ pub enum TimeBannerError { NotFound, } -#[derive(Serialize, Deserialize)] +#[derive(Serialize)] pub struct ErrorResponse { - code: u16, + error: String, message: String, } pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json) { - let (code, message) = match error { + match error { + TimeBannerError::ParseError(msg) => ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: "ParseError".to_string(), + message: msg, + }), + ), TimeBannerError::RenderError(msg) => ( StatusCode::INTERNAL_SERVER_ERROR, - format!("RenderError :: {}", msg), + Json(ErrorResponse { + error: "RenderError".to_string(), + message: msg, + }), ), - TimeBannerError::ParseError(msg) => { - (StatusCode::BAD_REQUEST, format!("ParserError :: {}", msg)) - } TimeBannerError::RasterizeError(msg) => ( StatusCode::INTERNAL_SERVER_ERROR, - format!("RasterizeError :: {}", msg), + Json(ErrorResponse { + error: "RasterizeError".to_string(), + message: msg, + }), ), - TimeBannerError::NotFound => (StatusCode::NOT_FOUND, "Not Found".to_string()), - }; - - ( - code, - Json(ErrorResponse { - code: code.as_u16(), - message, - }), - ) + TimeBannerError::NotFound => ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "NotFound".to_string(), + message: "The requested resource was not found".to_string(), + }), + ), + } } diff --git a/src/main.rs b/src/main.rs index baf893c..fd912fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod raster; mod render; mod routes; mod template; +mod utils; #[tokio::main] async fn main() { diff --git a/src/raster.rs b/src/raster.rs index d809b61..2c12e6c 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -49,7 +49,6 @@ impl Rasterizer { tree_result.unwrap() }; - let zoom = 0.90; let pixmap_size = tree.size().to_int_size(); let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap(); @@ -58,6 +57,7 @@ impl Rasterizer { let center_y = pixmap_size.height() as f32 / 2.0; // Create transform that scales from center: translate to center, scale, translate back + let zoom = 0.90; // 10% zoom out from center let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y) .post_scale(zoom, zoom) .post_translate(center_x, center_y); diff --git a/src/render.rs b/src/render.rs index bfbafa6..f4f5a17 100644 --- a/src/render.rs +++ b/src/render.rs @@ -20,6 +20,15 @@ impl OutputFormat { } } + pub fn from_mime_type(mime_type: &str) -> Self { + // TODO: Support mime types dynamically, proper header parsing + match mime_type { + "image/svg+xml" => OutputFormat::Svg, + "image/png" => OutputFormat::Png, + _ => OutputFormat::Svg, // Default to SVG + } + } + pub fn mime_type(&self) -> &'static str { match self { OutputFormat::Svg => "image/svg+xml", diff --git a/src/routes.rs b/src/routes.rs index 57c435f..27cce47 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,65 +1,13 @@ -use crate::duration::parse_duration; +use crate::duration::parse_time_value; use crate::error::{get_error_response, TimeBannerError}; use crate::render::render_time_response; use crate::template::OutputForm; +use crate::utils::parse_path; use axum::extract::Path; use axum::response::IntoResponse; -use chrono::{DateTime, Utc}; - -pub fn split_on_extension(path: &str) -> Option<(&str, &str)> { - let split = path.rsplit_once('.')?; - - // Check that the file is not a dotfile (.env) - if split.0.is_empty() { - return None; - } - - Some(split) -} - -fn parse_path(path: &str) -> (&str, &str) { - split_on_extension(path).unwrap_or((path, "svg")) -} - -fn parse_epoch_into_datetime(epoch: i64) -> Option> { - DateTime::from_timestamp(epoch, 0) -} - -fn parse_time_value(raw_time: &str) -> Result, TimeBannerError> { - // Handle relative time values (starting with + or -, or duration strings like "1y2d") - if raw_time.starts_with('+') || raw_time.starts_with('-') { - let now = Utc::now(); - - // Try parsing as simple offset seconds first - if let Ok(offset_seconds) = raw_time.parse::() { - return Ok(now + chrono::Duration::seconds(offset_seconds)); - } - - // Try parsing as duration string (e.g., "+1y2d", "-3h30m") - if let Ok(duration) = parse_duration(raw_time) { - return Ok(now + duration); - } - - return Err(TimeBannerError::ParseError(format!( - "Could not parse relative time: {}", - raw_time - ))); - } - - // Try to parse as epoch timestamp - if let Ok(epoch) = raw_time.parse::() { - return parse_epoch_into_datetime(epoch) - .ok_or_else(|| TimeBannerError::ParseError("Invalid timestamp".to_string())); - } - - Err(TimeBannerError::ParseError(format!( - "Could not parse time value: {}", - raw_time - ))) -} pub async fn index_handler() -> impl IntoResponse { - let epoch_now = Utc::now().timestamp(); + let epoch_now = chrono::Utc::now().timestamp(); axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response() } diff --git a/src/template.rs b/src/template.rs index bd9b3cc..74deffa 100644 --- a/src/template.rs +++ b/src/template.rs @@ -15,7 +15,7 @@ lazy_static! { "templates/**/*.svg" }; - let mut _tera = match Tera::new(template_pattern) { + match Tera::new(template_pattern) { Ok(t) => { let names: Vec<&str> = t.get_template_names().collect(); println!("{} templates found ([{}]).", names.len(), names.join(", ")); @@ -25,9 +25,7 @@ lazy_static! { println!("Parsing error(s): {}", e); ::std::process::exit(1); } - }; - - _tera + } }; } diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..83cce44 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,49 @@ +//! General utility functions used across the application. + +/// Splits a path on the last dot to extract filename and extension. +/// Returns None for dotfiles (paths starting with a dot). +pub fn split_on_extension(path: &str) -> Option<(&str, &str)> { + let split = path.rsplit_once('.')?; + + // Check that the file is not a dotfile (.env) + if split.0.is_empty() { + return None; + } + + Some(split) +} + +/// Parses path into (filename, extension). Defaults to "svg" if no extension found. +pub fn parse_path(path: &str) -> (&str, &str) { + split_on_extension(path).unwrap_or((path, "svg")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_on_extension() { + assert_eq!(split_on_extension("file.txt"), Some(("file", "txt"))); + assert_eq!( + split_on_extension("path/to/file.png"), + Some(("path/to/file", "png")) + ); + assert_eq!(split_on_extension("noextension"), None); + assert_eq!(split_on_extension(".dotfile"), None); // dotfiles return None + assert_eq!(split_on_extension("file."), Some(("file", ""))); + assert_eq!( + split_on_extension("file.name.ext"), + Some(("file.name", "ext")) + ); + } + + #[test] + fn test_parse_path() { + assert_eq!(parse_path("file.txt"), ("file", "txt")); + assert_eq!(parse_path("path/to/file.png"), ("path/to/file", "png")); + assert_eq!(parse_path("noextension"), ("noextension", "svg")); // default to svg + assert_eq!(parse_path(".dotfile"), (".dotfile", "svg")); // dotfiles get svg default + assert_eq!(parse_path("file."), ("file", "")); + } +}