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
+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_complete = "4.6.7"
clap_mangen = "0.3.3"
ratatui = "0.30.2"
unicode-width = "0.2"
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
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
```bash
+9
View File
@@ -249,6 +249,9 @@ pub enum Commands {
/// Tab completion for bash, zsh, and fish
#[command(subcommand)]
Completions(CompletionsCommand),
/// Print the man page to stdout, for packaging
Man,
}
#[derive(Args, Debug, Default)]
@@ -315,6 +318,12 @@ pub enum CompletionsCommand {
/// Remove the completion script again
Uninstall(CompletionsArgs),
/// Print the completion script to stdout, for packaging
Print {
#[arg(value_enum)]
shell: Shell,
},
}
#[derive(Args, Debug)]
+23 -2
View File
@@ -8,10 +8,10 @@
use super::{unwritable, xdg};
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 directories::UserDirs;
use std::io::Write;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};
/// 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 {
CompletionsCommand::Install(args) => install(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::Shares(_)
| Commands::Skills(_)
| Commands::Completions(_) => {
| Commands::Completions(_)
| Commands::Man => {
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 file_config = match &config_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(),
);
}
Commands::Completions(ref command) => {
return crate::commands::completions::run(out, command);
}
Commands::Config(ConfigCommand::Path) => {
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");
}
#[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 ------------------
#[test]