M0: xtask layers, parity, oracle, assets, ci; Fusion command catalog

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:34:53 +00:00
1 parent 0983f80f01
commit 3714342737
6 files changed
+2285 -1

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
//! `cargo xtask parity`: Fusion Design-workspace command parity.
//!
//! `xtask/data/fusion-catalog.tsv` lists the toolbar commands of Fusion's Design workspace as
//! `tab \t panel \t command id \t command name` (ids and names only, nothing else). It is
//! derived from the local, uncommitted `plan/fusion/menu-tree.json` with `--refresh`. A catalog
//! entry is live when SolveCraft registers a command with the same id.
use std::path::Path;
use std::process::Command;
use serde_json::Value;
/// Design workspace tabs in the catalog (menu-tree tab id, display name).
const TABS: &[(&str, &str)] = &[
("SolidTab", "SOLID"),
("SketchTab", "SKETCH"),
("SurfaceTab", "SURFACE"),
("ParaMeshOuterTab", "MESH"),
("FormTab", "FORM"),
("SheetMetalTab", "SHEET METAL"),
("PlasticTab", "PLASTIC"),
("AssemblyTab", "ASSEMBLY"),
("ManageTab", "MANAGE"),
("ToolsTab", "UTILITIES"),
];
/// The tabs the headline number counts (M0 scope).
const HEADLINE: &[&str] = &["SOLID", "SKETCH"];
#[derive(Clone, Debug)]
pub struct Entry {
pub tab: String,
pub panel: String,
pub id: String,
pub name: String,
}
fn clean(s: &str) -> String {
s.replace(['\t', '\n', '\r'], " ").trim().to_string()
}
fn walk(controls: &[Value], defs: &[Value], tab: &str, panel: &str, out: &mut Vec<Entry>) {
for c in controls {
match c["type"].as_str() {
Some("CommandControl") => {
let id = c["command"]["id"].as_str().or(c["id"].as_str()).unwrap_or("");
if id.is_empty() {
continue;
}
let name = c["command"]["name"]
.as_str()
.filter(|n| !n.is_empty())
.or_else(|| defs.iter().find(|d| d["id"].as_str() == Some(id)).and_then(|d| d["name"].as_str()))
.unwrap_or(id);
if !out.iter().any(|e| e.tab == tab && e.id == id) {
out.push(Entry { tab: tab.into(), panel: panel.into(), id: clean(id), name: clean(name) });
}
}
Some("DropDownControl") => walk(c["controls"].as_array().map(Vec::as_slice).unwrap_or(&[]), defs, tab, panel, out),
_ => {}
}
}
}
/// Catalog entries from Fusion's menu-tree JSON.
pub fn from_menu_tree(v: &Value) -> Vec<Entry> {
let defs = v["commandDefinitions"].as_array().map(Vec::as_slice).unwrap_or(&[]);
let mut out = Vec::new();
for (tid, tname) in TABS {
let tab = &v["tabs"][format!("DesignProductType/{tid}")];
for p in tab["panels"].as_array().into_iter().flatten() {
let panel = clean(p["name"].as_str().unwrap_or("")).to_uppercase();
walk(p["controls"].as_array().map(Vec::as_slice).unwrap_or(&[]), defs, tname, &panel, &mut out);
}
}
out
}
pub fn parse_catalog(s: &str) -> Vec<Entry> {
s.lines()
.filter(|l| !l.starts_with('#') && !l.trim().is_empty())
.filter_map(|l| {
let mut it = l.split('\t');
Some(Entry { tab: it.next()?.into(), panel: it.next()?.into(), id: it.next()?.into(), name: it.next().unwrap_or("").into() })
})
.collect()
}
pub fn run(root: &Path, refresh: bool) -> Result<(), String> {
let cat_path = root.join("xtask/data/fusion-catalog.tsv");
if refresh {
let mt = root.join("plan/fusion/menu-tree.json");
let s = std::fs::read_to_string(&mt).map_err(|e| format!("{}: {e}", mt.display()))?;
let v: Value = serde_json::from_str(&s).map_err(|e| format!("menu-tree.json: {e}"))?;
let entries = from_menu_tree(&v);
let mut out = String::from("# Fusion Design workspace toolbar: tab, panel, command id, command name (ids and names only).\n# Regenerate with `cargo xtask parity --refresh` (needs the local plan/fusion/menu-tree.json).\n");
for e in &entries {
out += &format!("{}\t{}\t{}\t{}\n", e.tab, e.panel, e.id, e.name);
}
std::fs::create_dir_all(root.join("xtask/data")).map_err(|e| e.to_string())?;
std::fs::write(&cat_path, out).map_err(|e| format!("{}: {e}", cat_path.display()))?;
println!("parity: catalog refreshed with {} entries", entries.len());
}
let catalog = parse_catalog(&std::fs::read_to_string(&cat_path).map_err(|e| format!("{}: {e}", cat_path.display()))?);
let out = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
.current_dir(root)
.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "commands"])
.output()
.map_err(|e| format!("solvecraft-cli: {e}"))?;
let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
let ids: Vec<String> = cmds.as_array().into_iter().flatten().filter_map(|c| c["id"].as_str().map(str::to_string)).collect();
let live = |e: &Entry| ids.iter().any(|i| *i == e.id);
let pct = |a: usize, b: usize| if b == 0 { 0.0 } else { 100.0 * a as f64 / b as f64 };
let head: Vec<&Entry> = catalog.iter().filter(|e| HEADLINE.contains(&e.tab.as_str())).collect();
let head_live = head.iter().filter(|e| live(e)).count();
let all_live = catalog.iter().filter(|e| live(e)).count();
let mut md = format!(
"# Fusion command parity\n\nGenerated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.\n\n**SOLID + SKETCH: {head_live} / {} commands live ({:.0}%).** All Design tabs: {all_live} / {} ({:.0}%). Registered commands: {}.\n\n| Tab | Panel | Live | Total | % |\n|---|---|---:|---:|---:|\n",
head.len(),
pct(head_live, head.len()),
catalog.len(),
pct(all_live, catalog.len()),
ids.len()
);
let mut groups: Vec<(String, String, usize, usize)> = Vec::new();
for e in &catalog {
let l = usize::from(live(e));
match groups.iter_mut().find(|g| g.0 == e.tab && g.1 == e.panel) {
Some(g) => {
g.2 += l;
g.3 += 1;
}
None => groups.push((e.tab.clone(), e.panel.clone(), l, 1)),
}
}
for (t, p, l, n) in &groups {
md += &format!("| {t} | {p} | {l} | {n} | {:.0}% |\n", pct(*l, *n));
}
md += "\n## Live (SOLID, SKETCH)\n\n";
for e in head.iter().filter(|e| live(e)) {
md += &format!("- {} › {} › {} (`{}`)\n", e.tab, e.panel, e.name, e.id);
}
md += "\n## Not yet (SOLID, SKETCH)\n\n";
for e in head.iter().filter(|e| !live(e)) {
md += &format!("- {} › {} › {} (`{}`)\n", e.tab, e.panel, e.name, e.id);
}
std::fs::write(root.join("docs/parity.md"), md).map_err(|e| format!("docs/parity.md: {e}"))?;
println!("parity: SOLID+SKETCH {head_live}/{} ({:.0}%), all tabs {all_live}/{} → docs/parity.md", head.len(), pct(head_live, head.len()), catalog.len());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_from_menu_tree() {
let v: Value = serde_json::from_str(
r#"{"commandDefinitions":[{"id":"B","name":"Bee"}],"tabs":{"DesignProductType/SolidTab":{"panels":[{"name":"Create","controls":[
{"type":"CommandControl","id":"A","command":{"id":"A","name":"Aye"}},
{"type":"SeparatorControl","id":"s"},
{"type":"DropDownControl","id":"d","controls":[{"type":"CommandControl","id":"B","command":{"id":"B"}}]}]}]}}}"#,
)
.unwrap();
let e = from_menu_tree(&v);
assert_eq!(e.len(), 2);
assert_eq!((e[0].tab.as_str(), e[0].panel.as_str(), e[0].id.as_str(), e[0].name.as_str()), ("SOLID", "CREATE", "A", "Aye"));
assert_eq!(e[1].name, "Bee");
let tsv = "# c\nSOLID\tCREATE\tA\tAye\n";
assert_eq!(parse_catalog(tsv).len(), 1);
}
}