CLI snapshot takes --in; an unknown command suggests close ids (#1301)

snapshot starts from --in (a design or STEP file) with the script on top, as run does, and refuses options it doesn't take; an unknown command id names the closest registered ids.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-15 02:13:14 -04:00
1 parent 50c5c13f72
commit 96356dc544
6 files changed
+149 -24

No files matched your search

+52 -22
View File
@@ -6,7 +6,7 @@
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
@@ -47,7 +47,9 @@ const USAGE: &str = "usage:
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
@@ -213,26 +215,17 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
}
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let results = if args.iter().any(|a| a == "--results") {
let (mut s, results) = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
Some(Value::Array(r))
(s, Some(Value::Array(r)))
} else {
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
None
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
@@ -256,6 +249,40 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
Ok(())
}
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str]) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
match args.get(i + 1) {
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") {
return Err(format!("{cmd} doesn't take {a} (it takes {})", valued.join(", ")));
} else {
i += 1;
}
}
Ok(())
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
@@ -304,11 +331,14 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or_else(|| USAGE.to_string())?;
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view"])?;
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
let s = load(path)?;
let size = |name: &str, default: usize| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let s = open(flag(args, "--in"), positional(args))?;
let mut cam = solidcraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
+38
View File
@@ -132,3 +132,41 @@ fn legacy_extension_and_format_tag_still_open() {
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
}
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
/// option it doesn't take rather than ignoring it.
#[test]
fn snapshot_runs_its_script_on_the_in_design() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let shifted = dir.0.join("shifted.json");
std::fs::write(
&shifted,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
)
.unwrap();
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
let png = dir.0.join(name);
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
}
}
/// An unknown command's error suggests the id that was meant.
#[test]
fn unknown_command_suggests_the_closest_id() {
let output = cli().args(["exec", "file.open"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
}
+42
View File
@@ -269,3 +269,45 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
+1 -1
View File
@@ -43,7 +43,7 @@ pub const MAX_LINE: usize = 16 << 20;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
#[error("unknown command `{0}`{hint}", hint = cmd::did_you_mean(.0))]
UnknownCommand(String),
#[error("`{0}` is not available right now: {1}")]
Disabled(String, String),
+14
View File
@@ -837,6 +837,20 @@ fn only_own_ids_name_commands() {
}
}
/// An unknown command's error names the ids it was probably meant to be: a typo's, or the
/// command that ends in the same word.
#[test]
fn unknown_commands_suggest_the_closest_ids() {
let mut s = Session::default();
let mut msg = |id: &str| s.execute(id, &serde_json::json!({})).unwrap_err().to_string();
assert_eq!(msg("file.open"), "unknown command `file.open` (did you mean `doc.open`?)");
assert!(msg("solid.extrud").starts_with("unknown command `solid.extrud` (did you mean `solid.extrude`"), "{}", msg("solid.extrud"));
assert!(msg("Extrude").contains("`solid.extrude`"), "{}", msg("Extrude"));
assert_eq!(msg("FusionFilletEdgesCommand"), "unknown command `FusionFilletEdgesCommand`");
let long = "x".repeat(10_000);
assert_eq!(msg(&long), format!("unknown command `{long}`"));
}
#[test]
fn sample_design_builds() {
let mut s = Session::default();
+2 -1
View File
@@ -25,7 +25,8 @@ feature to the timeline and let the document evaluate it; they validate their in
(units, ranges, references) and return `BadParams` instead of building a broken feature.
Only the registry's own dotted ids name commands (matched ignoring case); any other id fails
with `unknown command`.
with `unknown command`, which names up to three close ids: a typo's (`solid.extrud`), or one
ending in the same word (`file.open`: did you mean `doc.open`?).
## Adding a command