feat(cli): print completion scripts and the man page to stdout

`completions install` writes into a home directory, which a package
build does not have. `completions print <shell>` and `man` give it the
files to put under /usr/share instead.
This commit is contained in:
Michel de Graaf
2026-09-21 12:15:43 +02:00
parent d71172542c
commit 14491768a5
8 changed files with 92 additions and 13 deletions
Generated
+24 -7
View File
@@ -476,9 +476,9 @@ dependencies = [
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.2" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@@ -486,9 +486,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_builder" name = "clap_builder"
version = "4.6.2" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [ dependencies = [
"anstream", "anstream",
"anstyle", "anstyle",
@@ -508,14 +508,14 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.1" version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.2",
] ]
[[package]] [[package]]
@@ -524,6 +524,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" 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]] [[package]]
name = "color-eyre" name = "color-eyre"
version = "0.6.5" version = "0.6.5"
@@ -2251,6 +2261,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "roff"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.28" version = "0.1.28"
@@ -2550,6 +2566,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"clap_complete", "clap_complete",
"clap_mangen",
"color-eyre", "color-eyre",
"directories", "directories",
"dotenv", "dotenv",
+1
View File
@@ -30,6 +30,7 @@ soulseek-rs-lib = { version = "19.0.0", path = "../soulseek-rs-lib" }
clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] } clap = { version = "4.6.2", features = ["derive", "color", "wrap_help", "env"] }
clap_complete = "4.6.7" clap_complete = "4.6.7"
clap_mangen = "0.3.3"
ratatui = "0.30.2" ratatui = "0.30.2"
unicode-width = "0.2" unicode-width = "0.2"
color-eyre = "0.6.5" color-eyre = "0.6.5"
+3
View File
@@ -236,6 +236,9 @@ this skill file into the local agent directories, or remove it: `agent`,
completion script where bash, zsh, or fish looks for it, or take it back completion script where bash, zsh, or fish looks for it, or take it back
again: `shell`, `path`, `action`. For a human at a prompt, not for you. again: `shell`, `path`, `action`. For a human at a prompt, not for you.
**`completions print <shell>`** and **`man`** — the completion script or the
man page on stdout, raw rather than records. For whoever builds a package.
## Idioms ## Idioms
```bash ```bash
+9
View File
@@ -249,6 +249,9 @@ pub enum Commands {
/// Tab completion for bash, zsh, and fish /// Tab completion for bash, zsh, and fish
#[command(subcommand)] #[command(subcommand)]
Completions(CompletionsCommand), Completions(CompletionsCommand),
/// Print the man page to stdout, for packaging
Man,
} }
#[derive(Args, Debug, Default)] #[derive(Args, Debug, Default)]
@@ -315,6 +318,12 @@ pub enum CompletionsCommand {
/// Remove the completion script again /// Remove the completion script again
Uninstall(CompletionsArgs), Uninstall(CompletionsArgs),
/// Print the completion script to stdout, for packaging
Print {
#[arg(value_enum)]
shell: Shell,
},
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
+23 -2
View File
@@ -8,10 +8,10 @@
use super::{unwritable, xdg}; use super::{unwritable, xdg};
use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell}; use crate::cli::{Cli, CompletionsArgs, CompletionsCommand, Shell};
use crate::output::{CliError, CliResult, CompletionRecord, Out}; use crate::output::{CliError, CliResult, CompletionRecord, Exit, Out};
use clap::CommandFactory; use clap::CommandFactory;
use directories::UserDirs; use directories::UserDirs;
use std::io::Write; use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// The command being completed, and the file name the scripts are written as. /// The command being completed, and the file name the scripts are written as.
@@ -65,6 +65,27 @@ pub fn run(out: &Out, command: &CompletionsCommand) -> CliResult {
match command { match command {
CompletionsCommand::Install(args) => install(out, &targets(args)?), CompletionsCommand::Install(args) => install(out, &targets(args)?),
CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?), CompletionsCommand::Uninstall(args) => uninstall(out, &targets(args)?),
CompletionsCommand::Print { shell } => print(&script(*shell)),
}
}
pub fn man() -> CliResult {
let mut page = Vec::new();
clap_mangen::Man::new(Cli::command().about("Soulseek client in Rust"))
.render(&mut page)
.expect("a Vec accepts every write");
print(&page)
}
fn print(bytes: &[u8]) -> CliResult {
match std::io::stdout().write_all(bytes) {
Err(error) if error.kind() != ErrorKind::BrokenPipe => {
Err(CliError::new(
Exit::Failure,
format!("cannot write stdout: {error}"),
))
}
_ => Ok(()),
} }
} }
+2 -1
View File
@@ -330,7 +330,8 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult {
| Commands::Config(_) | Commands::Config(_)
| Commands::Shares(_) | Commands::Shares(_)
| Commands::Skills(_) | Commands::Skills(_)
| Commands::Completions(_) => { | Commands::Completions(_)
| Commands::Man => {
Err(CliError::usage("handled without a session; see main::run")) Err(CliError::usage("handled without a session; see main::run"))
} }
} }
+8 -3
View File
@@ -35,6 +35,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
} }
} }
match &cli.command {
Some(Commands::Completions(command)) => {
return crate::commands::completions::run(out, command);
}
Some(Commands::Man) => return crate::commands::completions::man(),
_ => {}
}
let config_path = config_path(&cli); let config_path = config_path(&cli);
let file_config = match &config_path { let file_config = match &config_path {
Some(path) => crate::persist::config::FileConfig::load(path) Some(path) => crate::persist::config::FileConfig::load(path)
@@ -84,9 +92,6 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult {
resolved.username.as_deref(), resolved.username.as_deref(),
); );
} }
Commands::Completions(ref command) => {
return crate::commands::completions::run(out, command);
}
Commands::Config(ConfigCommand::Path) => { Commands::Config(ConfigCommand::Path) => {
return crate::commands::settings::config_path(out, &store); return crate::commands::settings::config_path(out, &store);
} }
+22
View File
@@ -412,6 +412,28 @@ fn installing_completions_is_repeatable_and_reversible() {
assert_eq!(action(&again), "absent"); assert_eq!(action(&again), "absent");
} }
#[test]
fn a_package_build_reads_completions_and_the_man_page_from_stdout() {
let dir = Scratch::new("package-build");
let config = dir.path().join("config.toml");
std::fs::write(&config, "not = [toml").expect("a config file");
let broken = config.display().to_string();
for (shell, marker) in [
("bash", "complete -F"),
("zsh", "#compdef soulseek-rs"),
("fish", "complete -c soulseek-rs"),
] {
let output = run(&["--config", &broken, "completions", "print", shell]);
assert_eq!(code(&output), EXIT_OK, "stderr: {}", stderr(&output));
assert!(stdout(&output).contains(marker), "{shell} script");
}
let man = run(&["--config", &broken, "man"]);
assert_eq!(code(&man), EXIT_OK, "stderr: {}", stderr(&man));
assert!(stdout(&man).contains(".TH soulseek-rs 1"));
}
// --- wishlist, the half that only touches the config file ------------------ // --- wishlist, the half that only touches the config file ------------------
#[test] #[test]