Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 28 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -422,14 +435,15 @@ Arguments:
[NAME] Open directly on this species, e.g. `pokeductor gengar`

Options:
--lang <LANG> Start in this UI language [possible values: en, tr, de, fr, es, it]
--color <WHEN> How much colour the terminal can show [default: auto] [possible values: auto, truecolor, 256, never]
--theme <PALETTE> 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 <LANG> Start in this UI language [possible values: en, tr, de, fr, es, it]
--color <WHEN> How much colour the terminal can show [default: auto] [possible values: auto, truecolor, 256, never]
--theme <PALETTE> 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 <SHELL> 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
Expand Down
113 changes: 110 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Shell>,

/// 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.
Expand Down Expand Up @@ -100,6 +113,16 @@ pub async fn run() -> anyhow::Result<Outcome> {
/// 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<Outcome> {
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);
Expand Down Expand Up @@ -129,6 +152,33 @@ async fn dispatch(cli: Cli) -> anyhow::Result<Outcome> {
}))
}

/// The completion script for `shell`, generated from the flags as defined.
fn completions(shell: Shell) -> Vec<u8> {
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<Vec<u8>> {
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
Expand Down Expand Up @@ -197,7 +247,6 @@ impl ValueEnum for Theme {
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;

fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("pokeductor").chain(args.iter().copied()))
Expand Down Expand Up @@ -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<String> {
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());
Expand Down
Loading
Loading