mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
9c42c0c5fe
commit
b870ad32e9
@@ -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).
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user