//! `cargo xtask parity [--check] [--out PATH]`: write `docs/parity-checklist.md` from the engine's command //! catalog (`gridcraft_engine::catalog::CATALOG`, Excel's ribbon and menu tree). A catalog id is //! implemented when a command with that id is registered (`gridcraft_engine::find_command`). //! //! `--check` fails if `docs/parity-checklist.md` is out of date instead of writing it; `--out` writes //! somewhere else. The rendering is pure (`render`) so it is unit-tested without the engine. use std::path::Path; #[cfg(feature = "engine")] use std::path::PathBuf; /// One catalog row as the renderer sees it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { pub id: String, pub tab: String, pub group: String, pub label: String, pub done: bool, } fn pct(done: usize, total: usize) -> f64 { if total == 0 { 0.0 } else { 100.0 * done as f64 / total as f64 } } /// The markdown for docs/parity-checklist.md. Ids are counted once (the first catalog row wins), tabs keep /// catalog order. pub fn render(entries: &[Entry]) -> String { let mut seen: Vec<&str> = Vec::new(); let mut unique: Vec<&Entry> = Vec::new(); for e in entries { if !seen.contains(&e.id.as_str()) { seen.push(&e.id); unique.push(e); } } let mut tabs: Vec<&str> = Vec::new(); for e in &unique { if !tabs.contains(&e.tab.as_str()) { tabs.push(&e.tab); } } let done = unique.iter().filter(|e| e.done).count(); let total = unique.len(); let mut s = String::new(); s += "# Excel parity checklist\n\n"; s += "\n\n"; s += "Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft \ registers a command with the same id. This measures breadth only; the overall assessment (depth, \ fidelity, effort) is in [target-app-parity.md](target-app-parity.md).\n\n"; s += &format!("**Overall: {done} / {total} commands implemented ({:.1}%).**\n\n", pct(done, total)); s += "| Tab | Implemented | Total | Parity |\n|---|---|---|---|\n"; for t in &tabs { let rows: Vec<&&Entry> = unique.iter().filter(|e| e.tab == *t).collect(); let d = rows.iter().filter(|e| e.done).count(); s += &format!("| {t} | {d} | {} | {:.0}% |\n", rows.len(), pct(d, rows.len())); } for t in &tabs { let rows: Vec<&&Entry> = unique.iter().filter(|e| e.tab == *t).collect(); let d = rows.iter().filter(|e| e.done).count(); s += &format!("\n## {t} ({d} / {}, {:.0}%)\n", rows.len(), pct(d, rows.len())); for (title, want) in [("Implemented", true), ("Missing", false)] { let list: Vec = rows.iter().filter(|e| e.done == want).map(|e| format!("- `{}` {} ({})", e.id, e.label, e.group)).collect(); if !list.is_empty() { s += &format!("\n{title}:\n\n{}\n", list.join("\n")); } } } s } #[cfg(feature = "engine")] fn entries() -> Vec { use gridcraft_engine::catalog::CATALOG; CATALOG .iter() .map(|(id, tab, group, label)| Entry { id: (*id).to_string(), tab: (*tab).to_string(), group: (*group).to_string(), label: (*label).to_string(), done: gridcraft_engine::find_command(id).is_some(), }) .collect() } #[cfg(feature = "engine")] fn summary_line() -> String { let (done, total) = gridcraft_engine::catalog::parity(); format!("parity: {done} / {total} catalog commands implemented ({:.1}%)", pct(done, total)) } #[cfg(feature = "engine")] pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { let mut check = false; let mut out: PathBuf = root.join("docs/parity-checklist.md"); let mut it = args.iter(); while let Some(a) = it.next() { match *a { "--check" => check = true, "--out" => out = PathBuf::from(it.next().ok_or("--out needs a path")?), other => return Err(format!("parity: unknown argument `{other}` (usage: cargo xtask parity [--check] [--out PATH])")), } } let text = render(&entries()); if check { let current = std::fs::read_to_string(&out).map_err(|e| format!("{}: {e}", out.display()))?; if current != text { return Err(format!("{} is out of date; run `cargo xtask parity`", out.display())); } println!("{} ({} up to date)", summary_line(), out.display()); return Ok(()); } if let Some(dir) = out.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?; } std::fs::write(&out, text).map_err(|e| format!("write {}: {e}", out.display()))?; println!("{} -> {}", summary_line(), out.display()); Ok(()) } #[cfg(not(feature = "engine"))] pub fn run(_root: &Path, _args: &[&str]) -> Result<(), String> { Err("xtask was built without the `engine` feature; run `cargo xtask parity` with default features".into()) } #[cfg(test)] mod tests { use super::*; fn e(id: &str, tab: &str, done: bool) -> Entry { Entry { id: id.into(), tab: tab.into(), group: "G".into(), label: id.to_uppercase(), done } } #[test] fn groups_by_tab_and_counts_unique_ids() { let s = render(&[e("file.new", "File", true), e("file.open", "File", false), e("home.bold", "Home", true), e("file.new", "Home", false)]); assert!(s.contains("**Overall: 2 / 3 commands implemented (66.7%).**"), "{s}"); assert!(s.contains("| File | 1 | 2 | 50% |"), "{s}"); assert!(s.contains("| Home | 1 | 1 | 100% |"), "{s}"); assert!(s.contains("## File (1 / 2, 50%)")); assert!(s.contains("Missing:\n\n- `file.open` FILE.OPEN (G)")); // File comes before Home (catalog order). assert!(s.find("## File").unwrap_or(usize::MAX) < s.find("## Home").unwrap_or(0)); } #[test] fn empty_catalog_renders() { assert!(render(&[]).contains("0 / 0 commands implemented (0.0%)")); } }