Merge pull request #1493

fix(cli): support --cmd-file for batch commands (#1098), carried from #1150
This commit is contained in:
Brandon Thomas
2026-10-30 15:58:53 -04:00
4 changed files with 231 additions and 10 deletions
+10 -1
View File
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
},
Subcommand {
name: "run",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
summary: "run commands on a presentation, then save or export it",
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
line endings and an optional UTF-8 BOM at the start of the file are accepted.
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
File errors name the path; line-reading, JSON and command errors also name the line number.
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
deckcraft-cli run \\
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
+69 -9
View File
@@ -465,6 +465,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
})
}
enum CommandSource {
Inline(String),
File(String),
}
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
line.clear();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(!line.is_empty());
}
let newline = available.iter().position(|byte| *byte == b'\n');
let count = newline.unwrap_or(available.len());
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
})?;
line.extend_from_slice(&available[..count]);
reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
return Ok(true);
}
}
}
/// The same command syntax and execution path for --cmd and each command-file line.
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
let (id, params) = match command.split_once('=') {
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (command, json!({})),
};
let r = s.execute(id, &params).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
}
Ok(())
}
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
use std::io::BufReader;
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let mut reader = BufReader::new(file);
let mut bytes = Vec::new();
let mut line_number = 0usize;
loop {
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
break;
}
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
let command = line.trim();
if command.is_empty() || command.starts_with('#') {
continue;
}
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
}
Ok(())
}
fn run(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut sample = false;
@@ -478,7 +541,8 @@ fn run(args: &[String]) -> Result<(), String> {
match a.as_str() {
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
"--sample" => sample = true,
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
"--export-params" => {
@@ -501,14 +565,10 @@ fn run(args: &[String]) -> Result<(), String> {
}
let mut s = Session::new();
open(&mut s, input.as_deref(), sample)?;
for c in cmds {
let (id, p) = match c.split_once('=') {
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
None => (c.clone(), json!({})),
};
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
if print {
outln!("{id} → {r}");
for source in cmds {
match source {
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
}
}
if let Some(p) = save {
+150
View File
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn command_file_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn inspected_titles(out: &str) -> Vec<String> {
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
}
#[test]
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
let dir = command_file_dir("lines");
let file = dir.join("commands with spaces.txt");
std::fs::write(
&file,
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
)
.unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
let out = stdout(&o);
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 4);
assert_eq!(titles[1], "Résumé = # 测试");
assert_eq!(titles[3], "Last");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_files_and_inline_commands_keep_argument_order() {
let dir = command_file_dir("mixed");
let first = dir.join("first.txt");
let second = dir.join("second.txt");
let saved = dir.join("result.deckcraft");
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
let o = cli(&[
"run",
"--cmd",
"slide.new={\"title\":\"Inline first\"}",
"--cmd-file",
&first.to_string_lossy(),
"--cmd",
"slide.new={\"title\":\"Inline middle\"}",
"--cmd-file",
&second.to_string_lossy(),
"--cmd",
"document.inspect",
"--print",
"--save",
&saved.to_string_lossy(),
]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
assert_eq!(out.lines().count(), 5);
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
let dir = command_file_dir("failure");
let file = dir.join("commands.txt");
let later = dir.join("later-in-file.deckcraft");
let inline = dir.join("later-inline.deckcraft");
let save = dir.join("saved.deckcraft");
let export = dir.join("exported.png");
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
std::fs::write(&save, b"keep saved file").unwrap();
std::fs::write(&export, b"keep exported file").unwrap();
let o = cli(&[
"run",
"--print",
"--cmd-file",
&file.to_string_lossy(),
"--cmd",
&inline_command,
"--save",
&save.to_string_lossy(),
"--export",
&export.to_string_lossy(),
]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_read_errors_name_the_path_and_utf8_line() {
let dir = command_file_dir("read-errors");
let missing = dir.join("missing.txt");
let invalid = dir.join("invalid.txt");
let save = dir.join("out.deckcraft");
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
for file in [&missing, &dir, &invalid] {
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&file.display().to_string()), "{err}");
if file == &invalid {
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
}
assert!(!save.exists());
}
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn command_file_rejects_an_oversized_line_before_running_it() {
let dir = command_file_dir("line-limit");
let file = dir.join("commands.txt");
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
let err = stderr(&o);
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
assert!(o.stdout.is_empty(), "the oversized command must not run");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn inline_commands_keep_their_existing_syntax_and_errors() {
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
let out = stdout(&o);
let titles = inspected_titles(&out);
assert_eq!(titles.len(), 3);
assert_eq!(titles[2], "Inline = #");
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
}
#[test]
fn an_unknown_subcommand_is_named_before_the_overview() {
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
"Shape ids are looked up on the current slide only.",
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
"format.* acts on the selected text or shapes",
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
"commands execute in the order those options appear.",
"If a command fails, run stops there and saves nothing.",
] {
assert!(out.contains(fact), "{fact}: {out}");
}