//! `cargo xtask book`: generate the book's command reference and screenshots, then build it. //! //! The command reference (`docs/book/src/reference/commands.md`) comes from //! `solidcraft-cli commands`, so it can't drift from the registry. The screenshots in //! `docs/book/src/generated/` are CPU renders by `solidcraft-cli snapshot` of `examples/*.json` //! and `docs/book/scripts/*.json`, plus the app screenshot from `docs/screenshots/`. Both are //! gitignored and attributed in ATTRIBUTION.md. The book itself is built by `mdbook` (a separate //! tool, not a dependency) into `target/book`. use std::path::Path; use serde_json::Value; use crate::cargo; /// Toolbar tabs in toolbar order; commands not on the toolbar come last, grouped by id prefix. const TABS: &[&str] = &["SOLID", "SKETCH", "SURFACE", "SHEET METAL", "PLASTIC"]; const SNAPSHOT_SIZE: (&str, &str) = ("960", "600"); pub fn run(root: &Path, build: bool) -> Result<(), String> { let book = root.join("docs/book"); std::fs::copy(root.join("assets/branding/web/source/og.png"), book.join("src/og.png")).map_err(|e| format!("book preview card: {e}"))?; let mut c = cargo(); c.args(["build", "-q", "--release", "-p", "solidcraft-cli"]); crate::run(c, "cargo build -p solidcraft-cli")?; let out = cli().arg("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 md = reference(cmds.as_array().map(Vec::as_slice).unwrap_or_default()); write(&book.join("src/reference/commands.md"), &md)?; let gen_dir = book.join("src/generated"); std::fs::create_dir_all(&gen_dir).map_err(|e| format!("{}: {e}", gen_dir.display()))?; let mut scripts = vec![root.join("examples/bracket.json")]; scripts.extend(json_files(&book.join("scripts"))?); for s in &scripts { let stem = s.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); let png = gen_dir.join(format!("{stem}.png")); let mut c = cli(); c.arg("snapshot").arg(s).arg("--out").arg(&png).args(["--width", SNAPSHOT_SIZE.0, "--height", SNAPSHOT_SIZE.1]); let o = c.output().map_err(|e| format!("solidcraft-cli snapshot: {e}"))?; if !o.status.success() { return Err(format!("snapshot of {} failed:\n{}", s.display(), String::from_utf8_lossy(&o.stderr))); } println!("book: {}", png.strip_prefix(root).unwrap_or(&png).display()); } let shot = "sample-plate.png"; std::fs::copy(root.join("docs/screenshots").join(shot), gen_dir.join(shot)).map_err(|e| format!("{shot}: {e}"))?; if build { let mut c = std::process::Command::new("mdbook"); c.arg("build").arg(&book); crate::run(c, "mdbook build docs/book").map_err(|e| format!("{e}\n(install mdbook with `cargo install mdbook`)"))?; println!("book: built into target/book"); } Ok(()) } /// The release solidcraft-cli just built. fn cli() -> std::process::Command { let mut c = cargo(); c.args(["run", "-q", "--release", "-p", "solidcraft-cli", "--"]); c } fn write(path: &Path, text: &str) -> Result<(), String> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display())) } fn json_files(dir: &Path) -> Result, String> { let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut v: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect(); v.sort(); Ok(v) } /// The command reference page: one section per toolbar tab and panel, in registry order. fn reference(cmds: &[Value]) -> String { let s = |c: &Value, k: &str| c[k].as_str().unwrap_or_default().to_string(); let mut md = format!( "# Command reference\n\nGenerated by `cargo xtask book` from `solidcraft-cli commands` ({} commands). Every command runs the same \ way from the toolbar, a script, `solidcraft-cli exec ''`, the control channel and MCP. Parameters marked `?` are \ optional; `expr` is a number or an expression with units.\n", cmds.len() ); let mut sections: Vec<(String, Vec<&Value>)> = Vec::new(); for tab in TABS { for c in cmds.iter().filter(|c| s(c, "tab") == *tab) { let title = format!("{tab} › {}", s(c, "panel")); add(&mut sections, title, c); } } for c in cmds.iter().filter(|c| !TABS.contains(&s(c, "tab").as_str())) { let id = s(c, "id"); let prefix = id.split('.').next().unwrap_or_default().to_string(); add(&mut sections, format!("Other: {prefix}.*"), c); } for (title, list) in sections { md.push_str(&format!("\n## {title}\n\n")); for c in list { let key = c["shortcut"].as_str().map(|k| format!(" ({k})")).unwrap_or_default(); md.push_str(&format!("- `{}` **{}**{key}", s(c, "id"), s(c, "label"))); let p = s(c, "params"); if !p.is_empty() { md.push_str(&format!("
``{}``", p.replace('\n', " "))); } md.push('\n'); } } md } fn add<'a>(sections: &mut Vec<(String, Vec<&'a Value>)>, title: String, c: &'a Value) { match sections.iter_mut().find(|(t, _)| *t == title) { Some((_, v)) => v.push(c), None => sections.push((title, vec![c])), } }