Files
cadcraft/xtask/src/parity.rs
T
2ez4gcxandClaude Opus 5.5 9a1f200795 xtask: the test suite and clippy pass on Windows (#2198)
* xtask: the test suite and clippy pass on Windows

Two Windows-only failures stopped `cargo xtask ci` before it got going on
a Windows checkout (CI runs the gate on Linux only, so neither showed):

- parity::tests::civil_dates built a SystemTime 115,740,740 days after
  the epoch (year 318857). Windows' SystemTime ends in the year 30828, so
  the addition panicked ("overflow when adding duration to `SystemTime`").
  That case now checks `civil_from_days` on the day count, which is what
  it was about, on every platform.
- stats.rs imported `walk` for a test that only exists on Unix, so the
  import was unused on Windows and `clippy -D warnings` refused it. The
  Unix test calls `super::walk`, as its neighbour calls `super::walk_inner`.

Checked on Windows 11 x64: `cargo test --workspace` and
`cargo clippy --workspace --all-targets -- -D warnings` both pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: add 2ez4gcx (Doan Huynh Huu Khuong)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:21:20 -04:00

154 lines
7.0 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`).
// Checked on the day count: Windows' SystemTime ends in the year 30828, so building that
// SystemTime panics there.
assert_eq!(civil_from_days(115_740_740), (318_857, 5, 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"]);
}
}