From 82ae32f14866cb58d07d0d301bc54e60b50353fc Mon Sep 17 00:00:00 2001 From: thedancingdeveloper Date: Mon, 26 Oct 2026 13:36:00 +0000 Subject: [PATCH] Add generated completions and man pages --- Cargo.lock | 27 +++++ Cargo.toml | 2 + changes/WI-2540.md | 1 + crates/graft-cli/Cargo.toml | 2 + crates/graft-cli/src/cli.rs | 37 ++++++- crates/graft-cli/src/cmd/completions.rs | 46 ++++++++ crates/graft-cli/src/cmd/mod.rs | 1 + crates/graft-cli/src/main.rs | 2 + crates/graft-cli/tests/cli.rs | 49 ++------- crates/graft-cli/tests/completions.rs | 104 ++++++++++++++++++ .../snapshots/cli__completions_help.snap | 19 ++++ .../tests/snapshots/cli__top_level_help.snap | 57 +++++----- crates/graft-cli/tests/support/mod.rs | 41 +++++++ 13 files changed, 320 insertions(+), 68 deletions(-) create mode 100644 changes/WI-2540.md create mode 100644 crates/graft-cli/src/cmd/completions.rs create mode 100644 crates/graft-cli/tests/completions.rs create mode 100644 crates/graft-cli/tests/snapshots/cli__completions_help.snap diff --git a/Cargo.lock b/Cargo.lock index 6f313b0..aff5c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,6 +489,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -507,6 +516,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clru" version = "0.6.3" @@ -1870,6 +1889,8 @@ dependencies = [ "assert_cmd", "base64 0.22.1", "clap", + "clap_complete", + "clap_mangen", "graft-client", "graft-engine", "graft-lsp", @@ -3923,6 +3944,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rsa" version = "0.9.10" diff --git a/Cargo.toml b/Cargo.toml index 42419ed..2924b6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ axum = "0.8" base64 = "0.22" blake3 = "1.8" clap = { version = "4.6", features = ["derive"] } +clap_complete = "4.6" +clap_mangen = "0.2" criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } ed25519-dalek = "2.2" flate2 = "1.1" diff --git a/changes/WI-2540.md b/changes/WI-2540.md new file mode 100644 index 0000000..98d3806 --- /dev/null +++ b/changes/WI-2540.md @@ -0,0 +1 @@ +- graft-cli: add generated shell completions for bash, zsh, fish, elvish and PowerShell, plus a hidden command that writes man pages for every public command. Hidden and experimental commands are excluded from both outputs. (WI-2540) diff --git a/crates/graft-cli/Cargo.toml b/crates/graft-cli/Cargo.toml index 3b55436..33abbc0 100644 --- a/crates/graft-cli/Cargo.toml +++ b/crates/graft-cli/Cargo.toml @@ -19,6 +19,8 @@ workspace = true [dependencies] base64.workspace = true clap.workspace = true +clap_complete.workspace = true +clap_mangen.workspace = true graft-client.workspace = true graft-mirror = { path = "../graft-mirror" } graft-node = { path = "../graft-node" } diff --git a/crates/graft-cli/src/cli.rs b/crates/graft-cli/src/cli.rs index 2e73fba..d8d5620 100644 --- a/crates/graft-cli/src/cli.rs +++ b/crates/graft-cli/src/cli.rs @@ -4,7 +4,8 @@ use std::path::PathBuf; use clap::parser::ValueSource; -use clap::{ArgMatches, Args, Parser, Subcommand}; +use clap::{ArgMatches, Args, CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; /// The default line when no working copy or `--at` names a snapshot. pub(crate) const DEFAULT_LINE: &str = "main"; @@ -134,6 +135,19 @@ pub(crate) enum Command { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Print a shell completion script + Completions { + /// Shell to generate completions for + #[arg(value_enum)] + shell: Shell, + }, + /// Generate manual pages + #[command(hide = true)] + Man { + /// Directory to write manual pages into + #[arg(long, value_name = "DIR")] + out: PathBuf, + }, /// Run the authoritative server #[command(hide = true)] Serve(ServeArgs), @@ -148,6 +162,27 @@ pub(crate) enum Command { Planned(Vec), } +/// The public command tree used by generated documentation and completions. +pub(crate) fn public_command() -> clap::Command { + let command = Cli::command(); + clap::Command::new("graft") + .about("The graft command-line client.") + .disable_version_flag(true) + .arg_required_else_help(true) + .args( + command + .get_arguments() + .filter(|argument| !argument.is_hide_set()) + .cloned(), + ) + .subcommands( + command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .cloned(), + ) +} + /// Which MCP client a registration snippet is for. #[derive(Clone, Copy, Debug, clap::ValueEnum)] pub(crate) enum McpClient { diff --git a/crates/graft-cli/src/cmd/completions.rs b/crates/graft-cli/src/cmd/completions.rs new file mode 100644 index 0000000..b7a0919 --- /dev/null +++ b/crates/graft-cli/src/cmd/completions.rs @@ -0,0 +1,46 @@ +//! Shell completion and manual-page generation from the clap command tree. + +use std::fs::{self, File}; +use std::io; +use std::path::Path; + +use clap_complete::Shell; +use clap_mangen::Man; + +use crate::Exit; +use crate::cli; +use crate::error::Failure; + +/// Prints a completion script for `shell`. +pub(crate) fn run(shell: Shell) -> Result<(), Failure> { + let mut command = cli::public_command(); + clap_complete::generate(shell, &mut command, "graft", &mut io::stdout()); + Ok(()) +} + +/// Writes one manual page for graft and each visible top-level subcommand. +pub(crate) fn man(out: &Path) -> Result<(), Failure> { + fs::create_dir_all(out).map_err(|error| io_failure(out, error))?; + + let command = cli::public_command(); + render(&command, &out.join("graft.1"))?; + for subcommand in command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + { + let name = subcommand.get_name(); + render(subcommand, &out.join(format!("graft-{name}.1")))?; + } + Ok(()) +} + +fn render(command: &clap::Command, path: &Path) -> Result<(), Failure> { + let mut file = File::create(path).map_err(|error| io_failure(path, error))?; + Man::new(command.clone()) + .render(&mut file) + .map_err(|error| io_failure(path, error)) +} + +fn io_failure(path: &Path, error: io::Error) -> Failure { + Failure::new(Exit::Failure, "io", format!("{}: {error}", path.display())) +} diff --git a/crates/graft-cli/src/cmd/mod.rs b/crates/graft-cli/src/cmd/mod.rs index e4f414f..937b1b9 100644 --- a/crates/graft-cli/src/cmd/mod.rs +++ b/crates/graft-cli/src/cmd/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod cat; pub(crate) mod ci; pub(crate) mod clone; pub(crate) mod commit; +pub(crate) mod completions; pub(crate) mod config; pub(crate) mod diff; pub(crate) mod init; diff --git a/crates/graft-cli/src/main.rs b/crates/graft-cli/src/main.rs index 9b23f6e..842256c 100644 --- a/crates/graft-cli/src/main.rs +++ b/crates/graft-cli/src/main.rs @@ -295,6 +295,8 @@ fn dispatch(cli: &Cli) -> Result<(), Failure> { Exit::Ok => Ok(()), code => Err(Failure::silent(code)), }, + Command::Completions { shell } => cmd::completions::run(*shell), + Command::Man { out } => cmd::completions::man(out), Command::Serve(_) | Command::Node(_) | Command::Mirror(_) => { unreachable!("role command") } diff --git a/crates/graft-cli/tests/cli.rs b/crates/graft-cli/tests/cli.rs index 888b904..564d47a 100644 --- a/crates/graft-cli/tests/cli.rs +++ b/crates/graft-cli/tests/cli.rs @@ -154,6 +154,16 @@ fn top_level_help_snapshot() { insta::assert_snapshot!("top_level_help", text(out.get_output())); } +#[test] +fn completions_help_snapshot() { + let dir = scratch(); + let out = graft(dir.path()) + .args(["completions", "--help"]) + .assert() + .success(); + insta::assert_snapshot!("completions_help", text(out.get_output())); +} + #[test] fn experimental_commands_are_hidden_and_gated() { let dir = scratch(); @@ -174,45 +184,6 @@ fn experimental_commands_are_hidden_and_gated() { .stderr(contains("experimental; enable with GRAFT_EXPERIMENTAL=1")); } -fn assert_no_internal_references(command: &[&str], help: &str) { - const LITERALS: &[&str] = &[ - "pr bridge", - "crates/", - "owned by", - "vogt", - "trial", - "graft-dev", - "phase", - ]; - let lowercase = help.to_ascii_lowercase(); - for leak in LITERALS { - assert!( - !lowercase.contains(leak), - "graft {} --help leaked {leak:?}:\n{help}", - command.join(" ") - ); - } - for word in help.split(|character: char| !character.is_ascii_alphanumeric() && character != '-') - { - let numbered = (word.starts_with("WI-") - && word.len() > 3 - && word[3..] - .chars() - .all(|character| character.is_ascii_digit())) - || ((word.starts_with('D') || word.starts_with('G')) - && word.len() > 1 - && word[1..] - .chars() - .next() - .is_some_and(|character| character.is_ascii_digit())); - assert!( - !numbered, - "graft {} --help leaked internal reference {word:?}:\n{help}", - command.join(" ") - ); - } -} - #[test] fn clone_human_and_json() { let s = serve(); diff --git a/crates/graft-cli/tests/completions.rs b/crates/graft-cli/tests/completions.rs new file mode 100644 index 0000000..d42ca66 --- /dev/null +++ b/crates/graft-cli/tests/completions.rs @@ -0,0 +1,104 @@ +//! Generated shell completion and manual-page acceptance tests. + +mod support; + +use std::fs; + +use support::{assert_no_internal_references, graft, scratch}; + +const HIDDEN_COMMANDS: &[&str] = &["attest", "ci", "serve", "node", "mirror", "man"]; +const HIDDEN_DESCRIPTIONS: &[&str] = &[ + "Record a check result for a changeset", + "CI helpers", + "Run the authoritative server", + "Run the host cache daemon", + "Import, follow, batch or export git repositories", + "Generate manual pages", + "Commands designed but not built yet", +]; + +#[test] +fn completions_cover_public_commands_and_options() { + for shell in ["bash", "zsh", "fish", "elvish", "powershell"] { + let dir = scratch(); + let output = graft(dir.path()) + .args(["completions", shell]) + .output() + .expect("completion command runs"); + assert!(output.status.success(), "{shell}: {output:?}"); + let script = String::from_utf8(output.stdout).expect("completion script is text"); + assert!(!script.is_empty(), "{shell} completion is empty"); + for expected in ["land", "sync"] { + assert!( + script.contains(expected), + "{shell} completion lacks {expected}" + ); + } + let json_option = if shell == "fish" { "-l json" } else { "--json" }; + assert!( + script.contains(json_option), + "{shell} completion lacks --json" + ); + for hidden in HIDDEN_DESCRIPTIONS { + assert!( + !script.contains(hidden), + "{shell} completion exposed hidden command {hidden}" + ); + } + assert_no_internal_references(&["completions", shell], &script); + } +} + +#[test] +fn man_writes_root_and_every_visible_subcommand() { + let dir = scratch(); + let out = dir.path().join("man"); + graft(dir.path()) + .args(["man", "--out"]) + .arg(&out) + .assert() + .success(); + + let root = fs::read_to_string(out.join("graft.1")).expect("root man page"); + let land = fs::read_to_string(out.join("graft-land.1")).expect("land man page"); + assert!(!root.is_empty()); + assert!(!land.is_empty()); + assert_no_internal_references(&["man"], &root); + assert_no_internal_references(&["man", "land"], &land); + + for hidden in HIDDEN_COMMANDS { + assert!( + !out.join(format!("graft-{hidden}.1")).exists(), + "wrote a page for hidden command {hidden}" + ); + assert!( + !root.contains(&format!("graft\\-{hidden}(1)")), + "root page exposed hidden command {hidden}" + ); + } + + for visible in public_subcommands() { + let page = out.join(format!("graft-{visible}.1")); + let contents = + fs::read_to_string(&page).unwrap_or_else(|error| panic!("{}: {error}", page.display())); + assert!(!contents.is_empty(), "{} is empty", page.display()); + assert_no_internal_references(&["man", &visible], &contents); + } +} + +fn public_subcommands() -> Vec { + let dir = scratch(); + let output = graft(dir.path()) + .arg("--help") + .output() + .expect("help command runs"); + let help = String::from_utf8(output.stdout).expect("help is text"); + help.lines() + .skip_while(|line| *line != "Commands:") + .skip(1) + .take_while(|line| line.starts_with(" ")) + .filter_map(|line| line.split_whitespace().next()) + .filter(|name| *name != "help") + .map(str::to_owned) + .collect() +} diff --git a/crates/graft-cli/tests/snapshots/cli__completions_help.snap b/crates/graft-cli/tests/snapshots/cli__completions_help.snap new file mode 100644 index 0000000..e886a60 --- /dev/null +++ b/crates/graft-cli/tests/snapshots/cli__completions_help.snap @@ -0,0 +1,19 @@ +--- +source: crates/graft-cli/tests/cli.rs +expression: text(out.get_output()) +--- +Print a shell completion script + +Usage: graft completions [OPTIONS] + +Arguments: + Shell to generate completions for [possible values: bash, elvish, fish, powershell, zsh] + +Options: + --json Print stable JSON; its schema is published with the release + --endpoint Where to send requests: `http://host:port`, `https://…`, or `unix:/path` + --repo Repository name (default: the working copy's) + --token-file File holding the bearer token. `GRAFT_TOKEN` is the other source. A token is never taken from an argument + --server Named server in the user config. Its endpoint and token file are used; the token is never an argument + -C, --directory Run as if started in this directory, like `git -C` + -h, --help Print help diff --git a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap index e4e8b55..9ab3352 100644 --- a/crates/graft-cli/tests/snapshots/cli__top_level_help.snap +++ b/crates/graft-cli/tests/snapshots/cli__top_level_help.snap @@ -1,6 +1,6 @@ --- source: crates/graft-cli/tests/cli.rs -assertion_line: 153 +assertion_line: 154 expression: text(out.get_output()) --- The graft command-line client. @@ -8,33 +8,34 @@ The graft command-line client. Usage: graft [OPTIONS] Commands: - login Store credentials for a named server, or list configured servers - logout Remove a named server and its stored token - config Inspect user configuration - init Create a local repository and working copy - clone Open a workspace and lay out a sparse working copy - status Working-copy changes against the head draft, else the base (no server needed) - diff Diff the working copy, or two changesets - log First-parent history - show A changeset with its patch - blame Line origins for a file - cat Print a file at a changeset - ls List a directory at a changeset - sparse List, add or remove the slice's path prefixes - commit Upload the slice's changes and create a draft on the server - workspace Show, list, open, renew or close workspaces - line Create or list lines - tag Create or list immutable tags - stack List, squash, reorder, split or drop the drafts between the base and the head - sync Move the base to the line tip (the server rebases a stack; conflicts are written into the files) - land Land the stack on the line - revert Add a draft that inverts a changeset - pick Add a draft that re-applies a landed changeset (a cherry-pick; conflicts are written into the files) - api Call a read operation of the API, with JSON in and out - version Print versions - mcp Run the MCP server over stdio, for agents - lsp Run the LSP server over stdio - help Print this message or the help of the given subcommand(s) + login Store credentials for a named server, or list configured servers + logout Remove a named server and its stored token + config Inspect user configuration + init Create a local repository and working copy + clone Open a workspace and lay out a sparse working copy + status Working-copy changes against the head draft, else the base (no server needed) + diff Diff the working copy, or two changesets + log First-parent history + show A changeset with its patch + blame Line origins for a file + cat Print a file at a changeset + ls List a directory at a changeset + sparse List, add or remove the slice's path prefixes + commit Upload the slice's changes and create a draft on the server + workspace Show, list, open, renew or close workspaces + line Create or list lines + tag Create or list immutable tags + stack List, squash, reorder, split or drop the drafts between the base and the head + sync Move the base to the line tip (the server rebases a stack; conflicts are written into the files) + land Land the stack on the line + revert Add a draft that inverts a changeset + pick Add a draft that re-applies a landed changeset (a cherry-pick; conflicts are written into the files) + api Call a read operation of the API, with JSON in and out + version Print versions + mcp Run the MCP server over stdio, for agents + lsp Run the LSP server over stdio + completions Print a shell completion script + help Print this message or the help of the given subcommand(s) Options: --json Print stable JSON; its schema is published with the release diff --git a/crates/graft-cli/tests/support/mod.rs b/crates/graft-cli/tests/support/mod.rs index 70f94e8..6f518c0 100644 --- a/crates/graft-cli/tests/support/mod.rs +++ b/crates/graft-cli/tests/support/mod.rs @@ -522,6 +522,47 @@ pub fn scratch() -> tempfile::TempDir { tempfile::tempdir().expect("tempdir") } +/// Asserts that public output does not expose internal planning references. +pub fn assert_no_internal_references(command: &[&str], output: &str) { + const LITERALS: &[&str] = &[ + "pr bridge", + "crates/", + "owned by", + "vogt", + "trial", + "graft-dev", + "phase", + ]; + let lowercase = output.to_ascii_lowercase(); + for leak in LITERALS { + assert!( + !lowercase.contains(leak), + "graft {} output leaked {leak:?}:\n{output}", + command.join(" ") + ); + } + for word in + output.split(|character: char| !character.is_ascii_alphanumeric() && character != '-') + { + let numbered = (word.starts_with("WI-") + && word.len() > 3 + && word[3..] + .chars() + .all(|character| character.is_ascii_digit())) + || ((word.starts_with('D') || word.starts_with('G')) + && word.len() > 1 + && word[1..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_digit())); + assert!( + !numbered, + "graft {} output leaked internal reference {word:?}:\n{output}", + command.join(" ") + ); + } +} + /// The schema file for a command's JSON output. pub fn schema_path(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR"))