use anyhow::{Result, bail};
use clap::builder::styling::{AnsiColor, Effects, Styles};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use std::str::FromStr;
pub use crate::display::{DisplayOptions, InputDisplay, OutputDisplay, PathFormat};
const STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Cyan.on_default())
.error(AnsiColor::Red.on_default().effects(Effects::BOLD))
.valid(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.invalid(AnsiColor::Yellow.on_default().effects(Effects::BOLD));
#[derive(Parser)]
#[command(name = "rsconstruct")]
#[command(styles = STYLES)]
#[command(version = concat!(env!("CARGO_PKG_VERSION")))]
#[command(about = "Rust Build Tool - Incremental build system with templates", long_about = None)]
pub struct Cli {
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(short = 'O', long, global = true, value_enum, default_value = "none")]
pub output_display: OutputDisplay,
#[arg(short = 'I', long, global = true, value_enum, default_value = "source")]
pub input_display: InputDisplay,
#[arg(short = 'P', long, global = true, value_enum, default_value = "path")]
pub path_format: PathFormat,
#[arg(long, global = true)]
pub show_child_processes: bool,
#[arg(long, global = true)]
pub show_output: bool,
#[arg(long, global = true)]
pub json: bool,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(long, global = true)]
pub all: bool,
#[arg(long, global = true)]
pub fields: bool,
#[arg(long, global = true)]
pub phases: bool,
#[arg(long, global = true)]
pub graph_stats: bool,
#[arg(long, global = true)]
pub no_mtime_cache: bool,
#[arg(long, global = true, value_enum, default_value = "auto")]
pub color: ColorMode,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum ColorMode {
Auto,
Always,
Never,
}
impl Cli {
pub const fn display_options(&self) -> DisplayOptions {
DisplayOptions {
output: self.output_display,
input: self.input_display,
path_format: self.path_format,
}
}
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum GraphFormat {
Dot,
Mermaid,
Json,
Text,
#[default]
Svg,
}
#[derive(Debug, Clone, Copy, Default, ValueEnum)]
pub enum GraphViewer {
Mermaid,
#[default]
Svg,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub enum BuildPhase {
Discover,
AddDependencies,
Resolve,
Classify,
#[default]
Build,
}
#[derive(Subcommand)]
pub enum Commands {
Analyzer {
#[command(subcommand)]
action: AnalyzerAction,
},
Build {
#[arg(short, long)]
force: bool,
#[arg(short = 'n', long, conflicts_with = "stop_after")]
dry_run: bool,
#[arg(long)]
verify_tool_versions: bool,
#[arg(long, value_enum, default_value = "build")]
stop_after: BuildPhase,
#[command(flatten)]
shared: SharedBuildArgs,
},
Cache {
#[command(subcommand)]
action: CacheAction,
},
Clean {
#[command(subcommand)]
action: CleanAction,
},
Complete {
#[arg(value_enum)]
shells: Vec<Shell>,
},
Doctor,
Error,
Function {
#[command(subcommand)]
action: FunctionAction,
},
Graph {
#[command(subcommand)]
action: GraphAction,
},
Hook,
Info {
#[command(subcommand)]
action: InfoAction,
},
Init,
Page {
#[command(subcommand)]
action: PageAction,
},
Processor {
#[command(subcommand)]
action: ProcessorAction,
},
Product {
#[command(subcommand)]
action: ProductAction,
},
Sloc {
#[arg(long)]
cocomo: bool,
#[arg(long, default_value = "56286")]
salary: u64,
},
Smart {
#[command(subcommand)]
action: SmartAction,
},
Status {
#[arg(long)]
breakdown: bool,
},
SymlinkInstall,
Tag {
#[command(subcommand)]
action: TagAction,
},
Term {
#[command(subcommand)]
action: TermAction,
},
#[command(name = "toml")]
Toml {
#[command(subcommand)]
action: TomlAction,
},
Tool {
#[command(subcommand)]
action: ToolAction,
},
Version,
Watch {
#[command(flatten)]
shared: SharedBuildArgs,
},
#[command(name = "webcache")]
WebCache {
#[command(subcommand)]
action: WebCacheAction,
},
}
#[derive(Subcommand)]
pub enum SmartAction {
Auto,
Disable {
name: String,
},
DisableAll,
Enable {
name: String,
},
EnableAll,
EnableDetected,
EnableIfAvailable,
Minimal,
Only {
#[arg(required = true)]
names: Vec<String>,
},
RemoveNoFileProcessors,
Reset,
}
#[derive(Subcommand)]
pub enum InfoAction {
Source,
}
#[derive(Subcommand)]
pub enum GraphAction {
LookupFwd {
#[arg(required = true)]
files: Vec<String>,
},
LookupRev {
#[arg(required = true)]
files: Vec<String>,
},
Show {
#[arg(short, long, value_enum, default_value = "svg")]
format: GraphFormat,
},
Stats,
Unreferenced {
#[arg(short, long, value_delimiter = ',', required = true)]
extensions: Vec<String>,
#[arg(long)]
rm: bool,
},
View {
#[arg(long, value_enum, default_value = "svg")]
viewer: GraphViewer,
},
}
#[derive(Subcommand)]
pub enum CleanAction {
All,
Git,
Outputs {
#[arg(short, long, value_delimiter = ',')]
processor: Vec<String>,
#[arg(long)]
no_empty_dirs: bool,
},
Products {
#[arg(short, long, value_delimiter = ',', required = true)]
processor: Vec<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_empty_dirs: bool,
},
Unknown {
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_gitignore: bool,
},
}
#[derive(Subcommand)]
pub enum CacheAction {
Clear,
List,
RemoveStale,
Size,
Stale,
Stats,
Trim,
}
#[derive(Subcommand)]
pub enum WebCacheAction {
Clear,
List,
Stats,
}
#[derive(Subcommand)]
pub enum ProductAction {
Show {
path: String,
},
}
#[derive(Subcommand)]
pub enum ProcessorAction {
Add {
#[arg(value_parser = crate::registries::processor_name_parser())]
pname: String,
#[arg(long)]
dry_run: bool,
},
Allowlist,
Config {
iname: Option<String>,
#[arg(short, long)]
diff: bool,
},
Defconfig {
#[arg(value_parser = crate::registries::processor_name_parser())]
pname: String,
},
Delete {
iname: String,
},
Disable {
iname: String,
},
Enable {
iname: String,
},
Files {
iname: Option<String>,
#[arg(long)]
headers: bool,
},
Graph {
#[arg(short, long, value_enum, default_value = "text")]
format: GraphFormat,
},
Info {
#[arg(value_parser = crate::registries::processor_name_parser())]
pname: Option<String>,
},
List {
#[arg(long = "type", value_name = "TYPE")]
processor_type: Option<String>,
},
Names,
Recommend,
Search {
query: String,
},
Types,
Used,
}
#[derive(Subcommand)]
pub enum PageAction {
Dir,
}
#[derive(Subcommand)]
pub enum TomlAction {
Check,
Files,
}
#[derive(Subcommand)]
pub enum FunctionAction {
List,
}
#[derive(Subcommand)]
pub enum ToolAction {
Check,
Graph {
#[arg(short, long, value_enum, default_value = "dot")]
format: GraphFormat,
#[arg(long)]
view: bool,
},
Install {
name: Option<String>,
#[arg(short, long, conflicts_with = "name")]
all: bool,
#[arg(short, long)]
interactive: bool,
#[arg(long)]
no_eatmydata: bool,
},
InstallDeps {
#[arg(short, long)]
interactive: bool,
#[arg(long)]
no_eatmydata: bool,
},
List {
#[arg(short = 'M', long)]
methods: bool,
},
ListConfigured {
#[arg(short, long)]
all: bool,
#[arg(short = 'M', long)]
methods: bool,
},
Lock,
Stats,
}
#[derive(Subcommand)]
pub enum AnalyzerAction {
Add {
#[arg(value_parser = crate::registries::analyzer_name_parser())]
pname: String,
#[arg(long)]
dry_run: bool,
},
Build,
Clean {
#[arg(long)]
analyzer: Option<String>,
},
Config {
iname: Option<String>,
},
Defconfig {
#[arg(value_parser = crate::registries::analyzer_name_parser())]
pname: Option<String>,
},
Delete {
iname: String,
},
Disable {
iname: String,
},
Enable {
iname: String,
},
List,
Show {
#[command(subcommand)]
filter: AnalyzerShowFilter,
},
Stats,
Used,
}
#[derive(Subcommand)]
pub enum AnalyzerShowFilter {
All,
Analyzers {
#[arg(required = true)]
analyzers: Vec<String>,
},
Files {
#[arg(required = true)]
files: Vec<String>,
#[arg(long)]
hash_pieces: bool,
},
}
#[derive(Subcommand)]
pub enum TermAction {
Fix {
#[arg(long, default_value_t = false)]
remove_non_terms: bool,
},
Merge {
path: String,
},
Stats,
}
#[derive(Subcommand)]
pub enum TagAction {
Check,
Collect,
Count,
Coverage,
Files {
#[arg(required = true)]
tags: Vec<String>,
#[arg(long, short)]
or: bool,
},
ForFile {
path: String,
},
Frontmatter {
path: String,
},
Grep {
text: String,
#[arg(short, long)]
ignore_case: bool,
},
List,
Matrix,
Merge {
path: String,
},
Orphans,
Stats,
Suggest {
path: String,
},
Tree,
Unused {
#[arg(long)]
strict: bool,
},
Validate,
}
#[derive(Args, Clone)]
pub struct SharedBuildArgs {
#[arg(short, long)]
pub jobs: Option<usize>,
#[arg(long)]
pub timings: bool,
#[arg(short = 'k', long)]
pub keep_going: bool,
#[arg(long)]
pub no_summary: bool,
#[arg(long, allow_negative_numbers = true)]
pub batch_size: Option<i32>,
#[arg(short, long, value_delimiter = ',')]
pub processor: Option<Vec<String>>,
#[arg(short = 'x', long = "exclude-processor", value_delimiter = ',')]
pub exclude_processor: Option<Vec<String>>,
#[arg(long)]
pub auto_add_words: bool,
#[arg(long)]
pub explain: bool,
#[arg(long, value_name = "N", default_value = "0")]
pub retry: usize,
#[arg(long)]
pub no_mtime: bool,
#[arg(short, long = "target")]
pub targets: Option<Vec<String>>,
#[arg(short, long = "dir")]
pub dirs: Option<Vec<String>>,
#[arg(long, value_name = "FILE")]
pub trace: Option<String>,
#[arg(long)]
pub show_all_config_changes: bool,
#[arg(long, value_name = "INAME.FIELD=VALUE")]
pub iset: Vec<String>,
#[arg(long, value_name = "PNAME.FIELD=VALUE")]
pub pset: Vec<String>,
}
impl SharedBuildArgs {
pub fn to_build_options(&self, cli: &Cli, force: bool, stop_after: BuildPhase) -> BuildOptions {
let targets = match (&self.targets, &self.dirs) {
(None, None) => None,
(Some(t), None) => Some(t.clone()),
(None, Some(d)) => Some(d.iter().map(|dir| format!("{dir}/**")).collect()),
(Some(t), Some(d)) => {
let mut merged = t.clone();
merged.extend(d.iter().map(|dir| format!("{dir}/**")));
Some(merged)
}
};
BuildOptions {
force,
verbose: cli.verbose,
display_opts: cli.display_options(),
jobs: self.jobs,
timings: self.timings,
keep_going: self.keep_going,
summary: !self.no_summary,
batch_size: self
.batch_size
.map(|n| if n < 0 { None } else { Some(n as usize) }),
stop_after,
processor_filter: self.processor.clone(),
exclude_filter: self.exclude_processor.clone(),
auto_add_words: self.auto_add_words,
explain: self.explain,
no_mtime: self.no_mtime,
retry: self.retry,
targets,
trace: self.trace.clone(),
show_all_config_changes: self.show_all_config_changes,
iset: self.iset.clone(),
pset: self.pset.clone(),
}
}
}
#[derive(Clone)]
pub struct BuildOptions {
pub force: bool,
pub verbose: bool,
pub display_opts: DisplayOptions,
pub jobs: Option<usize>,
pub timings: bool,
pub keep_going: bool,
pub summary: bool,
#[allow(clippy::option_option)]
pub batch_size: Option<Option<usize>>,
pub stop_after: BuildPhase,
pub processor_filter: Option<Vec<String>>,
pub exclude_filter: Option<Vec<String>>,
pub auto_add_words: bool,
pub explain: bool,
pub no_mtime: bool,
pub retry: usize,
pub targets: Option<Vec<String>>,
pub trace: Option<String>,
pub show_all_config_changes: bool,
pub iset: Vec<String>,
pub pset: Vec<String>,
}
pub fn parse_shell(name: &str) -> Option<Shell> {
<Shell as FromStr>::from_str(name).ok()
}
fn hide_all_flags(cmd: clap::Command) -> clap::Command {
let cmd = cmd.mut_args(|arg| {
if arg.get_long().is_some() || arg.get_short().is_some() {
arg.hide_short_help(true)
} else {
arg
}
});
cmd.mut_subcommands(hide_all_flags)
}
pub fn parse_cli() -> Cli {
let cmd = hide_all_flags(Cli::command());
match cmd.try_get_matches() {
Ok(matches) => Cli::from_arg_matches(&matches).expect("failed to parse CLI arguments"),
Err(err) if is_missing_subcommand(&err) => {
let args: Vec<String> = std::env::args().collect();
anstream::eprint!("{}", operations_help_for(&args).ansi());
std::process::exit(2);
}
Err(err) => err.exit(),
}
}
fn is_missing_subcommand(err: &clap::Error) -> bool {
matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| clap::error::ErrorKind::MissingSubcommand
)
}
const SUBCOMMANDS_HELP_TEMPLATE: &str =
"{before-help}{about-with-newline}\n{usage-heading} {usage}{after-help}";
fn subcommands_of(cmd: &clap::Command) -> Vec<(String, String)> {
cmd.get_subcommands()
.filter(|sub| !sub.is_hide_set() && sub.get_name() != "help")
.map(|sub| {
let about = sub.get_about().map(ToString::to_string).unwrap_or_default();
(sub.get_name().to_string(), about)
})
.collect()
}
fn subcommands_list(subcommands: &[(String, String)]) -> String {
let h = STYLES.get_header();
let l = STYLES.get_literal();
let width = subcommands
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or(0);
let indent = " ".repeat(2 + width + 2);
let mut out = format!("{h}Commands:{h:#}\n");
for (name, help) in subcommands {
let pad = " ".repeat(width - name.len());
let help = help.trim().replace('\n', &format!("\n{indent}"));
out.push_str(format!(" {l}{name}{l:#}{pad} {help}").trim_end());
out.push('\n');
}
out
}
pub fn operations_help_for<I, S>(args: I) -> clap::builder::StyledStr
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut cmd = Cli::command();
cmd.build();
let mut path: Vec<String> = Vec::new();
{
let mut cur = &cmd;
for arg in args.into_iter().skip(1) {
if let Some(sub) = cur.find_subcommand(arg.as_ref()) {
path.push(sub.get_name().to_string());
cur = sub;
}
}
}
if path.is_empty() {
return cmd.render_long_help();
}
let full_name = format!("{} {}", cmd.get_name(), path.join(" "));
let mut cur = &mut cmd;
for name in &path {
cur = cur
.find_subcommand_mut(name)
.expect("subcommand path was taken from this very Command");
}
let subcommands = subcommands_list(&subcommands_of(cur));
cur.clone()
.help_template(SUBCOMMANDS_HELP_TEMPLATE)
.after_help(format!(
"{subcommands}\nRun `{full_name} --help` for the options."
))
.render_long_help()
}
pub fn print_completions(shell: Shell) -> Result<()> {
let script = generate_completion_script(shell)?;
print!("{script}");
Ok(())
}
fn generate_completion_script(shell: Shell) -> Result<String> {
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(shell, &mut cmd, "rsconstruct", &mut buf);
let script = String::from_utf8(buf).expect("completion script should be UTF-8");
match shell {
Shell::Bash => inject_bash_processor_completions(&script),
_ => Ok(script),
}
}
fn inject_bash_processor_completions(script: &str) -> Result<String> {
let helper = r#"
_rsconstruct_inames() {
local toml="rsconstruct.toml"
[[ -f "$toml" ]] || return
grep -E '^\[processor\.' "$toml" | sed -E 's/^\[([^]]+)\].*/\1/'
}
_rsconstruct_analyzer_inames() {
local toml="rsconstruct.toml"
[[ -f "$toml" ]] || return
# Match [analyzer.NAME] -> NAME; [analyzer.NAME.SUB] -> NAME.SUB
grep -E '^\[analyzer\.' "$toml" | sed -E 's/^\[analyzer\.([^]]+)\].*/\1/'
}
"#;
let iname_targets = [
("rsconstruct__subcmd__processor__subcmd__files)", 3),
("rsconstruct__subcmd__processor__subcmd__config)", 3),
("rsconstruct__subcmd__processor__subcmd__delete)", 3),
("rsconstruct__subcmd__processor__subcmd__disable)", 3),
("rsconstruct__subcmd__processor__subcmd__enable)", 3),
];
let mut missed: Vec<String> = Vec::new();
let mut result = script.to_string();
for (target, _) in &iname_targets {
if let Some(section_start) = result.find(target) {
let after_start = section_start + target.len();
let section_len = result[after_start..]
.find("\n rsconstruct__")
.unwrap_or(result.len() - after_start);
let section_end = after_start + section_len;
let section_slice = &result[section_start..section_end];
let needle = "COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )";
if let Some(rel_pos) = section_slice.find(needle) {
let abs_pos = section_start + rel_pos;
let replacement = "if [[ ${cur} != -* ]] ; then\n COMPREPLY=( $(compgen -W \"$(_rsconstruct_inames)\" -- \"${cur}\") )\n else\n COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n fi";
result.replace_range(abs_pos..abs_pos + needle.len(), replacement);
} else {
missed.push(format!("COMPREPLY line in section {target}"));
}
} else {
missed.push(format!("section {target}"));
}
}
let analyzer_iname_targets = [
"rsconstruct__subcmd__analyzer__subcmd__delete)",
"rsconstruct__subcmd__analyzer__subcmd__disable)",
"rsconstruct__subcmd__analyzer__subcmd__enable)",
];
for target in &analyzer_iname_targets {
if let Some(section_start) = result.find(target) {
let after_start = section_start + target.len();
let section_len = result[after_start..]
.find("\n rsconstruct__")
.unwrap_or(result.len() - after_start);
let section_end = after_start + section_len;
let section_slice = &result[section_start..section_end];
let needle = "COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )";
if let Some(rel_pos) = section_slice.find(needle) {
let abs_pos = section_start + rel_pos;
let replacement = "if [[ ${cur} != -* ]] ; then\n COMPREPLY=( $(compgen -W \"$(_rsconstruct_analyzer_inames)\" -- \"${cur}\") )\n else\n COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n fi";
result.replace_range(abs_pos..abs_pos + needle.len(), replacement);
} else {
missed.push(format!("COMPREPLY line in section {target}"));
}
} else {
missed.push(format!("section {target}"));
}
}
let old_processor =
" --processor)\n COMPREPLY=($(compgen -f \"${cur}\"))";
let new_processor = " --processor)\n COMPREPLY=($(compgen -W \"$(_rsconstruct_inames)\" -- \"${cur}\"))".to_string();
let old_p = " -p)\n COMPREPLY=($(compgen -f \"${cur}\"))";
let new_p = " -p)\n COMPREPLY=($(compgen -W \"$(_rsconstruct_inames)\" -- \"${cur}\"))".to_string();
let old_exclude = " --exclude-processor)\n COMPREPLY=($(compgen -f \"${cur}\"))";
let new_exclude = " --exclude-processor)\n COMPREPLY=($(compgen -W \"$(_rsconstruct_inames)\" -- \"${cur}\"))".to_string();
let old_x = " -x)\n COMPREPLY=($(compgen -f \"${cur}\"))";
let new_x = " -x)\n COMPREPLY=($(compgen -W \"$(_rsconstruct_inames)\" -- \"${cur}\"))".to_string();
for (label, old) in [
("--processor", old_processor),
("-p", old_p),
("--exclude-processor", old_exclude),
("-x", old_x),
] {
if !result.contains(old) {
missed.push(format!("{label} flag completion"));
}
}
let result = result
.replace(old_processor, &new_processor)
.replace(old_p, &new_p)
.replace(old_exclude, &new_exclude)
.replace(old_x, &new_x);
if !missed.is_empty() {
bail!(
"bash completion iname injection failed for: {} — clap-complete output format changed; update the labels/needles in inject_bash_processor_completions",
missed.join(", ")
);
}
Ok(format!("{helper}{result}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bash_iname_injection_matches_clap_complete_output() {
let script = generate_completion_script(Shell::Bash)
.expect("bash iname injection failed — clap-complete output format changed");
for helper_call in [
"compgen -W \"$(_rsconstruct_inames)\"",
"compgen -W \"$(_rsconstruct_analyzer_inames)\"",
] {
assert!(
script.contains(helper_call),
"injected helper call missing from bash completion script: {helper_call}"
);
}
}
fn command_groups(cmd: &clap::Command, path: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
for sub in cmd.get_subcommands() {
if sub.get_name() == "help" || !sub.has_subcommands() {
continue;
}
path.push(sub.get_name().to_string());
out.push(path.clone());
command_groups(sub, path, out);
path.pop();
}
}
#[test]
fn every_group_lists_only_its_subcommands() {
let mut cmd = Cli::command();
cmd.build();
let mut groups = Vec::new();
command_groups(&cmd, &mut Vec::new(), &mut groups);
assert!(!groups.is_empty(), "no command groups found");
for group in &groups {
let mut argv = vec!["rsconstruct".to_string()];
argv.extend(group.iter().cloned());
let text = operations_help_for(&argv).to_string();
let mut sub_cmd = &cmd;
for name in group {
sub_cmd = sub_cmd.find_subcommand(name).unwrap();
}
for (name, _) in subcommands_of(sub_cmd) {
assert!(
text.lines().any(|l| l.trim_start().starts_with(&name)),
"`{}` does not list `{name}`:\n{text}",
group.join(" ")
);
}
assert!(
!text.contains("Options:"),
"`{}` lists options:\n{text}",
group.join(" ")
);
assert!(
!text.lines().any(|l| l.trim_start().starts_with('-')),
"`{}` lists a flag:\n{text}",
group.join(" ")
);
assert!(
!text.lines().any(|l| l.trim_start().starts_with("help ")),
"`{}` lists the help subcommand:\n{text}",
group.join(" ")
);
assert!(
text.contains(&format!(
"Run `rsconstruct {} --help` for the options.",
group.join(" ")
)),
"`{}` has no --help trailer:\n{text}",
group.join(" ")
);
}
}
#[test]
fn operations_help_steps_over_global_flags() {
let text = operations_help_for(["rsconstruct", "-v", "clean"]).to_string();
assert!(text.contains("Run `rsconstruct clean --help`"), "{text}");
}
#[test]
fn zsh_and_fish_completion_scripts_generate() {
for shell in [Shell::Zsh, Shell::Fish] {
let script = generate_completion_script(shell)
.unwrap_or_else(|e| panic!("completion generation failed for {shell}: {e}"));
assert!(!script.is_empty(), "empty completion script for {shell}");
}
}
}