123 lines
6.0 KiB
Rust
123 lines
6.0 KiB
Rust
//! `cargo xtask features`: SolidCraft's own feature checklist, `docs/features.md`.
|
||
//!
|
||
//! The page lists every registered command by area (the first part of its dotted id), with its
|
||
//! label, id and toolbar place. It comes only from SolidCraft's command registry
|
||
//! (`solidcraft-cli commands`), so it can't drift from what the program does; it compares with
|
||
//! nothing else. Planned commands (registered, not built yet) are listed as planned, with their
|
||
//! issue. Counts are omitted so unrelated command additions do not edit shared headers.
|
||
|
||
use std::path::Path;
|
||
|
||
use serde_json::Value;
|
||
|
||
/// Areas in the order the page lists them (id prefix, heading). Other prefixes follow,
|
||
/// alphabetically, under their prefix.
|
||
const AREAS: &[(&str, &str)] = &[
|
||
("sketch", "Sketch"),
|
||
("solid", "Solid modelling"),
|
||
("surface", "Surfaces"),
|
||
("construct", "Construction geometry"),
|
||
("sheet", "Sheet metal"),
|
||
("plastic", "Plastic parts"),
|
||
("component", "Components"),
|
||
("joint", "Assembly joints"),
|
||
("config", "Configurations"),
|
||
("timeline", "Timeline"),
|
||
("inspect", "Inspect"),
|
||
("file", "Files"),
|
||
("doc", "Documents"),
|
||
("edit", "Edit"),
|
||
];
|
||
|
||
pub fn run(root: &Path) -> Result<(), String> {
|
||
let out = crate::cargo()
|
||
.args(["run", "-q", "--release", "-p", "solidcraft-cli", "--", "commands"])
|
||
.output()
|
||
.map_err(|e| format!("solidcraft-cli: {e}"))?;
|
||
if !out.status.success() {
|
||
return Err(format!("solidcraft-cli commands failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||
}
|
||
let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
|
||
let list = cmds.as_array().map(Vec::as_slice).unwrap_or_default();
|
||
let md = page(list);
|
||
std::fs::write(root.join("docs/features.md"), md).map_err(|e| format!("docs/features.md: {e}"))?;
|
||
println!("features: {} commands → docs/features.md", list.len());
|
||
Ok(())
|
||
}
|
||
|
||
fn area(id: &str) -> &str {
|
||
id.split('.').next().unwrap_or_default()
|
||
}
|
||
|
||
/// The checklist page for the registry's commands.
|
||
pub fn page(cmds: &[Value]) -> String {
|
||
let s = |c: &Value, k: &str| c[k].as_str().unwrap_or_default().to_string();
|
||
let mut prefixes: Vec<String> = cmds.iter().map(|c| area(&s(c, "id")).to_string()).collect();
|
||
prefixes.sort();
|
||
prefixes.dedup();
|
||
let mut order: Vec<(String, String)> =
|
||
AREAS.iter().filter(|(p, _)| prefixes.iter().any(|x| x == p)).map(|(p, h)| (p.to_string(), h.to_string())).collect();
|
||
for p in prefixes.iter().filter(|p| !AREAS.iter().any(|(a, _)| a == p)) {
|
||
order.push((p.clone(), format!("`{p}.*`")));
|
||
}
|
||
let planned = |c: &Value| c["planned"].as_str().map(str::to_string);
|
||
// Counts made every command addition edit the same header, even across unrelated areas.
|
||
// Keep generated text local to each command so parallel branches merge cleanly more often.
|
||
let mut md = String::from(
|
||
"# Features\n\nSolidCraft's commands by area, generated by `cargo xtask features` from the command registry \
|
||
(`solidcraft-cli commands`). Planned commands are marked in their rows. Every command runs the same way from the toolbar, a script, \
|
||
`solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the \
|
||
[command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).\n",
|
||
);
|
||
for (prefix, heading) in &order {
|
||
let mut in_area: Vec<&Value> = cmds.iter().filter(|c| area(&s(c, "id")) == prefix).collect();
|
||
in_area.sort_by_key(|c| s(c, "id"));
|
||
md += &format!("\n## {heading}\n\n| Command | Id | Toolbar |\n|---|---|---|\n");
|
||
for c in in_area {
|
||
let place = match (s(c, "tab"), s(c, "panel")) {
|
||
(t, _) if t.is_empty() => "–".to_string(),
|
||
(t, p) if p.is_empty() => t,
|
||
(t, p) => format!("{t} › {p}"),
|
||
};
|
||
let key = c["shortcut"].as_str().map(|k| format!(" ({k})")).unwrap_or_default();
|
||
let note = planned(c).map(|i| format!(" *(planned: [#{i}](https://github.com/bherbruck/solidcraft/issues/{i}))*")).unwrap_or_default();
|
||
md += &format!("| {}{key}{note} | `{}` | {place} |\n", s(c, "label").replace('|', "/"), s(c, "id"));
|
||
}
|
||
}
|
||
md
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn page_groups_by_area() {
|
||
let cmds: Value = serde_json::from_str(
|
||
r#"[{"id":"solid.extrude","label":"Extrude","tab":"SOLID","panel":"CREATE","shortcut":"E"},
|
||
{"id":"sketch.line","label":"Line","tab":"SKETCH","panel":"CREATE"},
|
||
{"id":"view.fit","label":"Fit","tab":"","panel":""},
|
||
{"id":"solid.knit","label":"Knit","tab":"SOLID","panel":"CREATE","planned":"9"}]"#,
|
||
)
|
||
.unwrap();
|
||
let md = page(cmds.as_array().unwrap());
|
||
assert!(md.contains("Planned commands are marked in their rows."));
|
||
let (sk, so, other) = (md.find("## Sketch").unwrap(), md.find("## Solid modelling").unwrap(), md.find("## `view.*`").unwrap());
|
||
assert!(sk < so && so < other);
|
||
assert!(md.contains("| Extrude (E) | `solid.extrude` | SOLID › CREATE |"));
|
||
assert!(md.contains("| Fit | `view.fit` | – |"));
|
||
assert!(md.contains("| Knit *(planned: [#9](https://github.com/bherbruck/solidcraft/issues/9))* | `solid.knit` | SOLID › CREATE |"));
|
||
}
|
||
#[test]
|
||
fn unrelated_additions_do_not_rewrite_existing_lines_and_order_is_stable() {
|
||
let commands = vec![serde_json::json!({"id":"view.zoom","label":"Zoom"}), serde_json::json!({"id":"view.fit","label":"Fit"})];
|
||
let before = page(&commands);
|
||
let mut reordered = commands.clone();
|
||
reordered.reverse();
|
||
assert_eq!(before, page(&reordered));
|
||
reordered.push(serde_json::json!({"id":"view.pan","label":"Pan"}));
|
||
let after = page(&reordered);
|
||
assert_eq!(before, after.lines().filter(|l| !l.contains("`view.pan`")).collect::<Vec<_>>().join("\n") + "\n");
|
||
}
|
||
}
|