diff --git a/README.md b/README.md index f563ee2fee..bd33ad23be 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index dd7ca66a0d..a162cc7055 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -64,7 +64,8 @@ pdfcraft-cli batch --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 [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 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, + out: Option<&str>, +) -> Result, 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 = 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::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))?; diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/run_report.rs new file mode 100644 index 0000000000..cf1e6b650d --- /dev/null +++ b/apps/pdfcraft-cli/tests/run_report.rs @@ -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::>(), ["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")); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index b7c37ce33f..31ec9d336a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"