use clap::Command;
use clap_complete::{generate, Shell};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ShellError {
#[error("unknown command '{0}'")]
UnknownCommand(String),
}
pub const KNOWN_BUILTINS: &[&str] = &[
"completion",
"config",
"describe",
"describe-pipeline",
"disable",
"enable",
"exec",
"health",
"init",
"list",
"man",
"reload",
"usage",
"validate",
];
pub const KNOWN_BUILTIN_DESCRIPTIONS: &[(&str, &str)] = &[
("completion", "Generate shell completion script"),
("config", "Read or update runtime configuration"),
("describe", "Show module metadata and schema"),
(
"describe-pipeline",
"Describe the execution pipeline for a strategy",
),
("disable", "Disable a module at runtime"),
("enable", "Enable a module at runtime"),
("exec", "Execute an apcore module"),
("health", "Show module or registry health status"),
("init", "Scaffolding commands"),
("list", "List available modules"),
("man", "Generate man page"),
("reload", "Reload a module's definition"),
("usage", "Show module usage counters"),
("validate", "Validate a module's input against its schema"),
];
pub fn register_completion_command(cli: Command, prog_name: &str) -> Command {
let _ = prog_name; cli.subcommand(completion_command())
}
pub fn register_man_command(cli: Command) -> Command {
cli.subcommand(man_command())
}
pub fn completion_command() -> clap::Command {
clap::Command::new("completion")
.about("Generate a shell completion script and print it to stdout")
.long_about(
"Generate a shell completion script and print it to stdout.\n\n\
Install examples:\n\
\x20 bash: eval \"$(apcore-cli completion bash)\"\n\
\x20 zsh: eval \"$(apcore-cli completion zsh)\"\n\
\x20 fish: apcore-cli completion fish | source\n\
\x20 elvish: eval (apcore-cli completion elvish)\n\
\x20 powershell: apcore-cli completion powershell | Out-String | Invoke-Expression",
)
.arg(
clap::Arg::new("shell")
.value_name("SHELL")
.required(true)
.value_parser(clap::value_parser!(Shell))
.help("Shell to generate completions for (bash, zsh, fish, elvish, powershell)"),
)
}
pub fn cmd_completion(shell: Shell, prog_name: &str, cmd: &mut clap::Command) -> String {
match shell {
Shell::Bash => generate_grouped_bash_completion(prog_name),
Shell::Zsh => generate_grouped_zsh_completion(prog_name),
Shell::Fish => generate_grouped_fish_completion(prog_name),
_ => {
let mut buf: Vec<u8> = Vec::new();
generate(shell, cmd, prog_name, &mut buf);
String::from_utf8_lossy(&buf).into_owned()
}
}
}
fn make_function_name(prog_name: &str) -> String {
let sanitised: String = prog_name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("_{sanitised}")
}
fn shell_quote(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\\''"))
}
fn module_list_cmd(quoted: &str) -> String {
format!(
"{quoted} list --format json 2>/dev/null \
| python3 -c \"import sys,json;\
[print(m['id']) for m in json.load(sys.stdin)]\" \
2>/dev/null"
)
}
fn groups_and_top_cmd(quoted: &str) -> String {
format!(
"{quoted} list --format json 2>/dev/null \
| python3 -c \"\
import sys,json\n\
ids=[m['id'] for m in json.load(sys.stdin)]\n\
groups=set()\n\
top=[]\n\
for i in ids:\n\
if '.' in i: groups.add(i.split('.')[0])\n\
else: top.append(i)\n\
print(' '.join(sorted(groups)+sorted(top)))\n\
\" 2>/dev/null"
)
}
fn group_cmds_cmd(quoted: &str) -> String {
format!(
"{quoted} list --format json 2>/dev/null \
| python3 -c \"\
import sys,json,os\n\
g=os.environ['_APCORE_GRP']\n\
ids=[m['id'] for m in json.load(sys.stdin)]\n\
for i in ids:\n\
if '.' in i and i.split('.')[0]==g: \
print(i.split('.',1)[1])\n\
\" 2>/dev/null"
)
}
pub fn generate_grouped_bash_completion(prog_name: &str) -> String {
let fn_name = make_function_name(prog_name);
let quoted = shell_quote(prog_name);
let ml = module_list_cmd("ed);
let gt = groups_and_top_cmd("ed);
let gc = group_cmds_cmd("ed);
let builtins = KNOWN_BUILTINS.join(" ");
format!(
"{fn_name}() {{\n\
\x20 local cur prev\n\
\x20 COMPREPLY=()\n\
\x20 cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n\
\x20 prev=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n\
\n\
\x20 if [[ ${{COMP_CWORD}} -eq 1 ]]; then\n\
\x20 local all_ids=$({gt})\n\
\x20 local builtins=\"{builtins}\"\n\
\x20 COMPREPLY=( $(compgen -W \
\"${{builtins}} ${{all_ids}}\" -- ${{cur}}) )\n\
\x20 return 0\n\
\x20 fi\n\
\n\
\x20 if [[ \"${{COMP_WORDS[1]}}\" == \"exec\" \
&& ${{COMP_CWORD}} -eq 2 ]]; then\n\
\x20 local modules=$({ml})\n\
\x20 COMPREPLY=( $(compgen -W \
\"${{modules}}\" -- ${{cur}}) )\n\
\x20 return 0\n\
\x20 fi\n\
\n\
\x20 if [[ ${{COMP_CWORD}} -eq 2 ]]; then\n\
\x20 local grp=\"${{COMP_WORDS[1]}}\"\n\
\x20 local cmds=$(export \
_APCORE_GRP=\"$grp\"; {gc})\n\
\x20 COMPREPLY=( $(compgen -W \
\"${{cmds}}\" -- ${{cur}}) )\n\
\x20 return 0\n\
\x20 fi\n\
}}\n\
complete -F {fn_name} {quoted}\n"
)
}
pub fn generate_grouped_zsh_completion(prog_name: &str) -> String {
let fn_name = make_function_name(prog_name);
let quoted = shell_quote(prog_name);
let ml = module_list_cmd("ed);
let gt = groups_and_top_cmd("ed);
let gc = group_cmds_cmd("ed);
let zsh_commands = KNOWN_BUILTIN_DESCRIPTIONS
.iter()
.map(|(name, desc)| format!(" '{name}:{desc}'\n"))
.collect::<String>();
format!(
"#compdef {prog_name}\n\
\n\
{fn_name}() {{\n\
\x20 local -a commands groups_and_top\n\
\x20 commands=(\n\
{zsh_commands}\
\x20 )\n\
\n\
\x20 _arguments -C \\\n\
\x20 '1:command:->command' \\\n\
\x20 '*::arg:->args'\n\
\n\
\x20 case \"$state\" in\n\
\x20 command)\n\
\x20 groups_and_top=($({gt}))\n\
\x20 _describe -t commands \
'{prog_name} commands' commands\n\
\x20 compadd -a groups_and_top\n\
\x20 ;;\n\
\x20 args)\n\
\x20 case \"${{words[1]}}\" in\n\
\x20 exec)\n\
\x20 local modules\n\
\x20 modules=($({ml}))\n\
\x20 compadd -a modules\n\
\x20 ;;\n\
\x20 *)\n\
\x20 local -a group_cmds\n\
\x20 group_cmds=($(export \
_APCORE_GRP=\"${{words[1]}}\"; {gc}))\n\
\x20 compadd -a group_cmds\n\
\x20 ;;\n\
\x20 esac\n\
\x20 ;;\n\
\x20 esac\n\
}}\n\
\n\
compdef {fn_name} {quoted}\n"
)
}
pub fn generate_grouped_fish_completion(prog_name: &str) -> String {
let quoted = shell_quote(prog_name);
let ml_fish = module_list_cmd_fish("ed);
let gt_fish = groups_and_top_cmd_fish("ed);
let gc_fish_fn = group_cmds_fish_fn("ed);
let fish_builtins = KNOWN_BUILTIN_DESCRIPTIONS
.iter()
.map(|(name, desc)| {
format!("complete -c {quoted} -n \"__fish_use_subcommand\" -a {name} -d \"{desc}\"\n")
})
.collect::<String>();
format!(
"# Fish completions for {prog_name}\n\
\n\
{gc_fish_fn}\
\n\
{fish_builtins}\
complete -c {quoted} -n \"__fish_use_subcommand\" \
-a \"({gt_fish})\" \
-d \"Module group or command\"\n\
\n\
complete -c {quoted} \
-n \"__fish_seen_subcommand_from exec\" \
-a \"({ml_fish})\"\n"
)
}
fn module_list_cmd_fish(quoted: &str) -> String {
format!(
"{quoted} list --format json 2>/dev/null \
| python3 -c \\\"import sys,json;\
[print(m['id']) for m in json.load(sys.stdin)]\\\" \
2>/dev/null"
)
}
fn groups_and_top_cmd_fish(quoted: &str) -> String {
format!(
"{quoted} list --format json 2>/dev/null \
| python3 -c \\\"\
import sys,json\\n\
ids=[m['id'] for m in json.load(sys.stdin)]\\n\
groups=set()\\n\
top=[]\\n\
for i in ids:\\n\
if '.' in i: groups.add(i.split('.')[0])\\n\
else: top.append(i)\\n\
print('\\\\n'.join(sorted(groups)+sorted(top)))\\n\
\\\" 2>/dev/null"
)
}
fn group_cmds_fish_fn(quoted: &str) -> String {
format!(
"function __apcore_group_cmds\n\
\x20 set -l grp $argv[1]\n\
\x20 {quoted} list --format json 2>/dev/null\
| python3 -c \\\"\
import sys,json,os\\n\
g=os.environ['_APCORE_GRP']\\n\
ids=[m['id'] for m in json.load(sys.stdin)]\\n\
for i in ids:\\n\
if '.' in i and i.split('.')[0]==g: \
print(i.split('.',1)[1])\\n\
\\\" 2>/dev/null\n\
end\n"
)
}
pub fn man_command() -> Command {
Command::new("man")
.about("Generate a roff man page for COMMAND and print it to stdout")
.long_about(
"Generate a roff man page for COMMAND and print it to stdout.\n\n\
View immediately:\n\
\x20 apcore-cli man exec | man -l -\n\
\x20 apcore-cli man list | col -bx | less\n\n\
Install system-wide:\n\
\x20 apcore-cli man exec > /usr/local/share/man/man1/apcore-cli-exec.1\n\
\x20 mandb # (Linux) or /usr/libexec/makewhatis # (macOS)",
)
.arg(
clap::Arg::new("command")
.value_name("COMMAND")
.required(true)
.help("CLI subcommand to generate the man page for"),
)
}
pub fn build_synopsis(cmd: Option<&clap::Command>, prog_name: &str, command_name: &str) -> String {
let prog = roff_escape(prog_name);
let cmd_name = roff_escape(command_name);
let Some(cmd) = cmd else {
return format!("\\fB{prog} {cmd_name}\\fR [OPTIONS]");
};
let mut parts = vec![format!("\\fB{prog} {cmd_name}\\fR")];
for arg in cmd.get_arguments() {
let id = arg.get_id().as_str();
if id == "help" || id == "version" {
continue;
}
let is_positional = arg.get_long().is_none() && arg.get_short().is_none();
let is_required = arg.is_required_set();
if is_positional {
let meta_owned: String = arg
.get_value_names()
.and_then(|v| v.first().map(|s| s.to_string()))
.unwrap_or_else(|| "ARG".to_string());
let meta = meta_owned.as_str();
if is_required {
parts.push(format!("\\fI{meta}\\fR"));
} else {
parts.push(format!("[\\fI{meta}\\fR]"));
}
} else {
let flag = if let Some(long) = arg.get_long() {
format!("\\-\\-{long}")
} else {
format!("\\-{}", arg.get_short().unwrap())
};
let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
if is_flag {
parts.push(format!("[{flag}]"));
} else {
let type_name_owned: String = arg
.get_value_names()
.and_then(|v| v.first().map(|s| s.to_string()))
.unwrap_or_else(|| "VALUE".to_string());
let type_name = type_name_owned.as_str();
if is_required {
parts.push(format!("{flag} \\fI{type_name}\\fR"));
} else {
parts.push(format!("[{flag} \\fI{type_name}\\fR]"));
}
}
}
}
parts.join(" ")
}
pub fn generate_man_page(
command_name: &str,
cmd: Option<&clap::Command>,
prog_name: &str,
version: &str,
) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let today = {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86400;
format_roff_date(days)
};
let prog = roff_escape(prog_name);
let cmd_name_esc = roff_escape(command_name);
let title = format!("{}-{}", prog_name, command_name).to_uppercase();
let pkg_label = format!("{prog} {}", roff_escape(version));
let manual_label = format!("{prog} Manual");
let mut sections: Vec<String> = Vec::new();
sections.push(format!(
".TH \"{title}\" \"1\" \"{today}\" \"{pkg_label}\" \"{manual_label}\""
));
sections.push(".SH NAME".to_string());
let desc = cmd
.and_then(|c| c.get_about())
.map(|s| s.to_string())
.unwrap_or_else(|| command_name.to_string());
let name_desc = desc.lines().next().unwrap_or("").trim_end_matches('.');
sections.push(format!(
"{prog}-{cmd_name_esc} \\- {}",
roff_escape(name_desc)
));
sections.push(".SH SYNOPSIS".to_string());
sections.push(build_synopsis(cmd, prog_name, command_name));
if let Some(about) = cmd.and_then(|c| c.get_about()) {
sections.push(".SH DESCRIPTION".to_string());
sections.push(roff_escape_block(&about.to_string()));
} else {
sections.push(".SH DESCRIPTION".to_string());
sections.push(format!("{prog}\\-{cmd_name_esc}"));
}
if let Some(c) = cmd {
let options: Vec<_> = c
.get_arguments()
.filter(|a| a.get_long().is_some() || a.get_short().is_some())
.filter(|a| a.get_id().as_str() != "help" && a.get_id().as_str() != "version")
.collect();
if !options.is_empty() {
sections.push(".SH OPTIONS".to_string());
for arg in options {
let flag_parts: Vec<String> = {
let mut fp = Vec::new();
if let Some(short) = arg.get_short() {
fp.push(format!("\\-{short}"));
}
if let Some(long) = arg.get_long() {
fp.push(format!("\\-\\-{long}"));
}
fp
};
let flag_str = flag_parts.join(", ");
let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
sections.push(".TP".to_string());
if is_flag {
sections.push(format!("\\fB{flag_str}\\fR"));
} else {
let type_name_owned: String = arg
.get_value_names()
.and_then(|v| v.first().map(|s| s.to_string()))
.unwrap_or_else(|| "VALUE".to_string());
let type_name = type_name_owned.as_str();
sections.push(format!("\\fB{flag_str}\\fR \\fI{type_name}\\fR"));
}
if let Some(help) = arg.get_help() {
sections.push(roff_escape_block(&help.to_string()));
}
if let Some(default) = arg.get_default_values().first() {
if !is_flag {
sections.push(format!("Default: {}.", default.to_string_lossy()));
}
}
}
}
}
sections.push(".SH ENVIRONMENT".to_string());
for (name, desc) in ENV_ENTRIES {
sections.push(".TP".to_string());
sections.push(format!("\\fB{name}\\fR"));
sections.push(desc.to_string());
}
sections.push(".SH EXIT CODES".to_string());
for (code, meaning) in EXIT_CODES {
sections.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
}
sections.push(".SH SEE ALSO".to_string());
let see_also = [
format!("\\fB{prog}\\fR(1)"),
format!("\\fB{prog}\\-list\\fR(1)"),
format!("\\fB{prog}\\-describe\\fR(1)"),
format!("\\fB{prog}\\-completion\\fR(1)"),
];
sections.push(see_also.join(", "));
sections.join("\n")
}
pub const ENV_ENTRIES: &[(&str, &str)] = &[
(
"APCORE_EXTENSIONS_ROOT",
"Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.",
),
(
"APCORE_CLI_AUTO_APPROVE",
"Set to \\fB1\\fR to bypass approval prompts for modules that require human-in-the-loop confirmation.",
),
(
"APCORE_CLI_LOGGING_LEVEL",
"CLI-specific logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
Takes priority over \\fBAPCORE_LOGGING_LEVEL\\fR. Default: WARNING.",
),
(
"APCORE_LOGGING_LEVEL",
"Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING.",
),
(
"APCORE_AUTH_API_KEY",
"API key for authenticating with the apcore registry.",
),
];
pub const EXIT_CODES: &[(&str, &str)] = &[
("0", "Success."),
("1", "Module execution error."),
("2", "Invalid CLI input or missing argument."),
("44", "Module not found, disabled, or failed to load."),
("45", "Input failed JSON Schema validation."),
(
"46",
"Approval denied, timed out, or no interactive terminal available.",
),
(
"47",
"Configuration error (extensions directory not found or unreadable).",
),
("48", "Schema contains a circular \\fB$ref\\fR."),
(
"77",
"ACL denied \\- insufficient permissions for this module.",
),
("130", "Execution cancelled by user (SIGINT / Ctrl\\-C)."),
];
pub fn cmd_man(
command_name: &str,
root_cmd: &clap::Command,
prog_name: &str,
version: &str,
) -> Result<String, ShellError> {
let cmd_opt = root_cmd
.get_subcommands()
.find(|c| c.get_name() == command_name);
if cmd_opt.is_none() && !KNOWN_BUILTINS.contains(&command_name) {
return Err(ShellError::UnknownCommand(command_name.to_string()));
}
Ok(generate_man_page(command_name, cmd_opt, prog_name, version))
}
fn roff_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('-', "\\-")
.replace('\'', "\\(aq")
}
fn roff_escape_block(s: &str) -> String {
roff_escape(s)
.lines()
.map(|line| {
if line.starts_with('.') {
format!("\\&{line}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn has_man_flag(args: &[String]) -> bool {
args.iter().any(|a| a == "--man")
}
pub fn build_program_man_page(
cmd: &clap::Command,
prog_name: &str,
version: &str,
description: Option<&str>,
docs_url: Option<&str>,
) -> String {
let desc = description
.map(|s| s.to_string())
.or_else(|| cmd.get_about().map(|s| s.to_string()))
.unwrap_or_else(|| "CLI".to_string());
let upper = prog_name.to_uppercase();
let mut s = Vec::new();
s.push(format!(
".TH \"{upper}\" \"1\" \"\" \
\"{prog_name} {version}\" \"{prog_name} Manual\""
));
s.push(".SH NAME".to_string());
s.push(format!("{prog_name} \\- {}", roff_escape(&desc)));
s.push(".SH SYNOPSIS".to_string());
s.push(format!(
"\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \
\\fIcommand\\fR [\\fIcommand\\-options\\fR]"
));
s.push(".SH DESCRIPTION".to_string());
s.push(roff_escape_block(&desc));
let global_args: Vec<_> = cmd
.get_arguments()
.filter(|a| !a.is_hide_set() && !matches!(a.get_id().as_str(), "help" | "version" | "man"))
.collect();
if !global_args.is_empty() {
s.push(".SH GLOBAL OPTIONS".to_string());
for arg in &global_args {
if let Some(long) = arg.get_long() {
s.push(".TP".to_string());
s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
if let Some(help) = arg.get_help() {
s.push(roff_escape_block(&help.to_string()));
}
}
}
}
let subcmds: Vec<_> = cmd
.get_subcommands()
.filter(|c| c.get_name() != "help")
.collect();
if !subcmds.is_empty() {
s.push(".SH COMMANDS".to_string());
for sub in &subcmds {
let name = sub.get_name();
let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
s.push(".TP".to_string());
s.push(format!("\\fB{prog_name} {}\\fR", roff_escape(name)));
if !about.is_empty() {
s.push(roff_escape_block(&about));
}
for arg in sub.get_arguments() {
if arg.is_hide_set() || arg.get_id().as_str() == "help" {
continue;
}
if let Some(long) = arg.get_long() {
s.push(".RS".to_string());
s.push(".TP".to_string());
s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
if let Some(help) = arg.get_help() {
s.push(roff_escape_block(&help.to_string()));
}
s.push(".RE".to_string());
}
}
for nested in sub.get_subcommands() {
if nested.get_name() == "help" {
continue;
}
let nested_about = nested
.get_about()
.map(|a| a.to_string())
.unwrap_or_default();
s.push(".TP".to_string());
s.push(format!(
"\\fB{prog_name} {} {}\\fR",
roff_escape(name),
roff_escape(nested.get_name())
));
if !nested_about.is_empty() {
s.push(roff_escape_block(&nested_about));
}
for arg in nested.get_arguments() {
if arg.is_hide_set() || arg.get_id().as_str() == "help" {
continue;
}
if let Some(long) = arg.get_long() {
s.push(".RS".to_string());
s.push(".TP".to_string());
s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
if let Some(help) = arg.get_help() {
s.push(roff_escape_block(&help.to_string()));
}
s.push(".RE".to_string());
}
}
}
}
}
s.push(".SH ENVIRONMENT".to_string());
for (name, env_desc) in ENV_ENTRIES {
s.push(".TP".to_string());
s.push(format!("\\fB{name}\\fR"));
s.push(env_desc.to_string());
}
s.push(".SH EXIT CODES".to_string());
for (code, meaning) in EXIT_CODES {
s.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
}
s.push(".SH SEE ALSO".to_string());
s.push(format!(
"\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR \
for full option list."
));
if let Some(url) = docs_url {
s.push(format!(
".PP\nFull documentation at \\fI{}\\fR",
roff_escape(url)
));
}
s.join("\n")
}
fn format_roff_date(days_since_epoch: u64) -> String {
let mut remaining = days_since_epoch;
let mut year = 1970u32;
loop {
let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
let days_in_year = if leap { 366 } else { 365 };
if remaining < days_in_year {
break;
}
remaining -= days_in_year;
year += 1;
}
let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
let month_days = [
31u64,
if leap { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 1u32;
for &d in &month_days {
if remaining < d {
break;
}
remaining -= d;
month += 1;
}
let day = remaining + 1;
format!("{year:04}-{month:02}-{day:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_shell_error_unknown_command_message() {
let err = ShellError::UnknownCommand("bogus".to_string());
assert_eq!(err.to_string(), "unknown command 'bogus'");
}
#[test]
fn test_known_builtins_contains_required_commands() {
for cmd in &["exec", "list", "describe", "completion", "init", "man"] {
assert!(
KNOWN_BUILTINS.contains(cmd),
"KNOWN_BUILTINS must contain '{cmd}'"
);
}
}
#[test]
fn test_known_builtins_has_expected_count() {
assert_eq!(KNOWN_BUILTINS.len(), 14);
}
#[test]
fn test_known_builtin_descriptions_covers_all_builtins() {
for name in KNOWN_BUILTINS {
assert!(
KNOWN_BUILTIN_DESCRIPTIONS.iter().any(|(n, _)| n == name),
"KNOWN_BUILTIN_DESCRIPTIONS missing entry for '{name}'"
);
}
assert_eq!(
KNOWN_BUILTIN_DESCRIPTIONS.len(),
KNOWN_BUILTINS.len(),
"tables must stay the same length"
);
}
#[test]
fn test_bash_completion_lists_every_known_builtin() {
let script = generate_grouped_bash_completion("apcli");
for name in KNOWN_BUILTINS {
assert!(
script.contains(name),
"bash completion script missing builtin '{name}': {script}"
);
}
}
#[test]
fn test_zsh_completion_lists_every_known_builtin() {
let script = generate_grouped_zsh_completion("apcli");
for name in KNOWN_BUILTINS {
assert!(
script.contains(name),
"zsh completion script missing builtin '{name}': {script}"
);
}
}
#[test]
fn test_fish_completion_lists_every_known_builtin() {
let script = generate_grouped_fish_completion("apcli");
for name in KNOWN_BUILTINS {
assert!(
script.contains(name),
"fish completion script missing builtin '{name}': {script}"
);
}
}
fn make_test_cmd(prog: &str) -> clap::Command {
clap::Command::new(prog.to_string())
.about("test")
.subcommand(clap::Command::new("exec"))
.subcommand(clap::Command::new("list"))
}
#[test]
fn test_cmd_completion_bash_nonempty() {
let mut cmd = make_test_cmd("apcore-cli");
let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
assert!(
!output.is_empty(),
"bash completion output must not be empty"
);
}
#[test]
fn test_cmd_completion_zsh_nonempty() {
let mut cmd = make_test_cmd("apcore-cli");
let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
assert!(
!output.is_empty(),
"zsh completion output must not be empty"
);
}
#[test]
fn test_cmd_completion_fish_nonempty() {
let mut cmd = make_test_cmd("apcore-cli");
let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
assert!(
!output.is_empty(),
"fish completion output must not be empty"
);
}
#[test]
fn test_cmd_completion_elvish_nonempty() {
let mut cmd = make_test_cmd("apcore-cli");
let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
assert!(
!output.is_empty(),
"elvish completion output must not be empty"
);
}
#[test]
fn test_cmd_completion_bash_contains_prog_name() {
let mut cmd = make_test_cmd("my-tool");
let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
assert!(
output.contains("my-tool") || output.contains("my_tool"),
"bash completion must reference the program name"
);
}
#[test]
fn test_completion_command_has_shell_arg() {
let cmd = completion_command();
let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
assert!(
arg.is_some(),
"completion_command must have a 'shell' argument"
);
}
#[test]
fn test_completion_command_name() {
let cmd = completion_command();
assert_eq!(cmd.get_name(), "completion");
}
fn make_exec_cmd() -> clap::Command {
clap::Command::new("exec")
.about("Execute an apcore module")
.arg(
clap::Arg::new("module_id")
.value_name("MODULE_ID")
.required(true)
.help("Module ID to execute"),
)
.arg(
clap::Arg::new("format")
.long("format")
.value_name("FORMAT")
.help("Output format")
.default_value("table"),
)
}
#[test]
fn test_build_synopsis_no_cmd() {
let synopsis = build_synopsis(None, "apcore-cli", "exec");
assert!(synopsis.contains("apcore\\-cli"));
assert!(synopsis.contains("exec"));
}
#[test]
fn test_build_synopsis_required_positional_no_brackets() {
let cmd = make_exec_cmd();
let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
assert!(
!synopsis.contains("[\\fIMODULE_ID\\fR]"),
"required arg must not have brackets"
);
}
#[test]
fn test_build_synopsis_optional_option_has_brackets() {
let cmd = make_exec_cmd();
let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
assert!(
synopsis.contains('['),
"optional option must be wrapped in brackets"
);
}
#[test]
fn test_generate_man_page_contains_th() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(page.contains(".TH"), "man page must have .TH header");
}
#[test]
fn test_generate_man_page_contains_sh_name() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(page.contains(".SH NAME"), "man page must have NAME section");
}
#[test]
fn test_generate_man_page_contains_sh_synopsis() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(
page.contains(".SH SYNOPSIS"),
"man page must have SYNOPSIS section"
);
}
#[test]
fn test_generate_man_page_contains_exit_codes() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(
page.contains(".SH EXIT CODES"),
"man page must have EXIT CODES section"
);
assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
}
#[test]
fn test_generate_man_page_contains_environment() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(
page.contains(".SH ENVIRONMENT"),
"man page must have ENVIRONMENT section"
);
assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
}
#[test]
fn test_generate_man_page_contains_see_also() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(
page.contains(".SH SEE ALSO"),
"man page must have SEE ALSO section"
);
assert!(page.contains("apcore\\-cli"));
}
#[test]
fn test_generate_man_page_th_includes_prog_and_version() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
assert!(
th_line.contains("APCORE-CLI-EXEC"),
"TH must contain uppercased title"
);
assert!(th_line.contains("0.2.0"), "TH must contain version");
}
#[test]
fn test_generate_man_page_name_uses_description() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(
page.contains("Execute an apcore module"),
"NAME must use about text"
);
}
#[test]
fn test_generate_man_page_no_description_section_when_no_long_help() {
let cmd = make_exec_cmd();
let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
assert!(page.contains(".SH DESCRIPTION"));
}
#[test]
fn test_cmd_man_known_builtin_returns_ok() {
let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
assert!(result.is_ok(), "known builtin 'list' must return Ok");
}
#[test]
fn test_cmd_man_registered_subcommand_returns_ok() {
let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
assert!(
result.is_ok(),
"registered subcommand 'exec' must return Ok"
);
let page = result.unwrap();
assert!(page.contains(".TH"));
}
#[test]
fn test_cmd_man_unknown_command_returns_err() {
let root = clap::Command::new("apcore-cli");
let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
assert!(result.is_err());
match result.unwrap_err() {
ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
}
}
#[test]
fn test_cmd_man_exec_contains_options_section() {
let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
assert!(
page.contains(".SH OPTIONS"),
"exec man page must have OPTIONS section"
);
}
#[test]
fn test_roff_escape_backslash() {
assert_eq!(roff_escape("a\\b"), "a\\\\b");
}
#[test]
fn test_roff_escape_hyphen() {
assert_eq!(roff_escape("foo-bar"), "foo\\-bar");
}
#[test]
fn test_roff_escape_single_quote() {
assert_eq!(roff_escape("it's"), "it\\(aqs");
}
#[test]
fn test_roff_escape_block_leading_dot_defused() {
let text = "first line\n.NET Framework\nend";
let escaped = roff_escape_block(text);
assert!(
escaped.contains("\\&.NET"),
"leading dot must be prefixed with \\& to prevent roff control: {escaped:?}"
);
}
#[test]
fn test_roff_escape_block_no_leading_dot_unchanged() {
let text = "normal line\nalso normal";
let escaped = roff_escape_block(text);
assert_eq!(escaped, "normal line\nalso normal");
}
#[test]
fn test_has_man_flag_present() {
let args = vec!["--man".to_string()];
assert!(has_man_flag(&args));
}
#[test]
fn test_has_man_flag_absent() {
let args = vec!["--help".to_string()];
assert!(!has_man_flag(&args));
}
#[test]
fn test_build_program_man_page_basic() {
let cmd = clap::Command::new("t")
.about("Test")
.subcommand(clap::Command::new("sub").about("A sub"));
let roff = build_program_man_page(&cmd, "t", "0.1.0", None, None);
assert!(roff.contains(".TH \"T\""));
assert!(roff.contains(".SH COMMANDS"));
assert!(roff.contains("sub"));
assert!(roff.contains(".SH EXIT CODES"));
}
#[test]
fn test_build_program_man_page_custom_description() {
let cmd = clap::Command::new("t").about("Default");
let roff = build_program_man_page(&cmd, "t", "0.1.0", Some("Custom"), None);
assert!(roff.contains("Custom"));
}
#[test]
fn test_completion_bash_outputs_script() {
let cmd = completion_command();
let positionals: Vec<&str> = cmd
.get_positionals()
.filter_map(|a| a.get_id().as_str().into())
.collect();
assert!(
!positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
"completion must have shell arg, got {positionals:?}"
);
}
#[test]
fn test_completion_zsh_outputs_script() {
let cmd = completion_command();
let shell_arg = cmd
.get_arguments()
.find(|a| a.get_id() == "shell")
.expect("shell argument must exist");
let possible = shell_arg.get_possible_values();
let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
}
#[test]
fn test_completion_invalid_shell_exits_nonzero() {
let cmd = completion_command();
let shell_arg = cmd
.get_arguments()
.find(|a| a.get_id() == "shell")
.expect("shell argument must exist");
let possible = shell_arg.get_possible_values();
let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
assert!(
!values.contains(&"invalid_shell"),
"invalid_shell must not be accepted"
);
}
#[test]
fn test_register_completion_command_attaches_completion() {
let root = register_completion_command(Command::new("root"), "apcore-cli");
let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
assert!(
names.contains(&"completion"),
"must have 'completion' subcommand, got {names:?}"
);
assert!(
!names.contains(&"man"),
"man must be absent when only completion registrar called"
);
}
#[test]
fn test_register_man_command_attaches_man() {
let root = register_man_command(Command::new("root"));
let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
assert!(
names.contains(&"man"),
"must have 'man' subcommand, got {names:?}"
);
assert!(
!names.contains(&"completion"),
"completion must be absent when only man registrar called"
);
}
}