Files
cadcraft/xtask/src/parity.rs
T
Dylan M. Taylor acd109d97c 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.
2026-10-22 14:09:16 -04:00

152 lines
6.8 KiB
Rust

//! `cargo xtask parity`: compare the reference app's menu tree (feature names only, in
//! `xtask/data/menu-catalog.txt`) with the live command registry (`cadcraft-cli commands`), and
//! write `docs/parity-checklist.md` (dated; the assessment that interprets it is
//! `docs/target-app-parity.md`).
use std::path::Path;
use std::process::Command;
/// Leaf menu items with their path ("Draw > Circle > 3 Points").
pub fn leaves(catalog: &str) -> Vec<String> {
let lines: Vec<&str> = catalog.lines().filter(|l| !l.trim().is_empty()).collect();
let mut stack: Vec<(usize, String)> = Vec::new();
let mut out = Vec::new();
for (i, l) in lines.iter().enumerate() {
let ind = l.len() - l.trim_start().len();
while stack.last().is_some_and(|(d, _)| *d >= ind) {
stack.pop();
}
stack.push((ind, l.trim().to_string()));
let next_ind = lines.get(i + 1).map(|n| n.len() - n.trim_start().len()).unwrap_or(0);
if next_ind <= ind && ind > 0 {
out.push(stack.iter().map(|(_, s)| s.as_str()).collect::<Vec<_>>().join(" > "));
}
}
out
}
/// 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).
fn today() -> String {
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 → 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> {
let catalog = std::fs::read_to_string(root.join("xtask/data/menu-catalog.txt")).map_err(|e| format!("menu catalog: {e}"))?;
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.current_dir(root)
.args(["run", "-q", "-p", "cadcraft-cli", "--", "commands"])
.output()
.map_err(|e| format!("cadcraft-cli: {e}"))?;
let cmds: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
let paths: Vec<String> = cmds
.as_array()
.map(|a| {
a.iter()
.filter_map(|c| c["menu"].as_array())
.filter(|m| !m.is_empty())
.map(|m| m.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" > ").to_ascii_lowercase())
.collect()
})
.unwrap_or_default();
let labels: Vec<String> = paths.iter().filter_map(|p| p.rsplit(" > ").next().map(str::to_string)).collect();
let ls = leaves(&catalog);
let mut by_menu: Vec<(String, usize, usize)> = Vec::new();
let mut missing = Vec::new();
let mut hit = 0;
for l in &ls {
let top = l.split(" > ").next().unwrap_or("").to_string();
let lower = l.to_ascii_lowercase();
let leaf = lower.rsplit(" > ").next().unwrap_or("").to_string();
let ok = paths.contains(&lower) || labels.contains(&leaf);
if ok {
hit += 1;
} else {
missing.push(l.clone());
}
match by_menu.iter_mut().find(|(m, ..)| *m == top) {
Some(e) => {
e.1 += 1;
e.2 += usize::from(ok);
}
None => by_menu.push((top, 1, usize::from(ok))),
}
}
let pct = |a: usize, b: usize| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 };
let mut md = format!(
"# Parity checklist\n\n> **Generated:** {} by `cargo xtask parity`. Do not edit by hand.\n\nMenu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment.\n\n**Menu breadth: {hit} / {} items ({:.0}%)** · {} registered commands.\n\n| Menu | Items | Covered | % |\n|---|---|---|---|\n",
today(),
ls.len(),
pct(hit, ls.len()),
cmds.as_array().map(Vec::len).unwrap_or(0)
);
for (m, n, k) in &by_menu {
md += &format!("| {m} | {n} | {k} | {:.0}% |\n", pct(*k, *n));
}
md += "\n## Not yet covered\n\n";
for m in &missing {
md += &format!("- {m}\n");
}
std::fs::write(root.join(OUT), md).map_err(|e| format!("{OUT}: {e}"))?;
println!("parity: {hit}/{} menu items ({:.0}%) → {OUT}", ls.len(), pct(hit, ls.len()));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_dates() {
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]
fn leaves_with_paths() {
let c = "Draw\n Line\n Circle\n 3 Points\n 2 Points\nModify\n Move\n";
assert_eq!(leaves(c), vec!["Draw > Line", "Draw > Circle > 3 Points", "Draw > Circle > 2 Points", "Modify > Move"]);
}
}