CLI: run --script --report prints one JSON document for the run (#1891) (#1974)

Per step: number, tool, status (ok/error/skipped) and result or error.
Without the flag the output is unchanged; exit codes are unchanged.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-15 08:34:30 +02:00
committed by GitHub
parent 9c42c0c5fe
commit b870ad32e9
4 changed files with 147 additions and 7 deletions
+1
View File
@@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON
pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"}
pdfcraft-cli run --script steps.json --root ./work # several steps in one session
pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error
```
With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it).
+73 -5
View File
@@ -64,7 +64,8 @@ pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
pdfcraft-cli run --script steps.json [--root DIR] [--report]
[{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run
",
mcp_usage!(),
"\
@@ -256,6 +257,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--root", takes_value: true },
OptionSpec { name: "--script", takes_value: true },
OptionSpec { name: "--out", takes_value: true },
OptionSpec { name: "--report", takes_value: false },
];
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
@@ -783,22 +785,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_au
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
}
None => stdout_line(format_args!(
"{}",
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
))?,
None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
},
}
}
Ok(())
}
/// What a PNG result is reported as when it isn't written to a file.
fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
}
/// A tool's result as JSON values, for the `--report` of a script. Same content as
/// [`print_output`], collected instead of printed.
fn output_values(
auto: &pdfcraft_automation::Automation,
content: Vec<pdfcraft_automation::Content>,
out: Option<&str>,
) -> Result<Vec<serde_json::Value>, String> {
let mut values = Vec::new();
for c in content {
values.push(match c {
pdfcraft_automation::Content::Json(v) => v,
pdfcraft_automation::Content::Png { data, width, height } => match out {
Some(path) => {
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
}
None => png_summary(&data, width, height),
},
});
}
Ok(values)
}
/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
/// with the result or the message. The first failing step ends the run, as without `--report`;
/// the steps after it are listed as skipped and the exit status is the same.
fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
let mut report = Vec::with_capacity(steps.len());
let mut failure: Option<String> = None;
for (i, step) in steps.iter().enumerate() {
let n = i + 1;
let tool = step["tool"].as_str();
if failure.is_some() {
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
continue;
}
let outcome = match tool {
None => Err(format!("step {n}: missing \"tool\"")),
Some(t) => auto
.call(t, &step["args"])
.map_err(|e| e.to_string())
.and_then(|content| output_values(auto, content, step["out"].as_str()))
.map_err(|e| format!("step {n} ({t}): {e}")),
};
match outcome {
Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
Err(message) => {
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
failure = Some(message);
}
}
}
let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
}
fn run(args: &[String]) -> Result<(), CliError> {
validate_options("run", args, RUN_OPTIONS)?;
let report = args.iter().any(|a| a == "--report");
if report && flag(args, "--script").is_none() {
return Err("run: --report needs --script".to_string().into());
}
let mut auto = automation(args)?;
if let Some(script) = flag(args, "--script") {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
if report {
return run_script_report(&mut auto, &steps);
}
for (i, step) in steps.iter().enumerate() {
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
+71
View File
@@ -0,0 +1,71 @@
//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891).
use std::process::{Command, Output};
fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let script = dir.join("steps.json");
std::fs::write(&script, steps.to_string()).unwrap();
let out =
Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap();
let _ = std::fs::remove_dir_all(&dir);
out
}
fn blank() -> serde_json::Value {
serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } })
}
#[test]
fn a_successful_run_is_one_document_with_a_result_per_step() {
let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]);
let o = run_script("report-ok", &steps, &["--report"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
assert_eq!(report["ok"], true);
let steps = report["steps"].as_array().unwrap();
assert_eq!(steps.len(), 2);
assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into()));
assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}");
}
#[test]
fn a_failing_step_is_named_and_the_rest_are_skipped() {
let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]);
let o = run_script("report-fail", &steps, &["--report"]);
assert_eq!(o.status.code(), Some(1), "exit status is unchanged");
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}");
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
assert_eq!(report["ok"], false);
let steps = report["steps"].as_array().unwrap();
assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::<Vec<_>>(), ["ok", "error", "skipped"]);
assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}");
assert_eq!(steps[2]["tool"], "doc_close");
}
#[test]
fn a_step_without_a_tool_is_an_error_step() {
let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]);
assert_eq!(o.status.code(), Some(1));
let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
assert_eq!(report["steps"][0]["status"], "error");
assert!(report["steps"][0]["tool"].is_null());
}
#[test]
fn without_the_flag_the_output_is_unchanged() {
let o = run_script("report-off", &serde_json::json!([blank()]), &[]);
assert!(o.status.success());
let stdout = String::from_utf8_lossy(&o.stdout);
assert!(!stdout.contains("\"steps\""), "{stdout}");
}
#[test]
fn the_flag_needs_a_script() {
let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script"));
}
+2 -2
View File
@@ -9412,8 +9412,8 @@ milestone = "M13"
status = "shipped"
commands = []
tools = []
evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed"]
notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors."
evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"]
notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged."
[[feature]]
id = "misc.cli-completeness"