completions: add clap_complete shell completion generator (#106)

Feature-gated binary (--features completions) that generates bash/zsh/fish/
elvish/powershell completions from each tool's uu_app() definition.

Usage:
  shadow-rs-completions passwd --shell bash
  shadow-rs-completions --all --shell zsh --dir completions/

Also updates generate-completions.sh to use the new binary and adds
windows-sys skip to deny.toml for transitive clap dep.

Fixes #106
This commit is contained in:
Pierre Warnier
2026-04-03 12:18:18 +02:00
parent e1cf43502a
commit 829b37f7a8
4 changed files with 224 additions and 31 deletions
+13
View File
@@ -44,6 +44,7 @@ shadow-core = { path = "src/shadow-core" }
# CLI
clap = { version = "4", features = ["derive", "wrap_help"] }
clap_complete = "4"
# uutils integration
uucore = "0.7"
@@ -57,12 +58,16 @@ libc = "0.2"
# Security
zeroize = "1"
landlock = "0.4"
# Testing
proptest = "1"
tempfile = "3"
[dependencies]
clap = { workspace = true }
clap_complete = { workspace = true, optional = true }
# Tool crates (optional, enabled by features)
passwd = { optional = true, version = "0.0.1", package = "uu_passwd", path = "src/uu/passwd" }
pwck = { optional = true, version = "0.0.1", package = "uu_pwck", path = "src/uu/pwck" }
@@ -105,10 +110,18 @@ feat_phase2 = ["feat_useradd", "feat_userdel", "feat_usermod", "feat_chpasswd",
feat_phase3 = ["feat_groupadd", "feat_groupdel", "feat_groupmod", "feat_grpck", "feat_chfn", "feat_chsh", "feat_newgrp"]
feat_common = ["feat_phase1", "feat_phase2"]
# Shell completions generator
completions = ["dep:clap_complete"]
[[bin]]
name = "shadow-rs"
path = "src/bin/shadow-rs.rs"
[[bin]]
name = "shadow-rs-completions"
path = "src/bin/completions.rs"
required-features = ["completions"]
[[test]]
name = "test_chage"
path = "tests/by-util/test_chage.rs"
+6 -3
View File
@@ -26,9 +26,12 @@ multiple-versions = "deny"
wildcards = "allow"
highlight = "all"
# Known duplicate dependencies (uucore pulls in older versions of some crates).
# Update this list when bumping uucore.
skip = []
# Known duplicate dependencies from transitive deps.
# Update this list when bumping clap/uucore.
skip = [
# clap -> terminal_size (0.60) vs clap -> anstyle-query (0.61)
{ crate = "windows-sys@0.60", reason = "transitive clap dep, Linux-only project" },
]
[sources]
unknown-registry = "warn"
+187
View File
@@ -0,0 +1,187 @@
// This file is part of the shadow-rs package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
// spell-checker:ignore completions
//! Shell completion generator for all shadow-rs tools.
//!
//! Generates completions by calling each tool's `uu_app()` function,
//! so completions always match the actual CLI definition.
//!
//! Usage:
//! shadow-rs-completions passwd --shell bash
//! shadow-rs-completions --all --shell zsh --dir completions/
//!
//! Supported shells: bash, zsh, fish, elvish, powershell
use clap::{Arg, ArgAction, Command};
use clap_complete::generate;
use clap_complete::Shell;
use std::io;
fn get_tool_app(name: &str) -> Option<Command> {
match name {
#[cfg(feature = "chage")]
"chage" => Some(chage::uu_app()),
#[cfg(feature = "chfn")]
"chfn" => Some(chfn::uu_app()),
#[cfg(feature = "chpasswd")]
"chpasswd" => Some(chpasswd::uu_app()),
#[cfg(feature = "chsh")]
"chsh" => Some(chsh::uu_app()),
#[cfg(feature = "groupadd")]
"groupadd" => Some(groupadd::uu_app()),
#[cfg(feature = "groupdel")]
"groupdel" => Some(groupdel::uu_app()),
#[cfg(feature = "groupmod")]
"groupmod" => Some(groupmod::uu_app()),
#[cfg(feature = "grpck")]
"grpck" => Some(grpck::uu_app()),
#[cfg(feature = "newgrp")]
"newgrp" => Some(newgrp::uu_app()),
#[cfg(feature = "passwd")]
"passwd" => Some(passwd::uu_app()),
#[cfg(feature = "pwck")]
"pwck" => Some(pwck::uu_app()),
#[cfg(feature = "useradd")]
"useradd" => Some(useradd::uu_app()),
#[cfg(feature = "userdel")]
"userdel" => Some(userdel::uu_app()),
#[cfg(feature = "usermod")]
"usermod" => Some(usermod::uu_app()),
_ => None,
}
}
#[allow(clippy::vec_init_then_push)] // cfg attributes on each push prevent using vec![]
fn all_tool_names() -> Vec<&'static str> {
let mut names = Vec::new();
#[cfg(feature = "chage")]
names.push("chage");
#[cfg(feature = "chfn")]
names.push("chfn");
#[cfg(feature = "chpasswd")]
names.push("chpasswd");
#[cfg(feature = "chsh")]
names.push("chsh");
#[cfg(feature = "groupadd")]
names.push("groupadd");
#[cfg(feature = "groupdel")]
names.push("groupdel");
#[cfg(feature = "groupmod")]
names.push("groupmod");
#[cfg(feature = "grpck")]
names.push("grpck");
#[cfg(feature = "newgrp")]
names.push("newgrp");
#[cfg(feature = "passwd")]
names.push("passwd");
#[cfg(feature = "pwck")]
names.push("pwck");
#[cfg(feature = "useradd")]
names.push("useradd");
#[cfg(feature = "userdel")]
names.push("userdel");
#[cfg(feature = "usermod")]
names.push("usermod");
names
}
fn cli() -> Command {
Command::new("shadow-rs-completions")
.about("Generate shell completions for shadow-rs tools")
.arg(
Arg::new("tool")
.help("Tool name (or use --all)")
.required_unless_present("all"),
)
.arg(
Arg::new("shell")
.long("shell")
.short('s')
.help("Target shell: bash, zsh, fish, elvish, powershell")
.required(true),
)
.arg(
Arg::new("all")
.long("all")
.help("Generate completions for all tools")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dir")
.long("dir")
.help("Output directory (one file per tool; default: stdout)")
.requires("all"),
)
}
fn generate_for_tool(name: &str, shell: Shell, out: &mut dyn io::Write) {
if let Some(mut cmd) = get_tool_app(name) {
generate(shell, &mut cmd, name, out);
} else {
eprintln!("unknown tool: {name}");
eprintln!("available: {}", all_tool_names().join(", "));
std::process::exit(1);
}
}
fn shell_extension(shell: Shell) -> &'static str {
match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Elvish => "elv",
Shell::PowerShell => "ps1",
_ => "txt",
}
}
fn main() {
let matches = cli().get_matches();
let shell: Shell = matches
.get_one::<String>("shell")
.expect("shell is required")
.parse()
.unwrap_or_else(|e| {
eprintln!("invalid shell: {e}");
eprintln!("supported: bash, zsh, fish, elvish, powershell");
std::process::exit(1);
});
if matches.get_flag("all") {
let tools = all_tool_names();
if let Some(dir) = matches.get_one::<String>("dir") {
std::fs::create_dir_all(dir).unwrap_or_else(|e| {
eprintln!("cannot create directory '{dir}': {e}");
std::process::exit(1);
});
let ext = shell_extension(shell);
for name in &tools {
let path = format!("{dir}/{name}.{ext}");
let mut file = std::fs::File::create(&path).unwrap_or_else(|e| {
eprintln!("cannot create '{path}': {e}");
std::process::exit(1);
});
if let Some(mut cmd) = get_tool_app(name) {
generate(shell, &mut cmd, *name, &mut file);
}
}
eprintln!("generated {} completions in {dir}/", tools.len());
} else {
let stdout = io::stdout();
let mut out = stdout.lock();
for name in &tools {
if let Some(mut cmd) = get_tool_app(name) {
generate(shell, &mut cmd, *name, &mut out);
}
}
}
} else {
let tool = matches.get_one::<String>("tool").expect("tool is required");
let stdout = io::stdout();
let mut out = stdout.lock();
generate_for_tool(tool, shell, &mut out);
}
}
+18 -28
View File
@@ -2,37 +2,27 @@
# Generate shell completions for all shadow-rs tools.
#
# Usage:
# ./util/generate-completions.sh bash
# ./util/generate-completions.sh zsh
# ./util/generate-completions.sh fish
# ./util/generate-completions.sh bash [output-dir]
# ./util/generate-completions.sh zsh [output-dir]
# ./util/generate-completions.sh fish [output-dir]
#
# For proper clap-based completions, build with clap_complete.
# This is a minimal placeholder until clap_complete is integrated.
# Requires: cargo build --features completions
set -euo pipefail
SHELL_TYPE="${1:-bash}"
TOOLS="passwd pwck useradd userdel usermod chpasswd chage groupadd groupdel groupmod grpck chfn chsh newgrp"
OUTPUT_DIR="${2:-}"
case "$SHELL_TYPE" in
bash)
for tool in $TOOLS; do
echo "complete -W '--help' $tool"
done
;;
zsh)
echo "#compdef shadow-rs"
for tool in $TOOLS; do
echo "complete -c $tool -s h -l help -d 'Show help'"
done
;;
fish)
for tool in $TOOLS; do
echo "complete -c $tool -s h -l help -d 'Show help'"
done
;;
*)
echo "Usage: $0 {bash|zsh|fish}" >&2
exit 1
;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# Build the completions binary
cargo build --manifest-path "$PROJECT_DIR/Cargo.toml" --features completions --bin shadow-rs-completions
BINARY="$PROJECT_DIR/target/debug/shadow-rs-completions"
if [ -n "$OUTPUT_DIR" ]; then
"$BINARY" --all --shell "$SHELL_TYPE" --dir "$OUTPUT_DIR"
else
"$BINARY" --all --shell "$SHELL_TYPE"
fi