From 1c2f0fe65e2aa0840f765a5126a78666cb91729d Mon Sep 17 00:00:00 2001 From: Nathanael Date: Thu, 15 Oct 2026 02:08:15 +0700 Subject: [PATCH] --help and -h print the usage for the app and every CLI command (#968) (#977) * --help and -h print the usage for the app and every CLI command (#968) wordcraft --help / -h printed nothing useful: the app tried to open a file named --help and started. wordcraft-cli --help failed with "unknown option --help" for every command, and -h was taken as a file name or ignored. The app now prints its options and returns before the window opens. The CLI checks for --help / -h after any known command before options are validated or files and folders opened, and prints the shared usage. Tests run both binaries for each command and flag; the app's test runs without a display, so a regression fails instead of opening a window. Co-Authored-By: Claude Opus 5.5 (1M context) * #968: test bare --help and -h on wordcraft-cli Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Brandon Thomas --- ROADMAP.md | 4 ++- apps/wordcraft-cli/src/main.rs | 32 ++++++++++++++++++++++ apps/wordcraft-cli/tests/help.rs | 47 ++++++++++++++++++++++++++++++++ apps/wordcraft/src/main.rs | 21 +++++++++++++- apps/wordcraft/tests/help.rs | 42 ++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 apps/wordcraft-cli/tests/help.rs create mode 100644 apps/wordcraft/tests/help.rs diff --git a/ROADMAP.md b/ROADMAP.md index 64998fb9f..a3849a84d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: ODT tracked changes, gap 19; previously trivial: progress log: RTF tracked changes, gap 19; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: All Caps text in PDFs, #1473; previously trivial: progress log: `--help` for the app and every CLI subcommand, #968; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -184,6 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing | | 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | +| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli --help` works for every command instead of failing with `unknown option --help` | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | | 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents | @@ -364,6 +365,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) | | 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | +| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) | | 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 6a9d44a3b..8f743c47a 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -42,6 +42,7 @@ USAGE: running): addEditCitation, addEditBibliography, addNote, refresh, removeCodes, setDocPrefs (--cmd runs first, e.g. --cmd caret.docEnd) + wordcraft-cli [] -h | --help this help wordcraft-cli --version Paper: documents created here, and HTML, Markdown and text files (which give no page size), use @@ -50,6 +51,13 @@ LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another same output on every machine. "; +/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968). +const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"]; + +fn wants_help(cmd: &str, rest: &[String]) -> bool { + SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h") +} + fn open(path: &str) -> Result { // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's // paper, as in the app: A4 in Italy, Letter in the US (#1381). @@ -205,6 +213,10 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io fn run(args: &[String]) -> Result<(), Failure> { let cmd = args.first().map(String::as_str).unwrap_or(""); let rest: Vec = args.iter().skip(1).cloned().collect(); + // Before options are checked or files and folders opened. + if wants_help(cmd, &rest) { + return write_stdout(|o| write!(o, "{USAGE}")); + } let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}")); match cmd { "convert" => { @@ -502,6 +514,26 @@ mod tests { assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into())); } + #[test] + fn help_or_h_after_a_subcommand_asks_for_the_usage() { + for cmd in SUBCOMMANDS { + assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}"); + assert!(wants_help(cmd, &args(&["-h"])), "{cmd}"); + assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}"); + } + assert!(!wants_help("frobnicate", &args(&["--help"]))); + } + + #[test] + fn usage_lists_exactly_the_subcommands() { + let listed: Vec<&str> = USAGE + .lines() + .filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next()) + .filter(|word| !word.starts_with(['-', '['])) + .collect(); + assert_eq!(listed, SUBCOMMANDS); + } + #[test] fn text_keeps_bullet_and_number_labels() { let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap(); diff --git a/apps/wordcraft-cli/tests/help.rs b/apps/wordcraft-cli/tests/help.rs new file mode 100644 index 000000000..dd40efa39 --- /dev/null +++ b/apps/wordcraft-cli/tests/help.rs @@ -0,0 +1,47 @@ +//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked +//! or files and folders opened (#968). + +use std::process::{Command, Output}; + +const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"]; + +fn cli(args: &[&str]) -> Output { + // `output()` gives the child no stdin, so `mcp` can't wait for a client. + Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap() +} + +fn assert_prints_usage(args: &[&str]) { + let out = cli(args); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status); + assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout)); + assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}"); +} + +#[test] +fn every_subcommand_prints_usage_for_help_and_h() { + assert_prints_usage(&["--help"]); + assert_prints_usage(&["-h"]); + for sub in SUBCOMMANDS { + for flag in ["--help", "-h"] { + assert_prints_usage(&[sub, flag]); + } + } +} + +#[test] +fn help_comes_before_opening_files_and_folders() { + assert_prints_usage(&["info", "missing.docx", "--help"]); + assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]); + assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]); +} + +#[test] +fn misspelled_options_and_unknown_commands_still_fail() { + let out = cli(&["info", "--hepl"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl")); + let out = cli(&["frobnicate", "--help"]); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`")); +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 91c880732..ab3d2ea2e 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -1,6 +1,6 @@ //! WordCraft desktop app. //! -//! Usage: `wordcraft [--control ] [--sample] [files…]` +//! Usage: `wordcraft [--control ] [--sample] [files…]`; `--help` prints `USAGE`. //! //! `--control ` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`. @@ -230,6 +230,20 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +const USAGE: &str = "\ +wordcraft — the WordCraft desktop app + +USAGE: + wordcraft [--sample] [--control PORT] [FILE…] + +OPTIONS: + --sample open the sample document + --control PORT start the localhost control server on PORT, for agents and scripts + (or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md + --version print the version + -h, --help print this help +"; + /// The commit the build came from (short), or `dev`. fn build_sha() -> &'static str { option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev") @@ -251,6 +265,11 @@ fn main() -> eframe::Result { println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha()); return Ok(()); } + // Before the window opens (#968). Unlike `print!`, a closed stdout can't panic. + "--help" | "-h" => { + let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes()); + return Ok(()); + } _ => files.push(a), } } diff --git a/apps/wordcraft/tests/help.rs b/apps/wordcraft/tests/help.rs new file mode 100644 index 000000000..67d1f078a --- /dev/null +++ b/apps/wordcraft/tests/help.rs @@ -0,0 +1,42 @@ +//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968). + +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; + +fn app(flag: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft")) + .arg(flag) + // With no display, an app that tries to open a window fails at once instead. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WORDCRAFT_CONTROL_PORT") + .env("WORDCRAFT_NO_PREFS", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(30); + while child.try_wait().unwrap().is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + panic!("wordcraft {flag} was still running after 30 s: it started the app"); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output().unwrap() +} + +#[test] +fn help_and_h_print_the_options_and_exit() { + for flag in ["--help", "-h"] { + let out = app(flag); + let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status); + assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}"); + for option in ["--sample", "--control PORT", "--version", "-h, --help"] { + assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}"); + } + assert!(stderr.is_empty(), "{flag}: stderr: {stderr}"); + } +}