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

File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
//! `cargo xtask assets`: every non-code asset in the repository must have a row in
//! ATTRIBUTION.md (author, source, licence). Icons are drawn in code and need no file.
use std::path::Path;
const ASSET_EXT: &[&str] = &["png", "jpg", "jpeg", "svg", "ico", "icns", "ttf", "otf", "woff", "woff2", "gif", "bmp", "webp", "wav", "mp3", "stl", "step", "stp", "obj"];
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist"];
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
let name = e.file_name().to_string_lossy().to_string();
if p.is_dir() {
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
walk(&p, root, out);
}
} else if let Some(ext) = p.extension().map(|x| x.to_string_lossy().to_ascii_lowercase())
&& ASSET_EXT.contains(&ext.as_str())
&& let Ok(rel) = p.strip_prefix(root)
{
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
pub fn run(root: &Path) -> Result<(), String> {
let attribution = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
let mut files = Vec::new();
walk(root, root, &mut files);
files.sort();
let missing: Vec<&String> = files.iter().filter(|f| !attribution.contains(f.as_str())).collect();
if missing.is_empty() {
println!("assets: {} asset file(s), all attributed", files.len());
Ok(())
} else {
Err(format!("assets missing from ATTRIBUTION.md:\n{}", missing.iter().map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")))
}
}
+321
View File
@@ -0,0 +1,321 @@
//! Dependency layering rules (plan/architecture.md §3).
//!
//! Adapted from CADCraft's xtask (MIT OR Apache-2.0), see ATTRIBUTION.md.
//!
//! The rule engine works on a small, metadata-independent model so it can be
//! unit-tested; `from_metadata` builds that model from `cargo metadata`.
use serde_json::Value;
/// Where a workspace crate sits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Class {
/// Regular layered crate.
Layer(u8),
/// Layer 0, and additionally may depend on no workspace crate at all.
Standalone,
/// Test tooling: may depend on anything up to L5 (it sits at L6 for rule
/// purposes); other crates may use it only as a dev-dependency.
Testkit,
/// Binaries and build tooling: exempt from the rules.
Exempt,
}
impl Class {
fn layer(self) -> Option<u8> {
match self {
Class::Layer(l) => Some(l),
Class::Standalone => Some(0),
Class::Testkit => Some(6),
Class::Exempt => None,
}
}
}
/// The layering table. Names are package names without the `solvecraft-`
/// prefix.
pub const TABLE: &[(&str, Class)] = &[
("geom", Class::Layer(0)),
("sketch", Class::Layer(1)),
("kernel", Class::Layer(1)),
("render", Class::Layer(1)),
("doc", Class::Layer(2)),
("io", Class::Layer(3)),
("engine", Class::Layer(4)),
("ui-egui", Class::Layer(5)),
("testkit", Class::Testkit),
// apps and tooling
("solvecraft", Class::Exempt),
("cli", Class::Exempt),
("xtask", Class::Exempt),
];
/// Explicit orderings *within* a layer (earlier may be used by later).
pub const INTRA_LAYER_ORDER: &[&[&str]] = &[];
fn intra_layer_allowed(from: &str, to: &str) -> bool {
let (from, to) = (short_name(from), short_name(to));
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
(Some(f), Some(t)) => t < f,
_ => false,
})
}
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.
pub const UI_CRATES: &[&str] = &["egui", "eframe", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"];
/// First layer allowed to use UI crates.
pub const UI_MIN_LAYER: u8 = 5;
pub fn short_name(pkg: &str) -> &str {
pkg.strip_prefix("solvecraft-").unwrap_or(pkg)
}
pub fn classify(pkg: &str) -> Option<Class> {
let s = short_name(pkg);
TABLE.iter().find(|(n, _)| *n == s).map(|(_, c)| *c)
}
fn is_ui_crate(name: &str) -> bool {
UI_CRATES.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => name.starts_with(prefix),
None => name == *p,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepKind {
Normal,
Dev,
Build,
}
#[derive(Debug, Clone)]
pub struct Dep {
pub name: String,
pub kind: DepKind,
/// `true` if the dependency is a workspace member.
pub workspace: bool,
}
#[derive(Debug, Clone)]
pub struct Crate {
pub name: String,
pub deps: Vec<Dep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Violation {
Unregistered { krate: String },
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
StandaloneHasWorkspaceDep { krate: String, dep: String },
TestkitAsNormalDep { krate: String },
UiBelowL5 { krate: String, dep: String, layer: u8 },
}
impl std::fmt::Display for Violation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Violation::Unregistered { krate } => {
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
}
Violation::Upward { krate, dep, from, to, kind } => {
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
}
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
}
Violation::TestkitAsNormalDep { krate } => {
write!(f, "{krate}: solvecraft-testkit may only be a dev-dependency")
}
Violation::UiBelowL5 { krate, dep, layer } => {
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L5+")
}
}
}
}
/// Check all rules. Returns violations sorted for stable output.
pub fn check(crates: &[Crate]) -> Vec<Violation> {
let mut out = Vec::new();
for c in crates {
let Some(class) = classify(&c.name) else {
out.push(Violation::Unregistered { krate: c.name.clone() });
continue;
};
if class == Class::Exempt {
continue;
}
let layer = class.layer().unwrap_or(0);
for d in &c.deps {
// Self dev-dependencies (e.g. to enable features in tests) are fine.
if d.name == c.name {
continue;
}
if d.workspace {
if class == Class::Standalone {
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
continue;
}
match classify(&d.name) {
// Unregistered deps are reported on their own entry.
None => {}
Some(Class::Testkit) => {
if d.kind != DepKind::Dev {
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
}
}
Some(dc) => {
let to = dc.layer().unwrap_or(u8::MAX);
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
out.push(Violation::Upward {
krate: c.name.clone(),
dep: d.name.clone(),
from: layer,
to: if to == u8::MAX { 7 } else { to },
kind: d.kind,
});
}
}
}
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
out.push(Violation::UiBelowL5 { krate: c.name.clone(), dep: d.name.clone(), layer });
}
}
}
out.sort_by_key(|v| v.to_string());
out.dedup();
out
}
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
let mut out = Vec::new();
for p in pkgs {
let name = p["name"].as_str().ok_or("package without name")?.to_owned();
let mut deps = Vec::new();
for d in p["dependencies"].as_array().into_iter().flatten() {
let dname = d["name"].as_str().unwrap_or_default().to_owned();
let kind = match d["kind"].as_str() {
Some("dev") => DepKind::Dev,
Some("build") => DepKind::Build,
_ => DepKind::Normal,
};
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
deps.push(Dep { name: dname, kind, workspace });
}
out.push(Crate { name, deps });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn describe(class: Option<Class>) -> String {
match class {
Some(Class::Layer(l)) => format!("L{l}"),
Some(Class::Standalone) => "L0 standalone".into(),
Some(Class::Testkit) => "testkit".into(),
Some(Class::Exempt) => "exempt".into(),
None => "UNREGISTERED".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
}
use DepKind::*;
#[test]
fn clean_downward_graph_passes() {
let g = [
c("solvecraft-geom", &[("kurbo", Normal, false)]),
c("solvecraft-doc", &[("solvecraft-geom", Normal, true)]),
c("solvecraft-engine", &[("solvecraft-doc", Normal, true), ("solvecraft-testkit", Dev, true)]),
c("solvecraft-ui-egui", &[("solvecraft-engine", Normal, true), ("egui", Normal, false)]),
c("solvecraft-cli", &[("solvecraft-ui-egui", Normal, true)]),
];
assert!(check(&g).is_empty(), "{:?}", check(&g));
}
#[test]
fn upward_dependency_flagged() {
let v = check(&[c("solvecraft-doc", &[("solvecraft-engine", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 4, .. }]));
}
#[test]
fn sideways_dependency_flagged() {
let v = check(&[c("solvecraft-kernel", &[("solvecraft-sketch", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 1, to: 1, .. }]));
}
#[test]
fn self_dev_dependency_ignored() {
assert!(check(&[c("solvecraft-doc", &[("solvecraft-doc", Dev, true)])]).is_empty());
}
#[test]
fn upward_dev_dependency_flagged() {
let v = check(&[c("solvecraft-geom", &[("solvecraft-doc", Dev, true)])]);
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
}
#[test]
fn ui_crates_below_l6_flagged() {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("solvecraft-engine", &[(dep, Normal, false)])]);
assert!(matches!(v[..], [Violation::UiBelowL5 { layer: 4, .. }]), "{dep}");
}
assert!(check(&[c("solvecraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("solvecraft-ui-egui", &[("winit", Normal, false)])]).is_empty());
}
#[test]
fn unregistered_crate_is_error() {
let v = check(&[c("solvecraft-mystery", &[])]);
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "solvecraft-mystery"));
assert!(v[0].to_string().contains("register"));
}
#[test]
fn testkit_only_as_dev_dependency() {
let v = check(&[c("solvecraft-doc", &[("solvecraft-testkit", Normal, true)])]);
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
assert!(check(&[c("solvecraft-doc", &[("solvecraft-testkit", Dev, true)])]).is_empty());
assert!(check(&[c("solvecraft-testkit", &[("solvecraft-engine", Normal, true)])]).is_empty());
}
#[test]
fn apps_and_xtask_exempt() {
for app in ["solvecraft", "solvecraft-cli", "xtask"] {
assert!(check(&[c(app, &[("egui", Normal, false), ("solvecraft-ui-egui", Normal, true)])]).is_empty());
}
}
#[test]
fn metadata_parsing() {
let meta: Value = serde_json::from_str(
r#"{"packages":[
{"name":"solvecraft-doc","dependencies":[
{"name":"solvecraft-geom","kind":null,"path":"/x/crates/geom"},
{"name":"serde","kind":null},
{"name":"proptest","kind":"dev"}]},
{"name":"solvecraft-geom","dependencies":[]}
]}"#,
)
.unwrap();
let g = from_metadata(&meta).unwrap();
assert_eq!(g.len(), 2);
let doc = g.iter().find(|c| c.name == "solvecraft-doc").unwrap();
assert!(doc.deps[0].workspace && !doc.deps[1].workspace);
assert_eq!(doc.deps[2].kind, Dev);
assert!(check(&g).is_empty());
}
}
+177 -1
View File
@@ -1 +1,177 @@
fn main() {}
//! Workspace tooling: `cargo xtask <command>`.
//!
//! Pure Rust (std + serde_json). External tools (`cargo`) are invoked through
//! `std::process::Command`.
mod assets;
mod layers;
mod parity;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
const USAGE: &str = "\
usage: cargo xtask <command>
commands:
layers enforce the crate dependency layering (plan/architecture.md §3)
assets check that every non-code asset file is attributed in ATTRIBUTION.md
parity [--refresh]
recompute Fusion command parity in docs/parity.md (--refresh rebuilds
xtask/data/fusion-catalog.tsv from plan/fusion/menu-tree.json)
oracle replay plan/fusion/oracle/*/recipe.json and compare with measure.json;
writes docs/oracle.md
ci fmt --check, clippy -D warnings, test, assets, layers (stops at first failure)
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
let result = match args.first().map(String::as_str) {
Some("layers") => cmd_layers(),
Some("assets") => assets::run(&root()),
Some("parity") => parity::run(&root(), rest.contains(&"--refresh")),
Some("oracle") => cmd_oracle(),
Some("ci") => cmd_ci(),
Some("-h" | "--help" | "help") | None => {
print!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
/// Workspace root (parent of the xtask crate).
pub fn root() -> PathBuf {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
here.parent().unwrap_or(here).to_path_buf()
}
pub fn cargo() -> Command {
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
c.current_dir(root());
c
}
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}");
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
}
pub fn metadata() -> Result<serde_json::Value, String> {
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
}
fn cmd_layers() -> Result<(), String> {
let crates = layers::from_metadata(&metadata()?)?;
println!("Dependency layering (plan/architecture.md §3)\n");
println!("{:<28} {:<14} workspace deps", "crate", "layer");
for c in &crates {
let ws: Vec<String> = c
.deps
.iter()
.filter(|d| d.workspace)
.map(|d| {
let k = match d.kind {
layers::DepKind::Normal => "",
layers::DepKind::Dev => " (dev)",
layers::DepKind::Build => " (build)",
};
format!("{}{k}", layers::short_name(&d.name))
})
.collect();
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
}
let violations = layers::check(&crates);
println!();
if violations.is_empty() {
println!("OK: {} crates, no layering violations.", crates.len());
Ok(())
} else {
println!("{} violation(s):", violations.len());
for v in &violations {
println!(" - {v}");
}
Err(format!("{} layering violation(s)", violations.len()))
}
}
/// Run the CLI's oracle replay over every case directory and write docs/oracle.md.
fn cmd_oracle() -> Result<(), String> {
let dir = root().join("plan/fusion/oracle");
let mut cases: Vec<PathBuf> = std::fs::read_dir(&dir)
.map_err(|e| format!("{}: {e} (the oracle data is local, in plan/fusion/oracle)", dir.display()))?
.flatten()
.map(|e| e.path())
.filter(|p| p.join("recipe.json").is_file() && p.join("measure.json").is_file())
.collect();
cases.sort();
if cases.is_empty() {
return Err("no oracle cases with recipe.json and measure.json".into());
}
let mut cmd = cargo();
cmd.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--", "oracle", "--json"]);
for c in &cases {
cmd.arg(c);
}
let out = cmd.output().map_err(|e| format!("solvecraft-cli: {e}"))?;
let results: serde_json::Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("oracle JSON: {e}\n{}", String::from_utf8_lossy(&out.stderr)))?;
let list = results.as_array().cloned().unwrap_or_default();
let pass = list.iter().filter(|r| r["pass"].as_bool().unwrap_or(false)).count();
let skipped = list.iter().filter(|r| r["skipped"].as_bool().unwrap_or(false)).count();
let mut md = format!(
"# Fusion oracle\n\nGenerated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact).\n\n**{pass} / {} cases pass** ({skipped} need features not implemented yet).\n\n| Case | Result | Notes |\n|---|---|---|\n",
list.len()
);
for r in &list {
let status = if r["pass"].as_bool().unwrap_or(false) {
"pass"
} else if r["skipped"].as_bool().unwrap_or(false) {
"not yet"
} else {
"FAIL"
};
let mut notes: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{}: got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
if let Some(e) = r["error"].as_str() {
notes.push(e.replace('|', "/"));
}
md += &format!("| {} | {status} | {} |\n", r["case"].as_str().unwrap_or(""), notes.join("; "));
}
std::fs::write(root().join("docs/oracle.md"), md).map_err(|e| format!("docs/oracle.md: {e}"))?;
println!("oracle: {pass}/{} cases pass ({skipped} not yet supported) → docs/oracle.md", list.len());
Ok(())
}
fn cmd_ci() -> Result<(), String> {
let mut c = cargo();
c.args(["fmt", "--all", "--", "--check"]);
run(c, "cargo fmt --check")?;
let mut c = cargo();
c.args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]);
run(c, "cargo clippy -D warnings")?;
let mut c = cargo();
c.args(["test", "--workspace", "--release"]);
run(c, "cargo test")?;
assets::run(&root())?;
cmd_layers()?;
eprintln!("ci: all gates passed");
Ok(())
}
+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);
}
}