Files
Maximilian Roos 0b27755726 fix(help): name the doc-entry-point command from clap, not an argv scan (#3762)
Three developer entry points — `--help-page`, `--help-description`, and
`--print-schema` — each carried its own copy of a scan that picked the
subcommand out of argv by rejecting entries that looked like the binary:

```rust
.filter(|a| *a != "--help-page" && !a.starts_with('-') && !a.ends_with("/wt"))
.find(|a| !a.contains("target/") && *a != "wt")
```

Neither `ends_with("/wt")` nor `contains("target/")` matches `wt.exe`
under a backslash path, so on every Windows install all three read the
binary's own path as the command. `--print-schema` exited 2; the other
two exited 0 with empty stdout and `Unknown command: C:\...\wt.exe` on
stderr. The scan also took the value of a value-carrying global as the
command, so `wt -C <path> list --print-schema` answered `No JSON schema
for '<path>'`.

clap had already parsed the same argv one call earlier.
`parse_early_globals` runs the real `Cli` definition with
`ignore_errors(true)` and reads `matches.subcommand()` to pick the
alias-splice context, and the comment above it already said that exists
"so the splice path in `augment_help` has no separate arg scanner". The
doc entry points just never asked it. It now carries the name in an
`EarlyGlobals` struct, and the three handlers take `Option<&str>`
instead of `&[String]`.

Error messages are unchanged: `src/cli/mod.rs` has
`#[command(external_subcommand)]` for user aliases, so a name the `Cli`
definition doesn't declare still arrives intact and can be named back in
`Unknown command: X`.

`--print-schema` now prints through `crate::output::print_json` rather
than `serde_json::to_string_pretty` plus std's `println!`. std's panics
with exit 101 when a consumer closes the pipe; anstream's drops the
`BrokenPipe`. This was the last JSON-to-stdout surface still panicking
after #3746 — the schema landed via #3747 while that PR was in flight,
and it lives outside `src/commands/`, where #3746's stdout guard doesn't
scan.

<details>
<summary>Why CI never caught this</summary>

`tests/integration_tests/help.rs` and
`tests/integration_tests/readme_sync.rs` are both
`#![cfg(not(windows))]`, and on Unix the test binary is always named
`wt` under `target/`, which is exactly the shape the scan was written
against. The new test drives all four cases through a symlink named
`wt.exe` — a symlink rather than a copy because the debug binary is
67MB. Verified to fail against the pre-change code.

</details>

`wt merge --help-page` and `wt merge --help-description` still panic on
a closed pipe. Those use std's `println!`/`print!` deliberately, to
preserve the ANSI codes anstream's would strip, so they need a different
mechanism than this one. Left for a follow-up.

> _This was written by Claude Code on behalf of max-sixty_
2026-08-06 23:13:17 -07:00

432 lines
16 KiB
Rust

//! Snapshot tests for `-h` (short) and `--help` (long) output.
//!
//! These ensure our help formatting stays stable across releases and
//! catches accidental regressions in wording or wrapping.
//!
//! - Short help (`-h`): Compact format, single-line options
//! - Long help (`--help`): Verbose format with `after_long_help` content
//!
//! Skipped on Windows: clap renders markdown differently on Windows (tables, links,
//! emphasis) resulting in formatting-only differences. The help content is identical;
//! only the presentation varies.
#![cfg(not(windows))]
use crate::common::{add_standard_env_redactions, configure_cli_command, wt_bin, wt_command};
use ansi_str::AnsiStr as _;
use insta::Settings;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::process::Command;
/// Insta settings shared by every help / version / usage snapshot: the
/// standard env redactions plus the snapshot path. Routing all of them through
/// one builder is deliberate — a test that constructs its own `Settings` and
/// forgets `add_standard_env_redactions` leaks host-specific env (e.g.
/// `LLVM_PROFILE_FILE`'s temp path) into the recorded `env:` block, which then
/// diffs whenever the snapshot is regenerated on another machine. Callers layer
/// extra filters (e.g. the version-string filter) on the returned value.
fn help_settings() -> Settings {
let mut settings = Settings::clone_current();
settings.set_snapshot_path("../snapshots");
add_standard_env_redactions(&mut settings);
settings
}
fn snapshot_help(test_name: &str, args: &[&str]) {
let settings = help_settings();
settings.bind(|| {
let mut cmd = wt_command();
cmd.args(args);
// Check for double blank lines before snapshotting.
// Double blanks indicate formatting issues (e.g., HTML comments like
// `<!-- demo: file.gif -->` with blank lines on both sides).
let output = cmd.output().expect("failed to run command");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("\n\n\n"),
"Double blank line in help output for `wt {}`",
args.join(" ")
);
// Re-run for snapshot (assert_cmd_snapshot needs the Command)
let mut cmd = wt_command();
cmd.args(args);
assert_cmd_snapshot!(test_name, cmd);
});
}
#[test]
fn test_merge_help_describes_exact_shape_no_rebase() {
let output = wt_command()
.args(["merge", "--help"])
.output()
.expect("failed to run wt merge --help");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let stdout = stdout.ansi_strip();
assert!(
stdout.contains("Skip rebase; require the target to fast-forward to the resulting tip"),
"missing graph-preservation contract:\n{stdout}"
);
assert!(
stdout.contains("wt merge --no-commit --no-rebase"),
"missing exact-shape example:\n{stdout}"
);
assert!(
stdout.contains("explicit --no-rebase preserves the graph produced by earlier steps"),
"missing no-ff qualification:\n{stdout}"
);
}
// Root command (wt)
#[rstest]
#[case("help_root_short", "-h")]
#[case("help_root_long", "--help")]
#[case("help_no_args", "")]
// Major commands - short and long variants
#[case("help_config_short", "config -h")]
#[case("help_config_long", "config --help")]
#[case("help_list_short", "list -h")]
#[case("help_list_long", "list --help")]
#[case("help_switch_short", "switch -h")]
#[case("help_switch_long", "switch --help")]
#[case("help_remove_short", "remove -h")]
#[case("help_remove_long", "remove --help")]
#[case("help_merge_short", "merge -h")]
#[case("help_merge_long", "merge --help")]
#[case("help_step_short", "step -h")]
#[case("help_step_long", "step --help")]
#[case("help_step_promote", "step promote --help")]
#[case("help_step_copy_ignored", "step copy-ignored --help")]
// Config subcommands (long help only - these are less frequently accessed)
#[case("help_config_shell", "config shell --help")]
#[case("help_config_create", "config create --help")]
#[case("help_config_show", "config show --help")]
#[case("help_config_plugins", "config plugins --help")]
#[case("help_config_plugins_codex", "config plugins codex --help")]
#[case(
"help_config_plugins_codex_install",
"config plugins codex install --help"
)]
#[case("help_config_state", "config state --help")]
#[case("help_config_state_cache", "config state cache --help")]
#[case(
"help_config_state_default_branch",
"config state default-branch --help"
)]
#[case(
"help_config_state_previous_branch",
"config state previous-branch --help"
)]
#[case("help_config_state_ci_status", "config state ci-status --help")]
#[case("help_config_state_marker", "config state marker --help")]
#[case("help_config_state_logs", "config state logs --help")]
#[case("help_config_state_logs_profile", "config state logs profile --help")]
#[case("help_config_state_get", "config state get --help")]
#[case("help_config_state_clear", "config state clear --help")]
#[case("help_config_approvals", "config approvals --help")]
#[case("help_config_approvals_add", "config approvals add --help")]
#[case("help_config_approvals_clear", "config approvals clear --help")]
fn test_help(#[case] test_name: &str, #[case] args_str: &str) {
let args: Vec<&str> = if args_str.is_empty() {
vec![]
} else {
args_str.split_whitespace().collect()
};
snapshot_help(test_name, &args);
}
#[test]
fn test_version() {
let mut settings = help_settings();
// Filter out version number for stable snapshots
// Formats:
// - wt v0.4.0-25-gc9bcf6c0 (version with git commit info)
// - wt 7df940e (just git short hash in CI)
// - wt v0.4.0-dirty or wt 7df940e-dirty (uncommitted changes)
settings.add_filter(
r"wt (v\d+\.\d+\.\d+(-[\w.-]+)?|[a-f0-9]{7,40}(?:-dirty)?)",
"wt [VERSION]",
);
settings.bind(|| {
let mut cmd = wt_command();
cmd.arg("--version");
assert_cmd_snapshot!("version", cmd);
});
}
/// `--help` must write to stdout, not stderr. POSIX convention — matches
/// `cargo`, `curl`, `python`, and `git <cmd> -h`. Lets users do
/// `wt --help | less` or `wt --help > help.txt` without redirection.
#[test]
fn test_help_goes_to_stdout() {
for args in [&["--help"][..], &["-h"][..], &["merge", "--help"][..]] {
let output = wt_command()
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to run wt {args:?}: {e}"));
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("Usage:"),
"wt {args:?} should write help to stdout, but stdout was: {stdout:?} (stderr: {stderr:?})"
);
assert!(
stderr.trim().is_empty(),
"wt {args:?} should not write to stderr, but stderr was: {stderr:?}"
);
}
}
/// When stdout is piped, help must be plain text — no ANSI escapes leaking into
/// `wt --help > file.txt` or `wt --help | less`. Built from `wt_command()` for
/// HOME/config isolation, then clears the `CLICOLOR_FORCE` it sets so color
/// detection sees a non-tty (piped) stdout.
#[test]
fn test_help_strips_ansi_when_piped() {
let output = wt_command()
.arg("--help")
.env_remove("CLICOLOR_FORCE")
.env("NO_COLOR", "1")
.output()
.expect("failed to run wt --help");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains('\x1b'),
"wt --help piped to a file must not contain ANSI escapes; got: {stdout:?}"
);
}
/// `--version` must write to stdout, not stderr. This is the POSIX convention
/// and what scripts expect — e.g., `version=$(wt --version)` or test harnesses
/// that grep for a version string from stdout. See #2072.
#[test]
fn test_version_goes_to_stdout() {
let output = wt_command()
.arg("--version")
.output()
.expect("failed to run wt --version");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stdout.contains("wt "),
"wt --version should write to stdout, but stdout was: {stdout:?} (stderr: {stderr:?})"
);
assert!(
stderr.trim().is_empty(),
"wt --version should not write to stderr, but stderr was: {stderr:?}"
);
}
#[test]
fn test_help_md() {
help_settings().bind(|| {
let mut cmd = wt_command();
cmd.args(["--help-md"]);
assert_cmd_snapshot!("help_md_root", cmd);
});
}
#[test]
fn test_help_md_subcommand() {
help_settings().bind(|| {
let mut cmd = wt_command();
cmd.args(["merge", "--help-md"]);
assert_cmd_snapshot!("help_md_merge", cmd);
});
}
/// Verifies width handling when help is narrower than its content:
/// - Markdown tables stay intact (no mid-row breaks), extending past 80 columns.
/// - Captured `wt list` example tables (`<!-- wt list … -->` blocks) are chopped
/// to width with an ellipsis — like real `wt list` — instead of word-wrapping,
/// while hand-authored command sessions (the `jq` examples) still wrap.
#[test]
fn test_help_list_narrow_terminal() {
help_settings().bind(|| {
let mut cmd = wt_command();
cmd.env("COLUMNS", "80");
cmd.args(["list", "--help"]);
assert_cmd_snapshot!("help_list_narrow_80", cmd);
});
}
/// With no detectable width (piped output, no COLUMNS), `terminal_width()`
/// returns `None` and the markdown renderer falls back to its own defaults.
/// Back when "no width" was a `usize::MAX` sentinel, `wt list --help` panicked
/// with a capacity overflow trying to render a `---` rule that wide.
#[test]
fn test_help_without_detectable_width() {
let mut cmd = wt_command();
cmd.env_remove("COLUMNS");
cmd.args(["list", "--help"]);
let output = cmd.output().expect("failed to run command");
assert!(
output.status.success(),
"wt list --help without COLUMNS failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!output.stdout.is_empty(), "help output should not be empty");
}
/// Tests --help-description outputs the meta description for docs frontmatter.
#[rstest]
#[case("switch", "Switch to a worktree; create if needed.")]
#[case(
"merge",
"Merge current branch into the target branch. Squash & rebase"
)]
#[case("hook", "Run configured hooks.")]
fn test_help_description(#[case] cmd: &str, #[case] expected_prefix: &str) {
let output = wt_command()
.args([cmd, "--help-description"])
.output()
.expect("failed to run wt --help-description");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.starts_with(expected_prefix),
"Expected description for '{cmd}' to start with '{expected_prefix}', got: {stdout}"
);
}
#[test]
fn test_help_description_no_subcommand() {
let output = wt_command()
.args(["--help-description"])
.output()
.expect("failed to run wt --help-description");
// Exits 0 (eprintln + return, not process::exit(1))
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Usage:"),
"Expected usage hint, got: {stderr}"
);
}
#[test]
fn test_help_description_unknown_command() {
let output = wt_command()
.args(["nonexistent", "--help-description"])
.output()
.expect("failed to run wt --help-description");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Unknown command"),
"Expected unknown command error, got: {stderr}"
);
}
/// Tests that using a nested subcommand at the top level suggests the correct command.
///
/// When users type `wt squash` instead of `wt step squash`, or `wt pre-merge` instead
/// of `wt hook pre-merge`, they should get a helpful suggestion.
#[rstest]
#[case("nested_subcommand_step_squash", "squash", "wt step squash")]
#[case("nested_subcommand_step_commit", "commit", "wt step commit")]
#[case("nested_subcommand_hook_pre_merge", "pre-merge", "wt hook pre-merge")]
#[case("nested_subcommand_hook_pre_start", "pre-start", "wt hook pre-start")]
fn test_nested_subcommand_suggestion(
#[case] test_name: &str,
#[case] subcommand: &str,
#[case] expected_suggestion: &str,
) {
help_settings().bind(|| {
let mut cmd = wt_command();
cmd.arg(subcommand);
let output = cmd.output().expect("failed to run wt");
// Should fail (exit code 2)
assert_eq!(output.status.code(), Some(2));
// Should contain the suggestion
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains(expected_suggestion),
"Expected stderr to contain '{expected_suggestion}', got:\n{stderr}"
);
// Snapshot the full error output
assert_cmd_snapshot!(test_name, cmd);
});
}
/// `--print-schema` names one command's `--format=json` payload, so an
/// unrecognized or missing command is a usage error rather than a silent
/// empty document. `test_docs_are_in_sync` treats a non-zero exit as a sync
/// failure, which is what keeps a renamed command from quietly publishing
/// nothing.
#[test]
fn test_print_schema_rejects_unknown_command() {
for args in [&["--print-schema"][..], &["merge", "--print-schema"][..]] {
let output = wt_command()
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to run wt {args:?}: {e}"));
assert_eq!(
output.status.code(),
Some(2),
"wt {args:?} should exit 2; stderr: {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"wt {args:?} should print no schema, but stdout was: {:?}",
String::from_utf8_lossy(&output.stdout)
);
}
}
/// The doc-generation entry points take their command from the early clap
/// parse, so neither the binary's name nor a global option carrying a value
/// reaches the answer.
///
/// A hand-rolled argv scan recognized the binary by the shape of its path,
/// which made `wt.exe list --print-schema` — the name every Windows install
/// ships — a request for a command named after the binary, and read the
/// `<path>` in `wt -C <path> list --print-schema` as the command. The name is
/// exercised through a symlink because this module is Unix-only.
#[test]
fn test_dev_entry_points_take_command_from_clap() {
let dir = tempfile::tempdir().expect("create tempdir");
let renamed = dir.path().join("wt.exe");
std::os::unix::fs::symlink(wt_bin(), &renamed).expect("symlink wt binary");
for args in [
&["list", "--print-schema"][..],
&["merge", "--help-page"][..],
&["merge", "--help-description"][..],
&["-C", ".", "list", "--print-schema"][..],
] {
let mut cmd = Command::new(&renamed);
configure_cli_command(&mut cmd);
cmd.current_dir(dir.path());
let output = cmd
.args(args)
.output()
.unwrap_or_else(|e| panic!("failed to run wt.exe {args:?}: {e}"));
assert_eq!(
output.status.code(),
Some(0),
"wt.exe {args:?} should succeed; stderr: {:?}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!output.stdout.is_empty(),
"wt.exe {args:?} printed nothing; stderr: {:?}",
String::from_utf8_lossy(&output.stderr)
);
}
}