feat: enhance duration parsing and error handling, add utility functions

This commit is contained in:
2025-07-10 18:05:07 -05:00
parent 5afffcaf07
commit 1b3f6c8864
9 changed files with 135 additions and 87 deletions

View File

@@ -152,10 +152,6 @@ fn generate_timezone_map() -> Result<(), BuildError> {
Some((abbreviation, offset)) => { Some((abbreviation, offset)) => {
builder.entry(abbreviation.clone(), offset.to_string()); builder.entry(abbreviation.clone(), offset.to_string());
processed_count += 1; processed_count += 1;
// println!(
// "cargo:warning=Processed timezone: {} -> {} seconds",
// abbreviation, offset
// );
} }
None => { None => {
skipped_count += 1; skipped_count += 1;

View File

@@ -1,7 +1,9 @@
use chrono::Duration; 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;
pub trait Months { pub trait Months {
fn months(count: i32) -> Self; fn months(count: i32) -> Self;
} }
@@ -37,7 +39,7 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
Ok(year) => { Ok(year) => {
Duration::days(year * 365) Duration::days(year * 365)
+ (if year > 0 { + (if year > 0 {
Duration::hours(6) * year as i32 Duration::hours(6) * year as i32 // Leap year compensation
} else { } else {
Duration::zero() Duration::zero()
}) })
@@ -141,6 +143,43 @@ pub fn parse_duration(str: &str) -> Result<Duration, String> {
Ok(value) Ok(value)
} }
pub fn parse_epoch_into_datetime(epoch: i64) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(epoch, 0)
}
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('-') {
let now = Utc::now();
// Try parsing as simple offset seconds first
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
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::<i64>() {
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)] #[cfg(test)]
mod tests { mod tests {
use crate::duration::{parse_duration, Months}; use crate::duration::{parse_duration, Months};

View File

@@ -1,7 +1,7 @@
use axum::http::StatusCode; use axum::{http::StatusCode, response::Json};
use axum::Json; use serde::Serialize;
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub enum TimeBannerError { pub enum TimeBannerError {
ParseError(String), ParseError(String),
RenderError(String), RenderError(String),
@@ -9,33 +9,41 @@ pub enum TimeBannerError {
NotFound, NotFound,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize)]
pub struct ErrorResponse { pub struct ErrorResponse {
code: u16, error: String,
message: String, message: String,
} }
pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) { pub fn get_error_response(error: TimeBannerError) -> (StatusCode, Json<ErrorResponse>) {
let (code, message) = match error { match error {
TimeBannerError::ParseError(msg) => (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "ParseError".to_string(),
message: msg,
}),
),
TimeBannerError::RenderError(msg) => ( TimeBannerError::RenderError(msg) => (
StatusCode::INTERNAL_SERVER_ERROR, 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) => ( TimeBannerError::RasterizeError(msg) => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("RasterizeError :: {}", msg), Json(ErrorResponse {
error: "RasterizeError".to_string(),
message: msg,
}),
), ),
TimeBannerError::NotFound => (StatusCode::NOT_FOUND, "Not Found".to_string()), TimeBannerError::NotFound => (
}; StatusCode::NOT_FOUND,
Json(ErrorResponse {
( error: "NotFound".to_string(),
code, message: "The requested resource was not found".to_string(),
Json(ErrorResponse { }),
code: code.as_u16(), ),
message, }
}),
)
} }

View File

@@ -15,6 +15,7 @@ mod raster;
mod render; mod render;
mod routes; mod routes;
mod template; mod template;
mod utils;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {

View File

@@ -49,7 +49,6 @@ impl Rasterizer {
tree_result.unwrap() tree_result.unwrap()
}; };
let zoom = 0.90;
let pixmap_size = tree.size().to_int_size(); let pixmap_size = tree.size().to_int_size();
let mut pixmap = tiny_skia::Pixmap::new(pixmap_size.width(), pixmap_size.height()).unwrap(); 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; let center_y = pixmap_size.height() as f32 / 2.0;
// Create transform that scales from center: translate to center, scale, translate back // 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) let render_ts = tiny_skia::Transform::from_translate(-center_x, -center_y)
.post_scale(zoom, zoom) .post_scale(zoom, zoom)
.post_translate(center_x, center_y); .post_translate(center_x, center_y);

View File

@@ -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 { pub fn mime_type(&self) -> &'static str {
match self { match self {
OutputFormat::Svg => "image/svg+xml", OutputFormat::Svg => "image/svg+xml",

View File

@@ -1,65 +1,13 @@
use crate::duration::parse_duration; use crate::duration::parse_time_value;
use crate::error::{get_error_response, TimeBannerError}; use crate::error::{get_error_response, TimeBannerError};
use crate::render::render_time_response; use crate::render::render_time_response;
use crate::template::OutputForm; use crate::template::OutputForm;
use crate::utils::parse_path;
use axum::extract::Path; use axum::extract::Path;
use axum::response::IntoResponse; 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<Utc>> {
DateTime::from_timestamp(epoch, 0)
}
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('-') {
let now = Utc::now();
// Try parsing as simple offset seconds first
if let Ok(offset_seconds) = raw_time.parse::<i64>() {
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::<i64>() {
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 { 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() axum::response::Redirect::temporary(&format!("/relative/{epoch_now}")).into_response()
} }

View File

@@ -15,7 +15,7 @@ lazy_static! {
"templates/**/*.svg" "templates/**/*.svg"
}; };
let mut _tera = match Tera::new(template_pattern) { match Tera::new(template_pattern) {
Ok(t) => { Ok(t) => {
let names: Vec<&str> = t.get_template_names().collect(); let names: Vec<&str> = t.get_template_names().collect();
println!("{} templates found ([{}]).", names.len(), names.join(", ")); println!("{} templates found ([{}]).", names.len(), names.join(", "));
@@ -25,9 +25,7 @@ lazy_static! {
println!("Parsing error(s): {}", e); println!("Parsing error(s): {}", e);
::std::process::exit(1); ::std::process::exit(1);
} }
}; }
_tera
}; };
} }

49
src/utils.rs Normal file
View File

@@ -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", ""));
}
}