Use chrono instead of hand-rolled UTC conversion (#1789)

* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the three civil_from_days copies (logging timestamp, Drawing
Recovery time, parity checklist date) with chrono as a workspace
dependency. Output formats are unchanged and pinned by the existing tests.
AGENTS.md now says time goes through chrono.

Fixes #1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp and xtask's date now use the fallible
DateTime::from_timestamp and read such a clock as the epoch, as the old
saturating code did. xtask's date also clamps a pre-epoch clock to
1970-01-01 again. Years past 9999 are written with {:04} like before,
because %Y would add a '+'. Tests cover all three cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* logging/autosave/parity: keep extended-year dates, no epoch fallback

Applies the stricter no-epoch-fallback rule (from wordcraft#3427 / filmcraft#2662,
superseding the earlier epoch-or-last-valid suggestion) so all sibling PRs agree:
a timestamp the old code converted past chrono's year 262143 range must keep its real
proleptic-Gregorian date, not collapse to 1970-01-01. Each site uses the 400-year
Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts
the day within one cycle, whole cycles carry in the year (i128, saturated into i64).
This also removes the panic FredApps flagged — `DateTime::<Utc>::from(SystemTime)` could
panic past chrono's range; `from_num_days_from_ce_opt` within one cycle never does.
Verified the cycle result equals the old formula across a wide range and at the extremes.

- apps/cadcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch
  clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  (was 1970-...); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch).
- crates/ui-egui/src/autosave.rs `utc`: cycle converter. Pins: utc(10_000_000_000_000)
  = "318857-05-20 17:46"; utc(u64::MAX) = "584554051223-11-09 07:00" (was "1970-01-01 00:00").
- xtask/src/parity.rs `date`: cycle converter; pre-1970 epoch clamp kept. Pins:
  day(115_740_740) = "318857-05-20"; checked_add u64::MAX/2 s -> "292277026596-12-04" (was epoch).

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
This commit is contained in:
Dylan M. Taylor
2026-10-22 14:09:16 -04:00
committed by GitHub
parent f4e6873130
commit acd109d97c
9 changed files with 123 additions and 46 deletions
+1
View File
@@ -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. - **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.** - **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`. - **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`). - **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`. - **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`.
Generated
+12
View File
@@ -552,6 +552,7 @@ dependencies = [
"cadcraft-engine", "cadcraft-engine",
"cadcraft-io", "cadcraft-io",
"cadcraft-ui-egui", "cadcraft-ui-egui",
"chrono",
"eframe", "eframe",
"egui", "egui",
"hidapi", "hidapi",
@@ -714,6 +715,7 @@ dependencies = [
"cadcraft-fonts", "cadcraft-fonts",
"cadcraft-geom", "cadcraft-geom",
"cadcraft-render", "cadcraft-render",
"chrono",
"craft-ui", "craft-ui",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
@@ -825,6 +827,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "clipboard-win" name = "clipboard-win"
version = "5.4.1" version = "5.4.1"
@@ -4813,6 +4824,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
name = "xtask" name = "xtask"
version = "0.10.0" version = "0.10.0"
dependencies = [ dependencies = [
"chrono",
"serde_json", "serde_json",
] ]
+2
View File
@@ -32,6 +32,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" 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" rayon = "1"
# rendering / formats # rendering / formats
+1
View File
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
[build-dependencies] [build-dependencies]
winresource = "0.1" winresource = "0.1"
+36 -16
View File
@@ -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 { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); 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()) 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 /// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd ///
/// clock can't overflow). /// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
let z = days.saturating_add(719_468); /// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
let era = z / 146_097; /// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
let doe = z % 146_097; /// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; /// 1970-01-01 is `num_days_from_ce` 719_163.
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); fn civil_from_days(days: u64) -> (i64, u32, u32) {
let mp = (5 * doy + 2) / 153; use chrono::Datelike;
let d = doy - (153 * mp + 2) / 5 + 1; let (cycles, rest) = (days / 146_097, days % 146_097);
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // `rest + 719_163` is 719_163..865_259, always a valid CE day.
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
(y, m, d) 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 { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -385,8 +393,20 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not. // 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(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"); 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"); 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] #[test]
+1
View File
@@ -23,6 +23,7 @@ egui-wgpu = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
image = { workspace = true } image = { workspace = true }
[dev-dependencies] [dev-dependencies]
+29 -12
View File
@@ -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 { fn utc(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). let (y, mo, d) = civil_from_days(secs / 86_400);
let z = days + 719_468; // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
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);
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}") 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. /// The Drawing Recovery window, while autosave files from a crash are waiting.
pub fn show(app: &mut CadApp, ctx: &egui::Context) { pub fn show(app: &mut CadApp, ctx: &egui::Context) {
if app.recovery.items.is_empty() { 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(0), "1970-01-01 00:00");
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01"); 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(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");
} }
} }
+1
View File
@@ -7,3 +7,4 @@ publish = false
[dependencies] [dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }
chrono = { workspace = true }
+40 -18
View File
@@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec<String> {
/// Where the generated checklist goes. /// Where the generated checklist goes.
pub const OUT: &str = "docs/parity-checklist.md"; 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 { fn today() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); date(std::time::SystemTime::now())
let (y, m, d) = civil_from_days((secs / 86_400) as i64); }
/// `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}") format!("{y:04}-{m:02}-{d:02}")
} }
/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar. /// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
fn civil_from_days(z: i64) -> (i64, u32, u32) { ///
let z = z + 719_468; /// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past
let era = z.div_euclid(146_097); /// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097
let doe = z.rem_euclid(146_097); /// days), so chrono converts the day within one cycle and whole cycles go back into the year (an
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; /// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); /// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
let mp = (5 * doy + 2) / 153; fn civil_from_days(days: u64) -> (i64, u32, u32) {
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; use chrono::Datelike;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let (cycles, rest) = (days / 146_097, days % 146_097);
let y = yoe + era * 400 + i64::from(m <= 2); // `rest + 719_163` is 719_163..865_259, always a valid CE day.
(y, m, d) 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> { pub fn run(root: &Path) -> Result<(), String> {
@@ -116,9 +130,17 @@ mod tests {
#[test] #[test]
fn civil_dates() { fn civil_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1)); let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400));
assert_eq!(civil_from_days(11_016), (2000, 2, 29)); assert_eq!(day(0), "1970-01-01");
assert_eq!(civil_from_days(20_736), (2026, 10, 10)); 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] #[test]