run and eval can write their JSON to a file (--json-out) (#1242)

This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-14 20:18:17 -04:00
1 parent 5c6f70cc00
commit 2e7f41710b
3 files changed
+220 -9

No files matched your search

+41 -9
View File
@@ -1,8 +1,8 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! 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] 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 <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--allow-errors]
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect; or --in FILE)
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--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 <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--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<Option<&str>, 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
+168
View File
@@ -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<u8> {
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}");
}
}
+11
View File
@@ -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: