diff --git a/AGENTS.md b/AGENTS.md index a8d4badc..cd6de23e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr - **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence. - **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`. diff --git a/Cargo.lock b/Cargo.lock index dd6d3649..0c35238a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -552,6 +552,7 @@ dependencies = [ "cadcraft-engine", "cadcraft-io", "cadcraft-ui-egui", + "chrono", "eframe", "egui", "hidapi", @@ -714,6 +715,7 @@ dependencies = [ "cadcraft-fonts", "cadcraft-geom", "cadcraft-render", + "chrono", "craft-ui", "egui", "egui-wgpu", @@ -825,6 +827,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -4813,6 +4824,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" name = "xtask" version = "0.10.0" dependencies = [ + "chrono", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 16db80cf..e037ea70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" # rendering / formats diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index ec527437..886eac1e 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", " egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [build-dependencies] winresource = "0.1" diff --git a/apps/cadcraft/src/logging.rs b/apps/cadcraft/src/logging.rs index 6678dec3..4113de54 100644 --- a/apps/cadcraft/src/logging.rs +++ b/apps/cadcraft/src/logging.rs @@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd -/// clock can't overflow). -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math. +/// 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -385,8 +393,20 @@ mod tests { // 2000 is a leap year (divisible by 400), 2100 is not. assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); - // Before the epoch (a clock set wrong) clamps instead of panicking. + // Before the epoch (a clock set wrong) clamps to the epoch instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 630188d7..18721a96 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -23,6 +23,7 @@ egui-wgpu = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } image = { workspace = true } [dev-dependencies] diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index c7ea384d..a0db8d0d 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -111,23 +111,36 @@ impl CadApp { } } -/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). +/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch. fn utc(secs: u64) -> String { - let days = (secs / 86_400) as i64; let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); - // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let mo = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(mo <= 2); + let (y, mo, d) = civil_from_days(secs / 86_400); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}") } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A recovery file's mtime can in principle land past `NaiveDate`'s ±262143-year range; the old +/// hand-rolled converter still produced a date there, so falling back to the epoch would change +/// the shown time. The Gregorian calendar repeats every 400 years (146_097 days), so chrono +/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then +/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old +/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + /// The Drawing Recovery window, while autosave files from a crash are waiting. pub fn show(app: &mut CadApp, ctx: &egui::Context) { if app.recovery.items.is_empty() { @@ -233,5 +246,9 @@ mod tests { assert_eq!(super::utc(0), "1970-01-01 00:00"); assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01"); assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00"); + assert_eq!(super::utc(253_402_300_800), "10000-01-01 00:00"); + // Past chrono's range the proleptic Gregorian date is kept, not the epoch (old output). + assert_eq!(super::utc(10_000_000_000_000), "318857-05-20 17:46"); + assert_eq!(super::utc(u64::MAX), "584554051223-11-09 07:00"); } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index df7dfa87..701013cd 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,4 @@ publish = false [dependencies] serde_json = { workspace = true } +chrono = { workspace = true } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 6adee8bd..39f8e871 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec { /// Where the generated checklist goes. pub const OUT: &str = "docs/parity-checklist.md"; -/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed). +/// Today's UTC date as `YYYY-MM-DD` (from the system clock). fn today() -> String { - let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - let (y, m, d) = civil_from_days((secs / 86_400) as i64); + date(std::time::SystemTime::now()) +} + +/// `t` as a UTC `YYYY-MM-DD`. A clock before 1970 reads as 1970-01-01 (the `unwrap_or(0)` clamp); +/// past chrono's year 262143 range the proleptic Gregorian date is kept via the 400-year cycle +/// rather than falling back to the epoch. +fn date(t: std::time::SystemTime) -> String { + let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (y, m, d) = civil_from_days(secs / 86_400); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999. format!("{y:04}-{m:02}-{d:02}") } -/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar. -fn civil_from_days(z: i64) -> (i64, u32, u32) { - let z = z + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(146_097); - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - let y = yoe + era * 400 + i64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past +/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097 +/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an +/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing +/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } pub fn run(root: &Path) -> Result<(), String> { @@ -116,9 +130,17 @@ mod tests { #[test] fn civil_dates() { - assert_eq!(civil_from_days(0), (1970, 1, 1)); - assert_eq!(civil_from_days(11_016), (2000, 2, 29)); - assert_eq!(civil_from_days(20_736), (2026, 10, 10)); + let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400)); + assert_eq!(day(0), "1970-01-01"); + assert_eq!(day(11_016), "2000-02-29"); + assert_eq!(day(20_736), "2026-10-10"); + assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01"); + // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`). + assert_eq!(day(115_740_740), "318857-05-20"); + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) { + assert_eq!(date(far), "292277026596-12-04"); + } } #[test]