mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 06:10:36 +00:00
Add dry-run preview to doc_batch without writing outputs (#6486)
* Add dry-run preview to doc_batch without writing outputs Users cannot preview a batch before it writes files, so they cannot check the planned steps and output names for a folder first. Add a dry_run option to doc_batch that reuses discovery, returns each file's ordered steps and planned output name, and skips processing and publishing. Test: doc_batch_dry_run_lists_outputs_without_writing failed before the change and passes after. * Address review for pull request 6486 --------- Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
This commit is contained in:
@@ -185,6 +185,7 @@ impl Automation {
|
||||
let recipe = Recipe::parse(self, args)?;
|
||||
let recursive = args.opt_bool("recursive")?.unwrap_or(false);
|
||||
let stop = args.opt_bool("stop_on_error")?.unwrap_or(false);
|
||||
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
|
||||
let paths = args.strs("paths")?;
|
||||
if paths.is_empty() || paths.len() > 1000 {
|
||||
return Err(Args::wrong("paths", "1 to 1000 files or folders"));
|
||||
@@ -211,11 +212,41 @@ impl Automation {
|
||||
let mut files = discovery.skipped;
|
||||
let (mut completed, mut failed_count) = (0, 0);
|
||||
let mut stopped = false;
|
||||
let mut planned_outputs: HashSet<PathBuf> = HashSet::new();
|
||||
for (path, relative) in discovery.files {
|
||||
if stopped || !recipe.accepts(&path) {
|
||||
files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}}));
|
||||
continue;
|
||||
}
|
||||
if dry_run {
|
||||
let planned: Result<PathBuf> = (|| {
|
||||
let target = out.join(&relative).with_extension("pdf");
|
||||
let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?;
|
||||
if !target.starts_with(&out) {
|
||||
return Err(failed("output path leaves out_dir"));
|
||||
}
|
||||
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
|
||||
let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy().into_owned();
|
||||
for n in 1..=10_000 {
|
||||
let candidate = if n == 1 { target.clone() } else { dir.join(format!("{stem} ({n}).pdf")) };
|
||||
if candidate.symlink_metadata().is_ok() || planned_outputs.contains(&candidate) {
|
||||
continue;
|
||||
}
|
||||
planned_outputs.insert(candidate.clone());
|
||||
return Ok(candidate);
|
||||
}
|
||||
Err(failed("all 10000 output names are occupied"))
|
||||
})();
|
||||
match planned {
|
||||
Ok(target) => files.push(json!({"path":path.to_string_lossy(),"status":"planned","output":target.to_string_lossy().replace('\\', "/"),"steps":recipe.steps})),
|
||||
Err(e) => {
|
||||
failed_count += 1;
|
||||
files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()}));
|
||||
stopped = stop;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Recheck confinement and links before reading a discovered file.
|
||||
let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?;
|
||||
@@ -244,8 +275,8 @@ impl Automation {
|
||||
}
|
||||
}
|
||||
}
|
||||
let skipped = files.len().saturating_sub(completed + failed_count);
|
||||
Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
|
||||
let skipped = files.iter().filter(|file| file["status"] == "skipped").count();
|
||||
Ok(json!({"dry_run":dry_run,"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,4 +310,20 @@ mod tests {
|
||||
discovery.visited = 0;
|
||||
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_batch_dry_run_lists_outputs_without_writing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("input")).unwrap();
|
||||
let pdf = Session::new().create_from_text("Doc", "Hello").unwrap();
|
||||
std::fs::write(dir.path().join("input/a.pdf"), &*pdf).unwrap();
|
||||
let auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = auto.doc_batch(&Args(&json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","dry_run":true}))).unwrap();
|
||||
assert_eq!(report["dry_run"], true, "{report}");
|
||||
assert_eq!(report["completed"], 0, "{report}");
|
||||
assert_eq!(report["failed"], 0, "{report}");
|
||||
assert_eq!(report["files"][0]["status"], "planned", "{report}");
|
||||
assert!(report["files"][0]["output"].as_str().unwrap().ends_with("out/a.pdf"), "{report}");
|
||||
assert!(!dir.path().join("out").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
|
||||
"out_dir": {"type":"string"},
|
||||
"steps": {"type":"array","items":{"type":"string","enum":["convert","ocr","optimize"]},"minItems":1,"maxItems":3,"uniqueItems":true},
|
||||
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"},
|
||||
"recursive": {"type":"boolean"}, "stop_on_error": {"type":"boolean"}, "dry_run": {"type":"boolean"},
|
||||
"ppi": {"type":"number","minimum":9,"maximum":1600},
|
||||
"quality": {"type":"integer","minimum":1,"maximum":100},
|
||||
"dpi": {"type":"number","minimum":72,"maximum":600},
|
||||
|
||||
@@ -238,3 +238,61 @@ fn mcp_batch_errors_keep_the_structured_per_file_report() {
|
||||
assert_eq!(report["result"]["isError"], true, "{report}");
|
||||
assert_eq!(report["result"]["structuredContent"]["failed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_planned_files_are_not_counted_as_skipped() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("out")).unwrap();
|
||||
let original = pdf();
|
||||
std::fs::write(dir.path().join("input/a.pdf"), &original).unwrap();
|
||||
std::fs::write(dir.path().join("input/sub/a.pdf"), &original).unwrap();
|
||||
std::fs::write(dir.path().join("out/a.pdf"), b"precious").unwrap();
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(&mut auto, json!({"paths":["input","input/sub"],"steps":["optimize"],"out_dir":"out","dry_run":true}));
|
||||
assert_eq!(report["dry_run"], true, "{report}");
|
||||
assert_eq!(report["completed"], 0, "{report}");
|
||||
assert_eq!(report["failed"], 0, "{report}");
|
||||
assert_eq!(report["skipped"], 0, "planned files are not skipped: {report}");
|
||||
let files = report["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2, "{report}");
|
||||
assert!(files.iter().all(|file| file["status"] == "planned"), "{report}");
|
||||
assert!(files.iter().all(|file| file["steps"] == json!(["optimize"])), "planned files list the ordered steps: {report}");
|
||||
assert!(files[0]["output"].as_str().unwrap().ends_with("out/a (2).pdf"), "existing output gets the publish collision name: {report}");
|
||||
assert!(files[1]["output"].as_str().unwrap().ends_with("out/a (3).pdf"), "later planned files reserve their names: {report}");
|
||||
assert_eq!(std::fs::read(dir.path().join("out/a.pdf")).unwrap(), b"precious");
|
||||
assert!(!dir.path().join("out/a (2).pdf").exists(), "dry run writes nothing: {report}");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// An output subfolder that resolves outside out_dir is a per-file error:
|
||||
// it counts as failed, honors stop_on_error, and later files are skipped.
|
||||
for outside_root in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("input/sub")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("out")).unwrap();
|
||||
std::fs::write(dir.path().join("input/a.pdf"), pdf()).unwrap();
|
||||
std::fs::write(dir.path().join("input/sub/page.pdf"), pdf()).unwrap();
|
||||
std::fs::write(dir.path().join("input/z.pdf"), pdf()).unwrap();
|
||||
let target = if outside_root { outside.path().to_path_buf() } else { dir.path().join("outside") };
|
||||
if !outside_root {
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
}
|
||||
std::os::unix::fs::symlink(&target, dir.path().join("out/sub")).unwrap();
|
||||
let mut auto = Automation::new().with_root(dir.path()).unwrap();
|
||||
let report = result(
|
||||
&mut auto,
|
||||
json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","recursive":true,"dry_run":true,"stop_on_error":true}),
|
||||
);
|
||||
assert_eq!(report["failed"], 1, "preview target failure is per-file: {report}");
|
||||
assert_eq!(report["stopped"], true, "{report}");
|
||||
assert_eq!(report["skipped"], 1, "only the unprocessed file is skipped: {report}");
|
||||
let files = report["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 3, "{report}");
|
||||
assert_eq!(files[0]["status"], "planned", "{report}");
|
||||
assert_eq!(files[1]["status"], "error", "{report}");
|
||||
assert_eq!(files[2]["status"], "skipped", "stop_on_error leaves later files unplanned: {report}");
|
||||
assert!(!dir.path().join("out/a.pdf").exists(), "dry run writes nothing: {report}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user