Skip to content

Commit 4676f0f

Browse files
committed
cli: Add shell completion generation command
Add a hidden 'bootc completion <shell>' subcommand that generates shell completion scripts for bash, zsh, and fish. This enables distributions to generate and package shell completions during RPM/deb build by calling 'bootc completion bash' etc. The completion scripts include descriptions for all visible subcommands and support prefix filtering for a better user experience. Signed-off-by: Shion Tanaka <[email protected]>
1 parent 0aae35a commit 4676f0f

File tree

3 files changed

+190
-0
lines changed

3 files changed

+190
-0
lines changed

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/lib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ uuid = { version = "1.8.0", features = ["v4"] }
7070
uapi-version = "0.4.0"
7171

7272
[dev-dependencies]
73+
clap_complete = "4"
7374
similar-asserts = { workspace = true }
7475
static_assertions = { workspace = true }
7576

crates/lib/src/cli.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use cap_std_ext::cap_std;
1414
use cap_std_ext::cap_std::fs::Dir;
1515
use clap::Parser;
1616
use clap::ValueEnum;
17+
use clap::CommandFactory;
1718
use composefs::dumpfile;
1819
use composefs_boot::BootOps as _;
1920
use etc_merge::{compute_diff, print_diff};
@@ -406,6 +407,15 @@ pub(crate) enum ImageCmdOpts {
406407
},
407408
}
408409

410+
/// Supported completion shells
411+
#[derive(Debug, Clone, ValueEnum, PartialEq, Eq)]
412+
#[clap(rename_all = "lowercase")]
413+
pub(crate) enum CompletionShell {
414+
Bash,
415+
Zsh,
416+
Fish,
417+
}
418+
409419
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
410420
#[serde(rename_all = "kebab-case")]
411421
pub(crate) enum ImageListType {
@@ -733,6 +743,15 @@ pub(crate) enum Opt {
733743
/// Diff current /etc configuration versus default
734744
#[clap(hide = true)]
735745
ConfigDiff,
746+
/// Generate shell completion script for supported shells.
747+
///
748+
/// Example: `bootc completion bash` prints a bash completion script to stdout.
749+
#[clap(hide = true)]
750+
Completion {
751+
/// Shell type to generate (bash, zsh, fish)
752+
#[clap(value_enum)]
753+
shell: CompletionShell,
754+
},
736755
#[clap(hide = true)]
737756
DeleteDeployment {
738757
depl_id: String,
@@ -1573,6 +1592,83 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15731592
Ok(())
15741593
}
15751594
},
1595+
Opt::Completion { shell } => {
1596+
// Build the clap Command from our derived Opt
1597+
let cmd = Opt::command();
1598+
1599+
// Collect visible top-level subcommands and their about text
1600+
fn visible_subcommands(cmd: &clap::Command) -> Vec<(String, String)> {
1601+
let mut subs: Vec<(String, String)> = cmd
1602+
.get_subcommands()
1603+
.filter(|c| {
1604+
// skip hidden subcommands and the help pseudo-command
1605+
if c.is_hide_set() {
1606+
return false;
1607+
}
1608+
if c.get_name() == "help" {
1609+
return false;
1610+
}
1611+
true
1612+
})
1613+
.map(|c| {
1614+
let name = c.get_name().to_string();
1615+
let about = c.get_about().map(|s| s.to_string()).unwrap_or_default();
1616+
(name, about)
1617+
})
1618+
.collect();
1619+
subs.sort_by_key(|(n, _)| n.clone());
1620+
subs
1621+
}
1622+
1623+
let subs = visible_subcommands(&cmd);
1624+
1625+
match shell {
1626+
CompletionShell::Zsh => {
1627+
// zsh: produce a simple _describe-based completion with descriptions
1628+
println!("#compdef bootc");
1629+
println!("# Generated by bootc");
1630+
println!("_bootc() {{");
1631+
println!(" local -a commands");
1632+
print!(" commands=(");
1633+
for (name, about) in &subs {
1634+
// escape single quotes
1635+
let about_esc = about.replace('\'', "'\\''");
1636+
print!(" '{}:{}'", name, about_esc);
1637+
}
1638+
println!(" )");
1639+
println!(" _describe 'bootc commands' commands");
1640+
println!("}}");
1641+
println!("compdef _bootc bootc");
1642+
}
1643+
CompletionShell::Fish => {
1644+
// fish: emit a complete line per command with description
1645+
println!("# Generated by bootc");
1646+
for (name, about) in &subs {
1647+
let about_esc = about.replace('"', "\\\"");
1648+
println!("complete -c bootc -n '__fish_use_subcommand' -a '{}' -d \"{}\"", name, about_esc);
1649+
}
1650+
}
1651+
CompletionShell::Bash => {
1652+
// bash: generate a simple completer that only lists top-level subcommands
1653+
println!("# Generated by bootc");
1654+
println!("_bootc() {{");
1655+
println!(" local cur prev words cword");
1656+
println!(" _init_completion || return");
1657+
print!(" local -a cmds=(");
1658+
for (name, _about) in &subs {
1659+
print!(" {}", name);
1660+
}
1661+
println!(" )");
1662+
println!(" if [ $COMP_CWORD -eq 1 ]; then");
1663+
println!(" COMPREPLY=( $(compgen -W \"${{cmds[*]}}\" -- \"$cur\") )");
1664+
println!(" return 0");
1665+
println!(" fi");
1666+
println!("}}");
1667+
println!("complete -F _bootc bootc");
1668+
}
1669+
};
1670+
Ok(())
1671+
}
15761672
Opt::Image(opts) => match opts {
15771673
ImageOpts::List {
15781674
list_type,
@@ -1841,6 +1937,41 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
18411937
mod tests {
18421938
use super::*;
18431939

1940+
#[test]
1941+
fn visible_subcommands_filter_and_sort() {
1942+
let cmd = Opt::command();
1943+
// use the same helper as completion
1944+
let subs = {
1945+
fn visible_subcommands_for_test(cmd: &clap::Command) -> Vec<String> {
1946+
let mut names: Vec<String> = cmd
1947+
.get_subcommands()
1948+
.filter(|c| {
1949+
if c.is_hide_set() {
1950+
return false;
1951+
}
1952+
if c.get_name() == "help" {
1953+
return false;
1954+
}
1955+
true
1956+
})
1957+
.map(|c| c.get_name().to_string())
1958+
.collect();
1959+
names.sort();
1960+
names
1961+
}
1962+
visible_subcommands_for_test(&cmd)
1963+
};
1964+
1965+
// basic expectations: completion subcommand is hidden and must not appear
1966+
assert!(!subs.iter().any(|s| s == "completion"));
1967+
// help must not be present
1968+
assert!(!subs.iter().any(|s| s == "help"));
1969+
// ensure sorted order
1970+
let mut sorted = subs.clone();
1971+
sorted.sort();
1972+
assert_eq!(subs, sorted);
1973+
}
1974+
18441975
#[test]
18451976
fn test_callname() {
18461977
use std::os::unix::ffi::OsStrExt;
@@ -1978,4 +2109,52 @@ mod tests {
19782109
]));
19792110
assert_eq!(args.as_slice(), ["container", "image", "pull"]);
19802111
}
2112+
2113+
#[test]
2114+
fn test_generate_completion_scripts_contain_commands() {
2115+
use clap_complete::{generate, shells::{Bash, Zsh, Fish}};
2116+
2117+
// For each supported shell, generate the completion script and
2118+
// ensure obvious subcommands appear in the output. This mirrors
2119+
// the style of completion checks used in other projects (e.g.
2120+
// podman) where the generated script is examined for expected
2121+
// tokens.
2122+
2123+
// `completion` is intentionally hidden from --help / suggestions;
2124+
// ensure other visible subcommands are present instead.
2125+
let want = ["install", "upgrade"];
2126+
2127+
// Bash
2128+
{
2129+
let mut cmd = Opt::command();
2130+
let mut buf = Vec::new();
2131+
generate(Bash, &mut cmd, "bootc", &mut buf);
2132+
let s = String::from_utf8(buf).expect("bash completion should be utf8");
2133+
for w in &want {
2134+
assert!(s.contains(w), "bash completion missing {w}");
2135+
}
2136+
}
2137+
2138+
// Zsh
2139+
{
2140+
let mut cmd = Opt::command();
2141+
let mut buf = Vec::new();
2142+
generate(Zsh, &mut cmd, "bootc", &mut buf);
2143+
let s = String::from_utf8(buf).expect("zsh completion should be utf8");
2144+
for w in &want {
2145+
assert!(s.contains(w), "zsh completion missing {w}");
2146+
}
2147+
}
2148+
2149+
// Fish
2150+
{
2151+
let mut cmd = Opt::command();
2152+
let mut buf = Vec::new();
2153+
generate(Fish, &mut cmd, "bootc", &mut buf);
2154+
let s = String::from_utf8(buf).expect("fish completion should be utf8");
2155+
for w in &want {
2156+
assert!(s.contains(w), "fish completion missing {w}");
2157+
}
2158+
}
2159+
}
19812160
}

0 commit comments

Comments
 (0)