diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a34660..96da04b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,9 +60,39 @@ jobs: - run: cargo clippy --all-targets --all-features -- -D warnings - run: cargo test --all-features + # Completions and the man page are text generated from the flag definitions, + # the same on every platform, so they are made once here rather than by each + # build — which could not run its own binary anyway where it cross-compiles, + # as the Intel Mac build does on an ARM runner. + extras: + name: Completions and man page + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Generate + run: | + cargo build --locked + bin=target/debug/pokeductor + mkdir -p extras/completions extras/man + "$bin" --completions bash > extras/completions/pokeductor.bash + "$bin" --completions zsh > extras/completions/_pokeductor + "$bin" --completions fish > extras/completions/pokeductor.fish + "$bin" --completions powershell > extras/completions/_pokeductor.ps1 + "$bin" --completions elvish > extras/completions/pokeductor.elv + "$bin" --man > extras/man/pokeductor.1 + ls -l extras/* + - uses: actions/upload-artifact@v4 + with: + name: extras + path: extras + if-no-files-found: error + build: name: ${{ matrix.target }} - needs: verify + needs: [verify, extras] runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -96,6 +126,11 @@ jobs: - run: cargo build --release --locked --target ${{ matrix.target }} + - uses: actions/download-artifact@v5 + with: + name: extras + path: extras + # tar on Unix, zip on Windows — each platform's `curl | extract` habit. # The checksum sits beside the archive rather than in one combined file so # a single download can be verified without fetching anything else. @@ -110,6 +145,7 @@ jobs: cp "target/${{ matrix.target }}/release/pokeductor" "dist/$name/" fi cp README.md LICENSE CHANGELOG.md "dist/$name/" + cp -r extras/completions extras/man "dist/$name/" cd dist if [ "${{ runner.os }}" = "Windows" ]; then 7z a "$name.zip" "$name" > /dev/null @@ -141,9 +177,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + # Only the per-target archives. `extras` is already inside every one of + # them, and its directories would make `dist/*` below fail to upload. - uses: actions/download-artifact@v5 with: path: dist + pattern: "*-*" merge-multiple: true # The notes are the changelog's section for this version, so the two are diff --git a/CHANGELOG.md b/CHANGELOG.md index a916cc5..cbecec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ Tagging began at `0.3.0`, so those two link commit ranges rather than tags. number, not the internal record. `NAME` has to name exactly one species; a miss or an ambiguous fragment exits `1` with the reason on stderr. (#33) +- Shell completions and a man page. `--completions SHELL` prints the script for + bash, zsh, fish, PowerShell or elvish, and a hidden `--man` prints the page. + Both are generated from the flag definitions, and every release archive now + carries them under `completions/` and `man/`. (#38) + ## [0.5.0] - 2026-09-22 ### Added diff --git a/Cargo.lock b/Cargo.lock index d9d9344..33bc742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,6 +188,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.4" @@ -206,6 +215,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -988,6 +1007,8 @@ version = "0.5.0" dependencies = [ "anyhow", "clap", + "clap_complete", + "clap_mangen", "crossterm", "futures", "image", @@ -1086,7 +1107,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1212,6 +1233,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index b4330e1..808c5b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,12 @@ anyhow = "1" thiserror = "2" image = { version = "0.25", default-features = false, features = ["png"] } clap = { version = "4.6.6", features = ["derive"] } +# Completions and the man page come out of the binary itself rather than a +# build script or an xtask, so anything that can build the crate — the release +# workflow, a PKGBUILD working from the crates.io tarball — can produce them +# from the exact flags it just built. Between them they add one crate, `roff`. +clap_complete = "4.6" +clap_mangen = "0.3" [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 2fa8dce..0fe6819 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,19 @@ tar xzf pokeductor-v0.5.0-x86_64-unknown-linux-musl.tar.gz ./pokeductor-v0.5.0-x86_64-unknown-linux-musl/pokeductor ``` +### Shell completions and man page + +Release archives carry `completions/` for bash, zsh, fish, PowerShell and +elvish, and `man/pokeductor.1`. Installed any other way, the binary prints +them itself, generated from the same flag definitions `--help` is: + +```bash +pokeductor --completions fish > ~/.config/fish/completions/pokeductor.fish +pokeductor --completions bash > ~/.local/share/bash-completion/completions/pokeductor +pokeductor --completions zsh > "${fpath[1]}/_pokeductor" +pokeductor --man > pokeductor.1 # for packagers; left out of --help +``` + ### With cargo ```bash @@ -422,14 +435,15 @@ Arguments: [NAME] Open directly on this species, e.g. `pokeductor gengar` Options: - --lang Start in this UI language [possible values: en, tr, de, fr, es, it] - --color How much colour the terminal can show [default: auto] [possible values: auto, truecolor, 256, never] - --theme Draw the interface in this palette [possible values: pico8, dmg] - --json Print NAME as JSON and exit, instead of opening the interface - --clear-cache Delete the on-disk cache and exit - --cache-dir Print the cache directory and exit - -h, --help Print help (see more with '--help') - -V, --version Print version + --lang Start in this UI language [possible values: en, tr, de, fr, es, it] + --color How much colour the terminal can show [default: auto] [possible values: auto, truecolor, 256, never] + --theme Draw the interface in this palette [possible values: pico8, dmg] + --json Print NAME as JSON and exit, instead of opening the interface + --clear-cache Delete the on-disk cache and exit + --cache-dir Print the cache directory and exit + --completions Print a completion script for SHELL and exit [possible values: bash, elvish, fish, powershell, zsh] + -h, --help Print help (see more with '--help') + -V, --version Print version ``` `NAME` goes into the search box rather than through a parser of its own, so diff --git a/src/cli.rs b/src/cli.rs index 71d221d..832be03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,16 +7,20 @@ //! what you reach for from a shell when something looks wrong, and until now //! they meant finding `$XDG_CACHE_HOME/pokeductor` by hand and guessing. //! `--json` is the other: the one way for a script to read what the app knows, -//! which it cannot do through a terminal interface. +//! which it cannot do through a terminal interface. `--completions` and the +//! hidden `--man` are for whoever installs it, so the shell and `man` know the +//! flags below without anyone writing them out a second time. //! //! Output here stays in English while the interface is translated. Clap writes //! its own help and errors in English regardless, so translating the handful of //! lines around them would make the surface less consistent, not more. +use std::io::Write; use std::path::Path; use clap::builder::PossibleValue; -use clap::{Parser, ValueEnum}; +use clap::{CommandFactory, Parser, ValueEnum}; +use clap_complete::Shell; use crate::cache; use crate::color::Choice; @@ -62,6 +66,15 @@ pub struct Cli { /// Print the cache directory and exit #[arg(long, conflicts_with_all = ["name", "lang", "color", "theme", "json"])] cache_dir: bool, + + /// Print a completion script for SHELL and exit + #[arg(long, value_enum, value_name = "SHELL", exclusive = true)] + completions: Option, + + /// Print the man page, as roff, and exit. For packagers, so it stays out + /// of `--help`. + #[arg(long, hide = true, exclusive = true)] + man: bool, } /// The state the arguments ask the TUI to open in. @@ -100,6 +113,16 @@ pub async fn run() -> anyhow::Result { /// The half of [`run`] that does not touch the process arguments, so the /// behaviour is reachable from a test with a hand-built `Cli`. async fn dispatch(cli: Cli) -> anyhow::Result { + if let Some(shell) = cli.completions { + print(&completions(shell))?; + return Ok(Outcome::Handled); + } + + if cli.man { + print(&man_page()?)?; + return Ok(Outcome::Handled); + } + if cli.cache_dir { println!("{}", cache_dir()?.display()); return Ok(Outcome::Handled); @@ -129,6 +152,33 @@ async fn dispatch(cli: Cli) -> anyhow::Result { })) } +/// The completion script for `shell`, generated from the flags as defined. +fn completions(shell: Shell) -> Vec { + let mut command = Cli::command(); + let name = command.get_name().to_string(); + let mut script = Vec::new(); + clap_complete::generate(shell, &mut command, name, &mut script); + script +} + +/// The man page, generated from the same definition `--help` is. +fn man_page() -> std::io::Result> { + let mut page = Vec::new(); + clap_mangen::Man::new(Cli::command()).render(&mut page)?; + Ok(page) +} + +/// Writes `bytes` to stdout, treating a reader that stopped early — `| head` — +/// as done rather than as a failure. `println!` panics there, and a script +/// that only wanted the first lines should not see a panic message for it. +pub fn print(bytes: &[u8]) -> std::io::Result<()> { + let mut out = std::io::stdout().lock(); + match out.write_all(bytes).and_then(|()| out.flush()) { + Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), + other => other, + } +} + /// The resolved cache directory, or an error explaining why there is none. /// /// [`cache::dir`] answering `None` means no home directory could be worked @@ -197,7 +247,6 @@ impl ValueEnum for Theme { #[cfg(test)] mod tests { use super::*; - use clap::CommandFactory; fn parse(args: &[&str]) -> Result { Cli::try_parse_from(std::iter::once("pokeductor").chain(args.iter().copied())) @@ -305,6 +354,64 @@ mod tests { assert!(parse(&["--json", "gengar", "--clear-cache"]).is_err()); } + /// Every flag a user can see, spelled as it is typed. + fn visible_long_flags() -> Vec { + Cli::command() + .get_arguments() + .filter(|arg| !arg.is_hide_set()) + .filter_map(|arg| arg.get_long()) + .map(|long| format!("--{long}")) + .collect() + } + + #[test] + fn every_completion_script_knows_every_visible_flag() { + let flags = visible_long_flags(); + assert!(flags.contains(&"--json".to_string()), "sanity: {flags:?}"); + for shell in Shell::value_variants() { + let script = String::from_utf8(completions(*shell)).unwrap(); + for flag in &flags { + // Some shells list a flag by its bare name. + let bare = flag.trim_start_matches('-'); + assert!( + script.contains(flag.as_str()) || script.contains(bare), + "{shell} completions are missing {flag}" + ); + } + } + } + + #[test] + fn completion_scripts_offer_the_values_a_flag_accepts() { + let script = String::from_utf8(completions(Shell::Bash)).unwrap(); + for value in ["pico8", "dmg", "truecolor", "tr", "de"] { + assert!(script.contains(value), "bash completions lack {value}"); + } + } + + #[test] + fn the_man_page_documents_every_visible_flag() { + let page = String::from_utf8(man_page().unwrap()).unwrap(); + assert!(page.starts_with(".ie"), "roff, not plain text"); + for flag in visible_long_flags() { + // roff escapes a hyphen that must not be typeset as a dash. + let escaped = flag.replace('-', "\\-"); + assert!(page.contains(&escaped), "man page is missing {flag}"); + } + // `--man` itself is for packagers and stays out of the page. + assert!(!page.contains("\\-\\-man")); + } + + #[test] + fn the_generators_stand_alone() { + assert!(parse(&["--completions", "fish"]).is_ok()); + assert!(parse(&["--man"]).is_ok()); + assert!(parse(&["--completions", "fish", "gengar"]).is_err()); + assert!(parse(&["--completions", "fish", "--man"]).is_err()); + assert!(parse(&["--man", "--lang", "tr"]).is_err()); + assert!(parse(&["--completions", "tcsh"]).is_err()); + } + #[test] fn an_unknown_flag_is_an_error_rather_than_a_species_name() { assert!(parse(&["--shiny"]).is_err()); diff --git a/src/json.rs b/src/json.rs index 40c03e7..be2110b 100644 --- a/src/json.rs +++ b/src/json.rs @@ -13,12 +13,11 @@ //! preference, and a script comparing `genus` across machines should not get a //! different answer from each. -use std::io::Write; - use serde::Serialize; use crate::api; use crate::cache; +use crate::cli; use crate::models::{EvolutionTree, PokemonDetail, PokemonEntry, StatKind}; /// Goes up when a field is renamed, removed or changes type. See the module @@ -36,19 +35,12 @@ pub async fn run(name: &str) -> anyhow::Result<()> { print(&species) } -/// Writes the output, treating a reader that stopped early — `| head` — as -/// done rather than as a failure. `println!` panics there, and a script that -/// only wanted the first lines should not see a panic message for it. +/// Writes the output through [`cli::print`], which ends quietly on a closed +/// pipe. fn print(species: &Species) -> anyhow::Result<()> { - let mut out = std::io::stdout().lock(); - let written = serde_json::to_writer_pretty(&mut out, species) - .map_err(std::io::Error::from) - .and_then(|()| writeln!(out)) - .and_then(|()| out.flush()); - match written { - Err(err) if err.kind() == std::io::ErrorKind::BrokenPipe => Ok(()), - other => Ok(other?), - } + let mut out = serde_json::to_vec_pretty(species)?; + out.push(b'\n'); + Ok(cli::print(&out)?) } /// The master list, cache first. A stale list is refreshed, and still used if