lightcraft/xtask/src/parity.rs

461 lines
18 KiB
Rust

//! `cargo xtask parity`: the Lightroom parity tracker (`docs/parity-checklist.md`).
//!
//! - **Reference check** (in `ci`): every `` `cmd:<id>` `` must be a registered command (engine command specs, as
//! listed by `lightcraft-cli commands --json`, or a UI command from `UI_COMMANDS`), every `` `ctl:<id>` `` a develop
//! control (`lightcraft-cli controls --json`), and every repository path in backticks must exist (with `:line`
//! inside the file). A trailing `*` matches an id prefix. Row ids must be unique and statuses valid.
//! - **Summary**: per section done / partial / missing / out of scope and the share of P0 and P1 rows done;
//! `--write` refreshes the table between the `parity:summary` markers in the document.
use std::collections::{BTreeSet, HashSet};
use std::path::Path;
pub const DOC: &str = "docs/parity-checklist.md";
const BEGIN: &str = "<!-- parity:summary -->";
const END: &str = "<!-- /parity:summary -->";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Done,
Partial,
Missing,
OutOfScope,
}
impl Status {
fn parse(cell: &str) -> Option<Status> {
let c = cell.trim_start();
[("✅", Status::Done), ("🟡", Status::Partial), ("⬜", Status::Missing), ("🚫", Status::OutOfScope)]
.into_iter()
.find(|(e, _)| c.starts_with(e))
.map(|(_, s)| s)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Ref {
Cmd(String),
Ctl(String),
/// Repository-relative path, optional 1-based line.
Path(String, Option<usize>),
}
#[derive(Clone, Debug)]
pub struct Row {
pub id: String,
pub tier: String,
pub status: Status,
pub section: String,
}
#[derive(Debug, Default)]
pub struct Doc {
pub rows: Vec<Row>,
/// (line, reference) for every reference anywhere in the document.
pub refs: Vec<(usize, Ref)>,
pub errors: Vec<String>,
}
fn is_row_id(s: &str) -> bool {
let Some((prefix, rest)) = s.split_once('-') else { return false };
matches!(prefix, "LR" | "LRC" | "MENU" | "KEY" | "KEYC")
&& !rest.is_empty()
&& rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
}
/// Directories whose paths are checked (others, e.g. the local-only `plan/`, are ignored).
const CHECKED_DIRS: &[&str] = &["crates/", "apps/", "docs/", "xtask/", "assets/"];
/// A concrete id (not a placeholder such as `cmd:<id>` in prose).
fn concrete(id: &str) -> bool {
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '*'))
}
fn classify(span: &str) -> Option<Ref> {
if let Some(id) = span.strip_prefix("cmd:").filter(|id| concrete(id)) {
return Some(Ref::Cmd(id.to_string()));
}
if let Some(id) = span.strip_prefix("ctl:").filter(|id| concrete(id)) {
return Some(Ref::Ctl(id.to_string()));
}
if CHECKED_DIRS.iter().any(|d| span.starts_with(d)) && !span.contains(' ') {
return Some(match span.rsplit_once(':') {
Some((p, l)) if l.parse::<usize>().is_ok() => Ref::Path(p.to_string(), l.parse().ok()),
_ => Ref::Path(span.to_string(), None),
});
}
None
}
/// Inline-code spans of one line (single backticks).
fn code_spans(line: &str) -> Vec<&str> {
line.split('`').skip(1).step_by(2).collect()
}
pub fn parse(md: &str) -> Doc {
let mut doc = Doc::default();
let mut section = String::new();
let mut seen = HashSet::new();
let mut in_comment = false;
for (i, line) in md.lines().enumerate() {
let n = i + 1;
let t = line.trim();
if t.starts_with("<!--") && !t.contains("-->") {
in_comment = true;
}
if in_comment {
in_comment = !t.contains("-->");
continue;
}
if let Some(h) = t.strip_prefix("## ") {
section = h.trim().to_string();
continue;
}
for span in code_spans(line) {
if let Some(r) = classify(span) {
doc.refs.push((n, r));
}
}
if !t.starts_with('|') {
continue;
}
let cells: Vec<&str> = t.trim_matches('|').split('|').map(str::trim).collect();
let Some(id) = cells.first().copied().filter(|c| is_row_id(c)) else { continue };
if cells.len() < 6 {
doc.errors.push(format!("{DOC}:{n}: {id}: expected 6 columns (Id | Feature | Tier | Status | Evidence | Notes)"));
continue;
}
let Some(status) = Status::parse(cells[3]) else {
doc.errors.push(format!("{DOC}:{n}: {id}: status `{}` is not one of ✅ 🟡 ⬜ 🚫", cells[3]));
continue;
};
if !seen.insert(id.to_string()) {
doc.errors.push(format!("{DOC}:{n}: duplicate id {id}"));
}
doc.rows.push(Row { id: id.to_string(), tier: cells[2].to_string(), status, section: section.clone() });
}
doc
}
/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language
/// commands live in their own table, which the Language menu builds from the i18n language list.
pub fn ui_command_ids(menus_rs: &str) -> Vec<String> {
let mut ids = Vec::new();
for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] {
let Some(start) = menus_rs.find(table) else { continue };
let body = &menus_rs[start..];
let body = &body[..body.find("];").unwrap_or(body.len())];
ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())));
}
ids
}
fn known(set: &BTreeSet<String>, id: &str) -> bool {
match id.strip_suffix('*') {
Some(prefix) => set.iter().any(|s| s.starts_with(prefix)),
None => set.contains(id),
}
}
/// Reference problems (empty = OK). `file_lines` returns a repository file's line count (None = missing).
pub fn check(doc: &Doc, commands: &BTreeSet<String>, controls: &BTreeSet<String>, file_lines: impl Fn(&str) -> Option<usize>) -> Vec<String> {
let mut out = doc.errors.clone();
for (n, r) in &doc.refs {
match r {
Ref::Cmd(id) if !known(commands, id) => out.push(format!("{DOC}:{n}: unknown command `cmd:{id}`")),
Ref::Ctl(id) if !known(controls, id) => out.push(format!("{DOC}:{n}: unknown develop control `ctl:{id}`")),
Ref::Path(p, line) => match (file_lines(p), line) {
(None, _) => out.push(format!("{DOC}:{n}: missing path `{p}`")),
(Some(len), Some(l)) if *l == 0 || *l > len => out.push(format!("{DOC}:{n}: `{p}:{l}` is past the end ({len} lines)")),
_ => {}
},
_ => {}
}
}
out
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Counts {
pub done: usize,
pub partial: usize,
pub missing: usize,
pub oos: usize,
pub p0: (usize, usize),
pub p1: (usize, usize),
}
impl Counts {
fn add(&mut self, r: &Row) {
match r.status {
Status::Done => self.done += 1,
Status::Partial => self.partial += 1,
Status::Missing => self.missing += 1,
Status::OutOfScope => self.oos += 1,
}
let done = usize::from(r.status == Status::Done);
match r.tier.as_str() {
"P0" => self.p0 = (self.p0.0 + done, self.p0.1 + 1),
"P1" => self.p1 = (self.p1.0 + done, self.p1.1 + 1),
_ => {}
}
}
}
/// Per-section counts in document order, plus the total.
pub fn summarize(doc: &Doc) -> (Vec<(String, Counts)>, Counts) {
let mut sections: Vec<(String, Counts)> = Vec::new();
let mut total = Counts::default();
for r in &doc.rows {
if sections.last().is_none_or(|(s, _)| *s != r.section) {
sections.push((r.section.clone(), Counts::default()));
}
sections.last_mut().expect("pushed").1.add(r);
total.add(r);
}
(sections, total)
}
fn pct((done, n): (usize, usize)) -> String {
match (done * 100 + n / 2).checked_div(n) {
Some(p) => format!("{done}/{n} ({p}%)"),
None => "—".into(),
}
}
pub fn summary_table(doc: &Doc) -> String {
let (sections, total) = summarize(doc);
let mut s = String::from("| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |\n|---|---:|---:|---:|---:|---:|---:|\n");
let row =
|name: &str, c: &Counts| format!("| {name} | {} | {} | {} | {} | {} | {} |\n", c.done, c.partial, c.missing, c.oos, pct(c.p0), pct(c.p1));
for (name, c) in &sections {
s += &row(name, c);
}
s += &row("**Total**", &total);
s += &format!("\n{}\n", weighted_line(doc));
s
}
/// Weighted completion of the in-scope rows (✅ = 1, 🟡 = ½, ⬜ = 0; 🚫 left out) for `tier`
/// (`None` = all): (percent, rows).
pub fn weighted(doc: &Doc, tier: Option<&str>) -> (f64, usize) {
let rows: Vec<&Row> = doc.rows.iter().filter(|r| r.status != Status::OutOfScope && tier.is_none_or(|t| r.tier == t)).collect();
let score: f64 = rows
.iter()
.map(|r| match r.status {
Status::Done => 1.0,
Status::Partial => 0.5,
_ => 0.0,
})
.sum();
(if rows.is_empty() { 0.0 } else { score * 100.0 / rows.len() as f64 }, rows.len())
}
/// "Weighted completion: 65.7% of 500 rows (P0 …, P1 …, P2 …)".
pub fn weighted_line(doc: &Doc) -> String {
let (all, n) = weighted(doc, None);
let tiers: Vec<String> = ["P0", "P1", "P2"]
.iter()
.filter_map(|t| Some(weighted(doc, Some(t))).filter(|w| w.1 > 0).map(|(p, n)| format!("{t} {p:.1}% of {n}")))
.collect();
format!("Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **{all:.1}%** of {n} in-scope rows — {}.", tiers.join(" · "))
}
/// Replace the summary between the markers.
pub fn with_summary(md: &str, table: &str) -> Option<String> {
let b = md.find(BEGIN)? + BEGIN.len();
let e = md[b..].find(END)? + b;
Some(format!("{}\n{}{}", &md[..b], table, &md[e..]))
}
fn registry(what: &str) -> Result<BTreeSet<String>, String> {
let out = crate::cargo()
.args(["run", "-q", "-p", "lightcraft-cli", "--", what, "--json"])
.output()
.map_err(|e| format!("lightcraft-cli {what}: {e}"))?;
if !out.status.success() {
return Err(format!("lightcraft-cli {what} --json failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("lightcraft-cli {what}: bad JSON: {e}"))?;
Ok(v.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect())
}
pub fn run(root: &Path, write: bool) -> Result<(), String> {
let path = root.join(DOC);
let md = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let doc = parse(&md);
let menus = std::fs::read_to_string(root.join("crates/ui-egui/src/menus.rs")).map_err(|e| format!("menus.rs: {e}"))?;
let mut commands = registry("commands")?;
commands.extend(ui_command_ids(&menus));
let controls = registry("controls")?;
let problems = check(&doc, &commands, &controls, |p| {
let full = root.join(p);
if full.is_dir() { Some(0) } else { std::fs::read_to_string(full).ok().map(|s| s.lines().count()) }
});
let table = summary_table(&doc);
println!("Lightroom parity ({DOC}): {} rows, {} references\n", doc.rows.len(), doc.refs.len());
print!("{table}");
if write {
let new = with_summary(&md, &table).ok_or_else(|| format!("{DOC}: summary markers `{BEGIN}` … `{END}` not found"))?;
if new != md {
std::fs::write(&path, new).map_err(|e| format!("{}: {e}", path.display()))?;
println!("\nupdated the summary in {DOC}");
}
} else if with_summary(&md, &table).is_some_and(|new| new != md) {
println!("\nnote: the summary in {DOC} is stale — run `cargo xtask parity --write`");
}
// Ids from the local Lightroom reference that have no row yet (plan/ is local-only, so only a warning).
let catalog = root.join("plan/lightroom/03-feature-catalog.md");
if let Ok(cat) = std::fs::read_to_string(&catalog) {
let have: HashSet<&str> = doc.rows.iter().map(|r| r.id.as_str()).collect();
let missing: Vec<&str> = cat
.lines()
.filter_map(|l| l.trim().strip_prefix("| ")?.split(['|', ' ']).next())
.filter(|id| id.starts_with("LR-") && !have.contains(id))
.collect();
if !missing.is_empty() {
println!("\nwarning: catalog ids without a row: {}", missing.join(", "));
}
}
if problems.is_empty() {
println!("\nOK: every command, control and path referenced in {DOC} exists.");
Ok(())
} else {
println!();
for p in &problems {
println!(" - {p}");
}
Err(format!("{} stale reference(s) in {DOC}", problems.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# Tracker
Prose mentioning `cmd:edit.undo`, placeholders `cmd:<id>` / `ctl:` and `plan/lightroom/03.md` (not checked) and `rating:3`.
<!-- parity:summary -->
old
<!-- /parity:summary -->
<!--
| LR-IGNORED-ROW | in a comment | P0 | ✅ | `cmd:nope` | |
-->
## A. Import (IMP)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-IMP-ONE | One | P0 | ✅ | `cmd:library.import`, `crates/engine/src/import.rs:12` | |
| LR-IMP-TWO | Two | P1 | 🟡 | `ctl:light.*` | partial |
| LR-IMP-THREE | Three | OOS | 🚫 | | |
| Action | Ours | Theirs |
|---|---|---|
| Pick flag | P | Z |
## B. Library (LIB)
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|---|---|---|---|---|---|
| LR-LIB-ONE | One | P0 | ⬜ | `cmd:album.nope` | |
| LR-LIB-ONE | Dup | P1 | done | | |
| KEY-SHORT | too | few |
";
fn set(ids: &[&str]) -> BTreeSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_rows_sections_and_statuses() {
let d = parse(SAMPLE);
let ids: Vec<&str> = d.rows.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, ["LR-IMP-ONE", "LR-IMP-TWO", "LR-IMP-THREE", "LR-LIB-ONE"]);
assert_eq!(d.rows[0].section, "A. Import (IMP)");
assert_eq!(d.rows[3].section, "B. Library (LIB)");
assert_eq!(d.rows[1].status, Status::Partial);
assert_eq!(d.rows[2].status, Status::OutOfScope);
assert_eq!(d.rows[1].tier, "P1");
// the bad-status duplicate and the short row are errors; the comment row is ignored
assert_eq!(d.errors.len(), 2, "{:?}", d.errors);
assert!(d.errors.iter().any(|e| e.contains("status `done`")));
assert!(d.errors.iter().any(|e| e.contains("KEY-SHORT")));
}
#[test]
fn collects_references_everywhere() {
let d = parse(SAMPLE);
let refs: Vec<&Ref> = d.refs.iter().map(|(_, r)| r).collect();
assert!(refs.contains(&&Ref::Cmd("edit.undo".into())));
assert!(refs.contains(&&Ref::Path("crates/engine/src/import.rs".into(), Some(12))));
assert!(refs.contains(&&Ref::Ctl("light.*".into())));
assert!(!refs.iter().any(|r| matches!(r, Ref::Cmd(id) if id == "nope")), "comment rows are skipped");
assert!(!refs.iter().any(|r| matches!(r, Ref::Path(p, _) if p.starts_with("plan/"))));
assert_eq!(refs.len(), 5);
}
#[test]
fn check_reports_unknown_ids_and_paths() {
let d = parse(SAMPLE);
let cmds = set(&["edit.undo", "library.import"]);
let ctls = set(&["light.exposure"]);
let problems = check(&d, &cmds, &ctls, |p| (p == "crates/engine/src/import.rs").then_some(100));
let unknown: Vec<&String> = problems.iter().filter(|p| p.contains("unknown")).collect();
assert_eq!(unknown.len(), 1, "{problems:?}");
assert!(unknown[0].contains("cmd:album.nope"));
// a line past the end of the file is stale too
let short = check(&d, &cmds, &ctls, |_| Some(5));
assert!(short.iter().any(|p| p.contains("import.rs:12")));
// missing files and unknown controls
let none = check(&d, &cmds, &set(&[]), |_| None);
assert!(none.iter().any(|p| p.contains("missing path")));
assert!(none.iter().any(|p| p.contains("ctl:light.*")));
}
#[test]
fn summary_counts_and_rewrites_between_markers() {
let d = parse(SAMPLE);
let (sections, total) = summarize(&d);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].1, Counts { done: 1, partial: 1, missing: 0, oos: 1, p0: (1, 1), p1: (0, 1) });
assert_eq!(total.p0, (1, 2));
let table = summary_table(&d);
assert!(table.contains("| A. Import (IMP) | 1 | 1 | 0 | 1 | 1/1 (100%) | 0/1 (0%) |"), "{table}");
assert!(table.contains("| **Total** | 1 | 1 | 1 | 1 | 1/2 (50%) | 0/1 (0%) |"), "{table}");
// three in-scope rows: 1 + ½ + 0
assert_eq!(weighted(&d, None), (50.0, 3));
assert_eq!(weighted(&d, Some("P0")), (50.0, 2));
assert!(table.contains("**50.0%** of 3 in-scope rows — P0 50.0% of 2 · P1 50.0% of 1."), "{table}");
let new = with_summary(SAMPLE, &table).unwrap();
assert!(!new.contains("\nold\n"));
assert!(new.contains(&format!("{BEGIN}\n{table}{END}")));
assert_eq!(with_summary(&new, &table).unwrap(), new, "idempotent");
}
#[test]
fn reads_ui_command_ids() {
let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }";
assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]);
let languages =
"pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n";
assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]);
}
/// The real menu tables: both are found, so every language command is a known id.
#[test]
fn reads_the_language_commands_from_menus_rs() {
let menus = include_str!("../../crates/ui-egui/src/menus.rs");
let ids = ui_command_ids(menus);
for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese", "app.language.portuguese"] {
assert!(ids.iter().any(|known| known == id), "{id} not found");
}
assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language");
}
}