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 -----------------------------------------------------------------------------------