diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 420683942..96725cd8d 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,6 @@ //! PdfCraft desktop app. //! -//! Usage: `pdfcraft [options] [files…]` +//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]). //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -56,6 +56,32 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/pdfcraft-10 #[cfg(not(target_os = "macos"))] const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png"); +/// What `-h` / `--help` prints (#4076): a hint, not the full option list (that is this file's +/// doc comment and the README). Scripts want `pdfcraft-cli`, which never opens a window. +const USAGE: &str = "\ +Usage: pdfcraft [options] [files…] + +Opens the files in the PdfCraft desktop app. + +Options: + --new-window open a window of its own instead of passing the files to a running PdfCraft + --create-images [images…] put the images in one PDF and ask for the page DPI + --control enable the UI control channel (pdfcraft-cli ui --control …) + --page N, --zoom 150, --layout continuous, --theme dark, … + view settings applied once the files are open + -h, --help print this and exit + --version print the version and exit + +To inspect, convert or edit PDFs from a script, use pdfcraft-cli (pdfcraft-cli --help)."; + +/// Print `text` for `--help` and `--version`. `println!` would panic on a full or closed +/// standard output; this exits normally instead. +fn print_and_exit(text: &str) -> eframe::Result { + use std::io::Write; + let _ = writeln!(std::io::stdout().lock(), "{text}"); + Ok(()) +} + /// The app was called PrintCraft before; settings saved then are under this key. const LEGACY_STORAGE_KEY: &str = "printcraft"; @@ -196,10 +222,9 @@ fn main() -> eframe::Result { let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { - "--version" => { - println!("pdfcraft {}", pdfcraft_engine::build_info::version()); - return Ok(()); - } + "--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())), + // Before any window or log file, so a scripted call returns (#4076). + "-h" | "--help" => return print_and_exit(USAGE), "--control" => control_file = args.next(), "--create-images" => create_images = true, "--new-window" => new_window = true, diff --git a/apps/pdfcraft/tests/help.rs b/apps/pdfcraft/tests/help.rs new file mode 100644 index 000000000..fce9a8dd3 --- /dev/null +++ b/apps/pdfcraft/tests/help.rs @@ -0,0 +1,55 @@ +//! `--help`, `-h` and `--version` print and exit without opening a window (#4076), so a script that +//! calls them returns. + +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft"); + +/// Run the app with `args`, failing if it is still running after 60 s (it opened its window). +fn run(args: &[&str], stdout: Stdio) -> Output { + let mut child = Command::new(BIN).args(args).stdin(Stdio::null()).stdout(stdout).stderr(Stdio::piped()).spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(60); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("pdfcraft {args:?} did not exit: it started the app instead"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn help_prints_usage_and_exits() { + for flag in ["--help", "-h"] { + let out = run(&[flag], Stdio::piped()); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.starts_with("Usage: pdfcraft [options] [files…]"), "{flag}: {stdout}"); + assert!(stdout.contains("pdfcraft-cli"), "{flag}: points scripts to the CLI: {stdout}"); + } + // Wherever it is on the command line, and with files named. + let out = run(&["missing.pdf", "--help"], Stdio::piped()); + assert!(out.status.success()); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("Usage:")); +} + +#[test] +fn version_prints_the_version_and_exits() { + let out = run(&["--version"], Stdio::piped()); + assert!(out.status.success(), "{:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("pdfcraft "), "{}", String::from_utf8_lossy(&out.stdout)); +} + +/// A standard output that can't be written (here a full device) ends the run normally: `println!` +/// would panic and exit 101. +#[cfg(target_os = "linux")] +#[test] +fn help_and_version_survive_an_unwritable_stdout() { + for flag in ["--help", "--version"] { + let full = std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap(); + let out = run(&[flag], Stdio::from(full)); + assert!(out.status.success(), "{flag}: {:?} {}", out.status, String::from_utf8_lossy(&out.stderr)); + } +}