From caea05eff8d2044136d4befe499db7b93b4dcd03 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 07:59:58 -0400 Subject: [PATCH] Use jiff for UTC/calendar conversion instead of hand-rolled copies This repo already uses jiff (ui-egui, for the system time zone), so the hand-rolled Howard Hinnant civil/days-from-civil copies move to jiff rather than pulling in a second time crate. jiff is lifted to a workspace dependency: - crates/core/src/time.rs: parse_rfc3339's days_from_civil and civil_from_unix - xtask/src/local.rs: rfc3339 (feed timestamps) ui-egui now takes jiff from the workspace. core keeps only civil-date math (no time zone), so the wasm build is unaffected. Output and parsing semantics are unchanged and pinned by the existing tests (RFC 3339 parsing, leap days, day rollover, the feed's YYYY-MM-DDTHH:MM:SSZ). AGENTS.md now says time goes through jiff. Follow-up to storytold/cadcraft#1785 --- AGENTS.md | 2 ++ Cargo.lock | 4 ++++ Cargo.toml | 2 ++ crates/core/Cargo.toml | 2 ++ crates/core/src/time.rs | 38 ++++++++++++++++---------------------- crates/ui-egui/Cargo.toml | 2 +- xtask/Cargo.toml | 3 +++ xtask/src/local.rs | 17 +++++------------ 8 files changed, 35 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 43670025..316c267d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,8 @@ only; English where there is none): never bundle a CJK font, they are 10 MB and GitHub release JSON fixtures (small, committed under `crates/core/tests/fixtures/`). 5. When fetching anything, use a generic `ArtCraft-Launcher/` User-Agent and never put a person's name, email or other personal details in requests. +6. **Time goes through `jiff`** (workspace dependency). Never hand-roll UTC/calendar conversion + (days-to-date algorithms, leap-year math); a module owns only its output format string. ## 3. Before you finish a task diff --git a/Cargo.lock b/Cargo.lock index b381bfc1..e656d002 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,6 +303,7 @@ dependencies = [ name = "artcraft-launcher-core" version = "0.6.1" dependencies = [ + "jiff", "semver", "serde", "serde_json", @@ -5875,6 +5876,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "xtask" version = "0.6.1" +dependencies = [ + "jiff", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index 673fdbac..805f2a7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" semver = { version = "1", features = ["serde"] } log = "0.4" +# All date/time conversion goes through jiff (no hand-rolled calendar code; AGENTS.md). +jiff = "0.2" [profile.dev.package."*"] opt-level = 2 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4806c1ce..c518d780 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -14,3 +14,5 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } semver = { workspace = true } +# Calendar conversion (jiff); see AGENTS.md. Only civil-date math is used, no time zone. +jiff = { workspace = true } diff --git a/crates/core/src/time.rs b/crates/core/src/time.rs index 22b580f4..34c19323 100644 --- a/crates/core/src/time.rs +++ b/crates/core/src/time.rs @@ -1,4 +1,5 @@ -//! Just enough calendar to read GitHub's timestamps and show them, without a date crate. +//! Just enough calendar to read GitHub's timestamps and show them (the conversion goes through +//! jiff; this module owns only the parsing and the output format strings). //! //! # Internationalisation //! [`format_ago`] and [`format_date`] are English-only fallbacks. Translatable UI must use @@ -64,32 +65,25 @@ pub fn parse_rfc3339(text: &str) -> Option { } _ => return None, }; - Some(days_from_civil(y, mo, d) * 86_400 + h * 3600 + mi * 60 + s - offset) + Some(days_from_civil(y, mo, d)? * 86_400 + h * 3600 + mi * 60 + s - offset) } -/// Days since 1970-01-01 (Howard Hinnant's algorithm). -fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { - let y = if m <= 2 { y - 1 } else { y }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let mp = (m + 9) % 12; - let doy = (153 * mp + 2) / 5 + d - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146_097 + doe - 719_468 +/// Days since 1970-01-01 (through jiff's civil calendar). A day past the month's length rolls over +/// the way the old Hinnant arithmetic did (e.g. `2026-02-30` → `2026-03-02`). `None` for a year +/// outside jiff's range. +fn days_from_civil(y: i64, m: i64, d: i64) -> Option { + let first = jiff::civil::Date::new(i16::try_from(y).ok()?, i8::try_from(m).ok()?, 1).ok()?; + let date = first.checked_add(jiff::SignedDuration::from_hours(d.checked_sub(1)?.checked_mul(24)?)).ok()?; + Some(date.duration_since(jiff::civil::Date::constant(1970, 1, 1)).as_secs() / 86_400) } -/// (year, month, day) for a Unix time. +/// (year, month, day) for a Unix time (UTC). A time outside jiff's calendar range reads as the +/// epoch rather than panicking. pub fn civil_from_unix(secs: i64) -> (i64, u32, u32) { - let z = secs.div_euclid(86_400) + 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 m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - (y, u32::try_from(m).unwrap_or(1), u32::try_from(d).unwrap_or(1)) + let epoch = jiff::civil::Date::constant(1970, 1, 1); + let whole_days = jiff::SignedDuration::from_secs(secs.div_euclid(86_400).saturating_mul(86_400)); + let date = epoch.checked_add(whole_days).unwrap_or(epoch); + (i64::from(date.year()), u32::try_from(date.month()).unwrap_or(1), u32::try_from(date.day()).unwrap_or(1)) } const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 235558cd..aebc6962 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -24,7 +24,7 @@ sys-locale = "0.3" # Finding the Simplified Chinese face in a system font collection; egui already uses it. skrifa = { version = "0.44", default-features = false, features = ["std"] } # The system time zone, so dates read as this computer's calendar; already in the desktop build. -jiff = "0.2" +jiff = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # The browser's time zone, for the same dates. diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index f027cec0..941b3d56 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,3 +7,6 @@ publish = false [lints] workspace = true + +[dependencies] +jiff = { workspace = true } diff --git a/xtask/src/local.rs b/xtask/src/local.rs index f818d21d..eae1fea3 100644 --- a/xtask/src/local.rs +++ b/xtask/src/local.rs @@ -493,20 +493,13 @@ fn unix_now() -> i64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(0)) } -/// `2026-10-10T12:00:00Z` (civil-from-days, Howard Hinnant's algorithm). +/// `2026-10-10T12:00:00Z` (through jiff's civil calendar). fn rfc3339(secs: i64) -> String { - let days = secs.div_euclid(86_400); let rem = secs.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z.rem_euclid(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 = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60) + let epoch = jiff::civil::Date::constant(1970, 1, 1); + let whole_days = jiff::SignedDuration::from_secs(secs.div_euclid(86_400).saturating_mul(86_400)); + let date = epoch.checked_add(whole_days).unwrap_or(epoch); + format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", date.year(), date.month(), date.day(), rem / 3600, rem % 3600 / 60, rem % 60) } // ---- versions -----------------------------------------------------------------------------------