From 1e8ffb02a3536bbdc3af709a6fa8b3e02084bb31 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 09:51:35 -0400 Subject: [PATCH] feat(core): add now_unix() and MonthYear MonthYear used for card expiries; bounds 2000..=2099 are intentional. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 3 ++ crates/idfoto-core/src/time.rs | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 crates/idfoto-core/src/time.rs diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 7e11a53..a43801d 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -38,6 +38,9 @@ pub use error::{IdfotoError, Result}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; +pub mod time; +pub use time::{now_unix, MonthYear}; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; diff --git a/crates/idfoto-core/src/time.rs b/crates/idfoto-core/src/time.rs new file mode 100644 index 0000000..979df76 --- /dev/null +++ b/crates/idfoto-core/src/time.rs @@ -0,0 +1,63 @@ +//! Time helpers and the `MonthYear` type used for card expiries. + +use serde::{Deserialize, Serialize}; + +/// Current Unix timestamp in seconds. +pub fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Month + year (1-12 / e.g. 2026). Used for card expiries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MonthYear { + pub month: u8, + pub year: u16, +} + +impl MonthYear { + pub fn new(month: u8, year: u16) -> Result { + if !(1..=12).contains(&month) { + return Err("month must be 1..=12"); + } + if year < 2000 || year > 2099 { + return Err("year must be 2000..=2099"); + } + Ok(Self { month, year }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn now_unix_is_positive_and_recent() { + let t = now_unix(); + assert!(t > 1_700_000_000); // after late 2023 + assert!(t < 4_000_000_000); // before 2096 + } + + #[test] + fn month_year_constructor_rejects_bad_month() { + assert!(MonthYear::new(0, 2026).is_err()); + assert!(MonthYear::new(13, 2026).is_err()); + assert!(MonthYear::new(1, 2026).is_ok()); + assert!(MonthYear::new(12, 2026).is_ok()); + } + + #[test] + fn month_year_constructor_rejects_bad_year() { + assert!(MonthYear::new(1, 1999).is_err()); + assert!(MonthYear::new(1, 2100).is_err()); + assert!(MonthYear::new(1, 2000).is_ok()); + assert!(MonthYear::new(1, 2099).is_ok()); + } + + #[test] + fn month_year_round_trips_through_json() { + let my = MonthYear::new(7, 2030).unwrap(); + let json = serde_json::to_string(&my).unwrap(); + let parsed: MonthYear = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, my); + } +}