From 2e7f41710bee953102108dea7748565204c97973 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:18:17 +0200 Subject: [PATCH] run and eval can write their JSON to a file (--json-out) (#1242) --- apps/solidcraft-cli/src/main.rs | 50 ++++++-- apps/solidcraft-cli/tests/json_out.rs | 168 +++++++++++++++++++++++++ docs/book/src/guide/getting-started.md | 11 ++ 3 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 apps/solidcraft-cli/tests/json_out.rs diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index adcd2de3..8a93eac0 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -1,8 +1,8 @@ //! SolidCraft headless command line. //! //! ```text -//! solidcraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors] -//! solidcraft-cli eval (alias: inspect) measurements as JSON +//! solidcraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors] +//! solidcraft-cli eval [--json-out FILE] (alias: inspect) measurements as JSON //! solidcraft-cli script [--out script.json] the design's history as a command script //! solidcraft-cli bench-open [--parse-only] 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 @@ -37,10 +37,12 @@ use solidcraft_engine::Session; use solidcraft_engine::render::{StandardView, render_png}; const USAGE: &str = "usage: - solidcraft-cli run [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors] + solidcraft-cli run [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors] solidcraft-cli run --in [--out FILE]... (a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway) - solidcraft-cli eval (alias: inspect; or --in FILE) + solidcraft-cli eval [--json-out FILE] (alias: inspect; or --in FILE) + (--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout, + as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark) solidcraft-cli script [--out script.json] (the design's history as a command script that eval and run replay) solidcraft-cli bench-open [--parse-only] @@ -176,7 +178,36 @@ fn positional(args: &[String]) -> Option<&String> { None } +/// `--json-out FILE`: where the JSON a command prints goes instead of stdout. +fn json_out(args: &[String]) -> Result, String> { + if !args.iter().any(|a| a == "--json-out") { + return Ok(None); + } + match flag(args, "--json-out") { + Some(file) if !file.starts_with("--") => Ok(Some(file)), + _ => Err("--json-out needs a FILE".to_string()), + } +} + +/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8 +/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows +/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it. +fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> { + match file { + Some(file) => { + let mut text = pretty(v); + text.push('\n'); + std::fs::write(file, text).map_err(|e| format!("{file}: {e}")) + } + None => { + println!("{}", pretty(v)); + Ok(()) + } + } +} + fn cmd_run(args: &[String]) -> Result<(), String> { + let json_out = json_out(args)?; let base = flag(args, "--in"); let path = positional(args); if path.is_none() && base.is_none() { @@ -216,10 +247,11 @@ fn cmd_run(args: &[String]) -> Result<(), String> { s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?; } if let Some(results) = results { - println!("{}", pretty(&results)); - } else if !args.iter().any(|a| a == "--quiet") { + print_json(&results, json_out)?; + } else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") { + // `--quiet` silences stdout; a file that was asked for is still written. let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?; - println!("{}", pretty(&m)); + print_json(&m, json_out)?; } Ok(()) } @@ -238,6 +270,7 @@ fn timeline_ok(s: &Session) -> Result<(), String> { } fn cmd_eval(args: &[String]) -> Result<(), String> { + let json_out = json_out(args)?; let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?; let mut s = load(path)?; let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?; @@ -250,8 +283,7 @@ fn cmd_eval(args: &[String]) -> Result<(), String> { "params": d["params"], "sketches": d["sketches"], }); - println!("{}", pretty(&out)); - Ok(()) + print_json(&out, json_out) } /// A design's history as a command script (`timeline.export_script`): printed, or written to diff --git a/apps/solidcraft-cli/tests/json_out.rs b/apps/solidcraft-cli/tests/json_out.rs new file mode 100644 index 00000000..7be69744 --- /dev/null +++ b/apps/solidcraft-cli/tests/json_out.rs @@ -0,0 +1,168 @@ +//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes +//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a +//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use serde_json::{Value, json}; + +static NEXT: AtomicUsize = AtomicUsize::new(0); + +struct Scratch(PathBuf); + +impl Scratch { + fn new() -> Self { + let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&dir).unwrap(); + Self(dir) + } + + /// A box, and a parameter whose comment has a character outside ASCII. + fn script(&self) -> PathBuf { + let path = self.0.join("box.json"); + let script = json!({"commands": [ + {"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}}, + {"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}, + ]}); + std::fs::write(&path, script.to_string()).unwrap(); + path + } + + /// A filleted box whose fillet a later parameter change makes impossible: every command + /// succeeds, the timeline ends with a failing feature. + fn broken_script(&self) -> PathBuf { + let path = self.0.join("broken.json"); + let script = json!({"commands": [ + {"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}}, + {"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}}, + {"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}}, + {"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}}, + ]}); + std::fs::write(&path, script.to_string()).unwrap(); + path + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn cli() -> Command { + Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")) +} + +fn stdout_of(output: Output) -> Vec { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + output.stdout +} + +/// Timings differ from one run to the next. +fn without_timings(v: &mut Value) { + match v { + Value::Object(map) => { + map.remove("ms"); + map.values_mut().for_each(without_timings); + } + Value::Array(items) => items.iter_mut().for_each(without_timings), + _ => {} + } +} + +/// The file holds what stdout carried in the same command without `--json-out`: the same JSON, +/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict +/// reader accepts it. +fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value { + let bytes = std::fs::read(file).unwrap(); + assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark"); + assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline"); + let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count(); + assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout"); + let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap(); + let mut printed: Value = serde_json::from_slice(stdout).unwrap(); + without_timings(&mut value); + without_timings(&mut printed); + assert_eq!(value, printed, "the file is what stdout would have carried"); + value +} + +#[test] +fn run_results_json_out_holds_what_stdout_would() { + let dir = Scratch::new(); + let script = dir.script(); + let file = dir.0.join("results.json"); + let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap()); + let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout"); + let results = assert_plain_utf8_json(&file, &on_stdout); + assert_eq!(results.as_array().map(Vec::len), Some(2)); +} + +#[test] +fn run_json_out_without_results_holds_the_measurement_even_when_quiet() { + let dir = Scratch::new(); + let script = dir.script(); + let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap()); + for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] { + let file = dir.0.join(format!("measure-{name}.json")); + let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "{name}"); + let measure = assert_plain_utf8_json(&file, &on_stdout); + assert_eq!(measure["body_count"], 1, "{name}"); + } +} + +#[test] +fn eval_json_out_holds_what_stdout_would() { + let dir = Scratch::new(); + let script = dir.script(); + let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap()); + // The option may come before the file to evaluate, and `inspect` is the same command. + for name in ["eval", "inspect"] { + let file = dir.0.join(format!("{name}.json")); + let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stdout.is_empty(), "{name}"); + let measured = assert_plain_utf8_json(&file, &on_stdout); + assert_eq!(measured["body_count"], 1, "{name}"); + // A character outside ASCII is written as it is, in UTF-8. + assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}"); + } +} + +#[test] +fn json_out_needs_a_file_and_fails_when_it_cannot_write() { + let dir = Scratch::new(); + let script = dir.script(); + for cmd in ["run", "eval"] { + let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{cmd}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}"); + assert!(output.stdout.is_empty(), "{cmd}"); + + let missing = dir.0.join("missing").join("out.json"); + let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{cmd}"); + assert!(!output.stderr.is_empty(), "{cmd}"); + assert!(output.stdout.is_empty(), "{cmd}"); + assert!(!missing.exists(), "{cmd}"); + } +} + +#[test] +fn run_json_out_is_not_written_when_a_feature_fails() { + let dir = Scratch::new(); + let script = dir.broken_script(); + for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] { + let file = dir.0.join(format!("failed-{name}.json")); + let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{name}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}"); + assert!(!file.exists(), "{name}"); + } +} diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index 3de1bb9b..da463a79 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -121,6 +121,17 @@ rebuilds it; `run` then exits with status 1, lists the failing features and writ its `--out` and `--save` files, because the bodies lack what those features should have made. `--allow-errors` writes them anyway. +`run` prints each command's result with `--results` (otherwise the measurement), and `eval` +prints the measurement, as JSON on stdout. `--json-out FILE` writes that JSON to a file +instead. The file is plain UTF-8, which is not what a redirect gives you everywhere: Windows +PowerShell's `>` re-encodes a program's output, as UTF-16 or as UTF-8 with a byte-order mark +depending on its settings, and strict JSON readers such as Python's `json.load` then refuse it. + +```sh +cargo run --release -p solidcraft-cli -- run examples/bracket.json --results --json-out results.json +cargo run --release -p solidcraft-cli -- eval examples/bracket.json --json-out measures.json +``` + A design's history turns into a script that rebuilds it, which makes a bug that only shows on one design a one-file reproduction: