Skip to main content

apcore_cli/
shell.rs

1// apcore-cli — Shell completion and man page generation.
2// Protocol spec: FE-10 (register_shell_commands)
3
4use clap::Command;
5use clap_complete::{generate, Shell};
6use thiserror::Error;
7
8// ---------------------------------------------------------------------------
9// ShellError
10// ---------------------------------------------------------------------------
11
12/// Errors produced by shell integration commands.
13#[derive(Debug, Error)]
14pub enum ShellError {
15    #[error("unknown command '{0}'")]
16    UnknownCommand(String),
17}
18
19// ---------------------------------------------------------------------------
20// KNOWN_BUILTINS
21// ---------------------------------------------------------------------------
22
23/// Command names that `cmd_man` accepts as a man-page fallback when the name
24/// is not yet wired into the live clap subcommand tree.
25///
26/// FE-13: after the `apcli` group restructure, the 13 former root-level
27/// built-ins live under `apcli` (e.g. `apcli list`). This constant retains
28/// the flat names for the v0.7 deprecation window so legacy invocations like
29/// `apcore-cli man list` still render useful output. Keep the set in sync
30/// with the spec §4.1 subcommand table plus the root-level `man` meta.
31pub const KNOWN_BUILTINS: &[&str] = &[
32    "completion",
33    "config",
34    "describe",
35    "describe-pipeline",
36    "disable",
37    "enable",
38    "exec",
39    "health",
40    "init",
41    "list",
42    "man",
43    "reload",
44    "usage",
45    "validate",
46];
47
48/// `(name, description)` pairs for every entry in `KNOWN_BUILTINS`. Single
49/// source of truth for shell completion scripts — drives the bash, zsh, and
50/// fish generators so adding a new built-in doesn't need touching three
51/// hand-maintained lists (review #3).
52pub const KNOWN_BUILTIN_DESCRIPTIONS: &[(&str, &str)] = &[
53    ("completion", "Generate shell completion script"),
54    ("config", "Read or update runtime configuration"),
55    ("describe", "Show module metadata and schema"),
56    (
57        "describe-pipeline",
58        "Describe the execution pipeline for a strategy",
59    ),
60    ("disable", "Disable a module at runtime"),
61    ("enable", "Enable a module at runtime"),
62    ("exec", "Execute an apcore module"),
63    ("health", "Show module or registry health status"),
64    ("init", "Scaffolding commands"),
65    ("list", "List available modules"),
66    ("man", "Generate man page"),
67    ("reload", "Reload a module's definition"),
68    ("usage", "Show module usage counters"),
69    ("validate", "Validate a module's input against its schema"),
70];
71
72// ---------------------------------------------------------------------------
73// register_shell_commands
74// ---------------------------------------------------------------------------
75
76/// Attach the `completion` subcommand to the given command. Returns the
77/// command with the subcommand added. `prog_name` is accepted for API
78/// symmetry and future dynamic use; the builder itself is currently static.
79pub fn register_completion_command(cli: Command, prog_name: &str) -> Command {
80    let _ = prog_name; // prog_name reserved for future dynamic use
81    cli.subcommand(completion_command())
82}
83
84/// Attach the `man` subcommand to the given command. Returns the command
85/// with the subcommand added.
86///
87/// `man` is a root-level meta command under FE-13 and is NOT part of the
88/// `apcli` built-in group (see spec §4.1).
89pub fn register_man_command(cli: Command) -> Command {
90    cli.subcommand(man_command())
91}
92
93// ---------------------------------------------------------------------------
94// completion_command / cmd_completion
95// ---------------------------------------------------------------------------
96
97/// Build the `completion` clap subcommand.
98pub fn completion_command() -> clap::Command {
99    clap::Command::new("completion")
100        .about("Generate a shell completion script and print it to stdout")
101        .long_about(
102            "Generate a shell completion script and print it to stdout.\n\n\
103             Install examples:\n\
104             \x20 bash:       eval \"$(apcore-cli completion bash)\"\n\
105             \x20 zsh:        eval \"$(apcore-cli completion zsh)\"\n\
106             \x20 fish:       apcore-cli completion fish | source\n\
107             \x20 elvish:     eval (apcore-cli completion elvish)\n\
108             \x20 powershell: apcore-cli completion powershell | Out-String | Invoke-Expression",
109        )
110        .arg(
111            clap::Arg::new("shell")
112                .value_name("SHELL")
113                .required(true)
114                .value_parser(clap::value_parser!(Shell))
115                .help("Shell to generate completions for (bash, zsh, fish, elvish, powershell)"),
116        )
117}
118
119/// Handler: generate a shell completion script and return it as a String.
120///
121/// For bash, zsh, and fish this produces grouped completion scripts that
122/// support `_APCORE_GRP`-based group filtering (matching the Python
123/// reference implementation).  Other shells fall back to clap_complete.
124///
125/// `shell`     — the target shell (parsed from clap argument)
126/// `prog_name` — the program name to embed in the script
127/// `cmd`       — mutable reference to the root Command (required by clap_complete)
128pub fn cmd_completion(shell: Shell, prog_name: &str, cmd: &mut clap::Command) -> String {
129    match shell {
130        Shell::Bash => generate_grouped_bash_completion(prog_name),
131        Shell::Zsh => generate_grouped_zsh_completion(prog_name),
132        Shell::Fish => generate_grouped_fish_completion(prog_name),
133        _ => {
134            let mut buf: Vec<u8> = Vec::new();
135            generate(shell, cmd, prog_name, &mut buf);
136            String::from_utf8_lossy(&buf).into_owned()
137        }
138    }
139}
140
141// -----------------------------------------------------------------
142// Helpers for grouped completion
143// -----------------------------------------------------------------
144
145/// Convert a prog_name like `my-tool` to a valid shell function
146/// name: `_my_tool`.
147fn make_function_name(prog_name: &str) -> String {
148    let sanitised: String = prog_name
149        .chars()
150        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
151        .collect();
152    format!("_{sanitised}")
153}
154
155/// POSIX-style shell quoting for a program name.
156///
157/// Wraps the value in single quotes and escapes embedded single
158/// quotes using the `'\''` idiom.
159fn shell_quote(s: &str) -> String {
160    if s.chars()
161        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
162    {
163        return s.to_string();
164    }
165    format!("'{}'", s.replace('\'', "'\\''"))
166}
167
168/// Build the inline python3 command that lists all module IDs.
169fn module_list_cmd(quoted: &str) -> String {
170    format!(
171        "{quoted} list --format json 2>/dev/null \
172         | python3 -c \"import sys,json;\
173         [print(m['id']) for m in json.load(sys.stdin)]\" \
174         2>/dev/null"
175    )
176}
177
178/// Build the inline python3 command that extracts group names and
179/// top-level (ungrouped) module IDs.
180fn groups_and_top_cmd(quoted: &str) -> String {
181    format!(
182        "{quoted} list --format json 2>/dev/null \
183         | python3 -c \"\
184         import sys,json\n\
185         ids=[m['id'] for m in json.load(sys.stdin)]\n\
186         groups=set()\n\
187         top=[]\n\
188         for i in ids:\n\
189             if '.' in i: groups.add(i.split('.')[0])\n\
190             else: top.append(i)\n\
191         print(' '.join(sorted(groups)+sorted(top)))\n\
192         \" 2>/dev/null"
193    )
194}
195
196/// Build the inline python3 command that lists sub-commands for a
197/// group using the `_APCORE_GRP` environment variable.
198fn group_cmds_cmd(quoted: &str) -> String {
199    format!(
200        "{quoted} list --format json 2>/dev/null \
201         | python3 -c \"\
202         import sys,json,os\n\
203         g=os.environ['_APCORE_GRP']\n\
204         ids=[m['id'] for m in json.load(sys.stdin)]\n\
205         for i in ids:\n\
206             if '.' in i and i.split('.')[0]==g: \
207         print(i.split('.',1)[1])\n\
208         \" 2>/dev/null"
209    )
210}
211
212// -----------------------------------------------------------------
213// Grouped completion generators
214// -----------------------------------------------------------------
215
216/// Generate a bash completion script with grouped module support.
217///
218/// Position 1: complete with builtins + group names + top-level IDs.
219/// Position 2 after `exec`: complete with all module IDs.
220/// Position 2 other: export `_APCORE_GRP="$prev"`, filter by group.
221pub fn generate_grouped_bash_completion(prog_name: &str) -> String {
222    let fn_name = make_function_name(prog_name);
223    let quoted = shell_quote(prog_name);
224    let ml = module_list_cmd(&quoted);
225    let gt = groups_and_top_cmd(&quoted);
226    let gc = group_cmds_cmd(&quoted);
227    // Drive the builtins list from KNOWN_BUILTINS so adding a new built-in
228    // doesn't require touching the bash/zsh/fish generators (review #3).
229    let builtins = KNOWN_BUILTINS.join(" ");
230
231    format!(
232        "{fn_name}() {{\n\
233         \x20   local cur prev\n\
234         \x20   COMPREPLY=()\n\
235         \x20   cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n\
236         \x20   prev=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n\
237         \n\
238         \x20   if [[ ${{COMP_CWORD}} -eq 1 ]]; then\n\
239         \x20       local all_ids=$({gt})\n\
240         \x20       local builtins=\"{builtins}\"\n\
241         \x20       COMPREPLY=( $(compgen -W \
242         \"${{builtins}} ${{all_ids}}\" -- ${{cur}}) )\n\
243         \x20       return 0\n\
244         \x20   fi\n\
245         \n\
246         \x20   if [[ \"${{COMP_WORDS[1]}}\" == \"exec\" \
247         && ${{COMP_CWORD}} -eq 2 ]]; then\n\
248         \x20       local modules=$({ml})\n\
249         \x20       COMPREPLY=( $(compgen -W \
250         \"${{modules}}\" -- ${{cur}}) )\n\
251         \x20       return 0\n\
252         \x20   fi\n\
253         \n\
254         \x20   if [[ ${{COMP_CWORD}} -eq 2 ]]; then\n\
255         \x20       local grp=\"${{COMP_WORDS[1]}}\"\n\
256         \x20       local cmds=$(export \
257         _APCORE_GRP=\"$grp\"; {gc})\n\
258         \x20       COMPREPLY=( $(compgen -W \
259         \"${{cmds}}\" -- ${{cur}}) )\n\
260         \x20       return 0\n\
261         \x20   fi\n\
262         }}\n\
263         complete -F {fn_name} {quoted}\n"
264    )
265}
266
267/// Generate a zsh completion script with grouped module support.
268///
269/// Uses `_arguments`, `compadd`, and `compdef` for native zsh
270/// completion integration.
271pub fn generate_grouped_zsh_completion(prog_name: &str) -> String {
272    let fn_name = make_function_name(prog_name);
273    let quoted = shell_quote(prog_name);
274    let ml = module_list_cmd(&quoted);
275    let gt = groups_and_top_cmd(&quoted);
276    let gc = group_cmds_cmd(&quoted);
277
278    // Drive the (name:description) array from KNOWN_BUILTIN_DESCRIPTIONS
279    // so adding a new built-in stays a one-line edit (review #3).
280    let zsh_commands = KNOWN_BUILTIN_DESCRIPTIONS
281        .iter()
282        .map(|(name, desc)| format!("        '{name}:{desc}'\n"))
283        .collect::<String>();
284
285    format!(
286        "#compdef {prog_name}\n\
287         \n\
288         {fn_name}() {{\n\
289         \x20   local -a commands groups_and_top\n\
290         \x20   commands=(\n\
291         {zsh_commands}\
292         \x20   )\n\
293         \n\
294         \x20   _arguments -C \\\n\
295         \x20       '1:command:->command' \\\n\
296         \x20       '*::arg:->args'\n\
297         \n\
298         \x20   case \"$state\" in\n\
299         \x20       command)\n\
300         \x20           groups_and_top=($({gt}))\n\
301         \x20           _describe -t commands \
302         '{prog_name} commands' commands\n\
303         \x20           compadd -a groups_and_top\n\
304         \x20           ;;\n\
305         \x20       args)\n\
306         \x20           case \"${{words[1]}}\" in\n\
307         \x20               exec)\n\
308         \x20                   local modules\n\
309         \x20                   modules=($({ml}))\n\
310         \x20                   compadd -a modules\n\
311         \x20                   ;;\n\
312         \x20               *)\n\
313         \x20                   local -a group_cmds\n\
314         \x20                   group_cmds=($(export \
315         _APCORE_GRP=\"${{words[1]}}\"; {gc}))\n\
316         \x20                   compadd -a group_cmds\n\
317         \x20                   ;;\n\
318         \x20           esac\n\
319         \x20           ;;\n\
320         \x20   esac\n\
321         }}\n\
322         \n\
323         compdef {fn_name} {quoted}\n"
324    )
325}
326
327/// Generate a fish completion script with grouped module support.
328///
329/// Defines `__apcore_group_cmds` helper and uses `complete -c` for
330/// native fish integration.
331pub fn generate_grouped_fish_completion(prog_name: &str) -> String {
332    let quoted = shell_quote(prog_name);
333
334    // Fish uses backslash-escaped quotes inside -c strings
335    let ml_fish = module_list_cmd_fish(&quoted);
336    let gt_fish = groups_and_top_cmd_fish(&quoted);
337    let gc_fish_fn = group_cmds_fish_fn(&quoted);
338
339    // Drive the per-builtin completion lines from KNOWN_BUILTIN_DESCRIPTIONS
340    // so adding a new built-in stays a one-line edit (review #3).
341    let fish_builtins = KNOWN_BUILTIN_DESCRIPTIONS
342        .iter()
343        .map(|(name, desc)| {
344            format!("complete -c {quoted} -n \"__fish_use_subcommand\" -a {name} -d \"{desc}\"\n")
345        })
346        .collect::<String>();
347
348    format!(
349        "# Fish completions for {prog_name}\n\
350         \n\
351         {gc_fish_fn}\
352         \n\
353         {fish_builtins}\
354         complete -c {quoted} -n \"__fish_use_subcommand\" \
355         -a \"({gt_fish})\" \
356         -d \"Module group or command\"\n\
357         \n\
358         complete -c {quoted} \
359         -n \"__fish_seen_subcommand_from exec\" \
360         -a \"({ml_fish})\"\n"
361    )
362}
363
364/// Fish-specific module list command (uses backslash-escaped quotes).
365fn module_list_cmd_fish(quoted: &str) -> String {
366    format!(
367        "{quoted} list --format json 2>/dev/null \
368         | python3 -c \\\"import sys,json;\
369         [print(m['id']) for m in json.load(sys.stdin)]\\\" \
370         2>/dev/null"
371    )
372}
373
374/// Fish-specific groups-and-top command.
375fn groups_and_top_cmd_fish(quoted: &str) -> String {
376    format!(
377        "{quoted} list --format json 2>/dev/null \
378         | python3 -c \\\"\
379         import sys,json\\n\
380         ids=[m['id'] for m in json.load(sys.stdin)]\\n\
381         groups=set()\\n\
382         top=[]\\n\
383         for i in ids:\\n\
384             if '.' in i: groups.add(i.split('.')[0])\\n\
385             else: top.append(i)\\n\
386         print('\\\\n'.join(sorted(groups)+sorted(top)))\\n\
387         \\\" 2>/dev/null"
388    )
389}
390
391/// Fish helper function for group sub-commands.
392fn group_cmds_fish_fn(quoted: &str) -> String {
393    format!(
394        "function __apcore_group_cmds\n\
395         \x20   set -l grp $argv[1]\n\
396         \x20   {quoted} list --format json 2>/dev/null\
397         | python3 -c \\\"\
398         import sys,json,os\\n\
399         g=os.environ['_APCORE_GRP']\\n\
400         ids=[m['id'] for m in json.load(sys.stdin)]\\n\
401         for i in ids:\\n\
402             if '.' in i and i.split('.')[0]==g: \
403         print(i.split('.',1)[1])\\n\
404         \\\" 2>/dev/null\n\
405         end\n"
406    )
407}
408
409// ---------------------------------------------------------------------------
410// man_command / build_synopsis / generate_man_page / cmd_man
411// ---------------------------------------------------------------------------
412
413/// Build the `man` clap subcommand.
414pub fn man_command() -> Command {
415    Command::new("man")
416        .about("Generate a roff man page for COMMAND and print it to stdout")
417        .long_about(
418            "Generate a roff man page for COMMAND and print it to stdout.\n\n\
419             View immediately:\n\
420             \x20 apcore-cli man exec | man -l -\n\
421             \x20 apcore-cli man list | col -bx | less\n\n\
422             Install system-wide:\n\
423             \x20 apcore-cli man exec > /usr/local/share/man/man1/apcore-cli-exec.1\n\
424             \x20 mandb   # (Linux)  or  /usr/libexec/makewhatis  # (macOS)",
425        )
426        .arg(
427            clap::Arg::new("command")
428                .value_name("COMMAND")
429                .required(true)
430                .help("CLI subcommand to generate the man page for"),
431        )
432}
433
434/// Build the roff SYNOPSIS line from a clap Command's arguments.
435pub fn build_synopsis(cmd: Option<&clap::Command>, prog_name: &str, command_name: &str) -> String {
436    // Route program / command names through roff_escape so the per-command
437    // page handles the same metacharacters (backslash / hyphen / apostrophe)
438    // that build_program_man_page already escapes — review #20.
439    let prog = roff_escape(prog_name);
440    let cmd_name = roff_escape(command_name);
441    let Some(cmd) = cmd else {
442        return format!("\\fB{prog} {cmd_name}\\fR [OPTIONS]");
443    };
444
445    let mut parts = vec![format!("\\fB{prog} {cmd_name}\\fR")];
446
447    for arg in cmd.get_arguments() {
448        // Skip help/version flags injected by clap
449        let id = arg.get_id().as_str();
450        if id == "help" || id == "version" {
451            continue;
452        }
453
454        let is_positional = arg.get_long().is_none() && arg.get_short().is_none();
455        let is_required = arg.is_required_set();
456
457        if is_positional {
458            let meta_owned: String = arg
459                .get_value_names()
460                .and_then(|v| v.first().map(|s| s.to_string()))
461                .unwrap_or_else(|| "ARG".to_string());
462            let meta = meta_owned.as_str();
463            if is_required {
464                parts.push(format!("\\fI{meta}\\fR"));
465            } else {
466                parts.push(format!("[\\fI{meta}\\fR]"));
467            }
468        } else {
469            let flag = if let Some(long) = arg.get_long() {
470                format!("\\-\\-{long}")
471            } else {
472                format!("\\-{}", arg.get_short().unwrap())
473            };
474            let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
475            if is_flag {
476                parts.push(format!("[{flag}]"));
477            } else {
478                let type_name_owned: String = arg
479                    .get_value_names()
480                    .and_then(|v| v.first().map(|s| s.to_string()))
481                    .unwrap_or_else(|| "VALUE".to_string());
482                let type_name = type_name_owned.as_str();
483                if is_required {
484                    parts.push(format!("{flag} \\fI{type_name}\\fR"));
485                } else {
486                    parts.push(format!("[{flag} \\fI{type_name}\\fR]"));
487                }
488            }
489        }
490    }
491
492    parts.join(" ")
493}
494
495/// Build a complete roff man page string for a CLI subcommand.
496pub fn generate_man_page(
497    command_name: &str,
498    cmd: Option<&clap::Command>,
499    prog_name: &str,
500    version: &str,
501) -> String {
502    use std::time::{SystemTime, UNIX_EPOCH};
503
504    let today = {
505        let secs = SystemTime::now()
506            .duration_since(UNIX_EPOCH)
507            .map(|d| d.as_secs())
508            .unwrap_or(0);
509        let days = secs / 86400;
510        format_roff_date(days)
511    };
512
513    // Route prog / command / version through roff_escape so per-command
514    // pages match the escaping policy already used by
515    // build_program_man_page (review #20).
516    let prog = roff_escape(prog_name);
517    let cmd_name_esc = roff_escape(command_name);
518    let title = format!("{}-{}", prog_name, command_name).to_uppercase();
519    let pkg_label = format!("{prog} {}", roff_escape(version));
520    let manual_label = format!("{prog} Manual");
521
522    let mut sections: Vec<String> = Vec::new();
523
524    // .TH
525    sections.push(format!(
526        ".TH \"{title}\" \"1\" \"{today}\" \"{pkg_label}\" \"{manual_label}\""
527    ));
528
529    // .SH NAME
530    sections.push(".SH NAME".to_string());
531    let desc = cmd
532        .and_then(|c| c.get_about())
533        .map(|s| s.to_string())
534        .unwrap_or_else(|| command_name.to_string());
535    let name_desc = desc.lines().next().unwrap_or("").trim_end_matches('.');
536    sections.push(format!(
537        "{prog}-{cmd_name_esc} \\- {}",
538        roff_escape(name_desc)
539    ));
540
541    // .SH SYNOPSIS
542    sections.push(".SH SYNOPSIS".to_string());
543    sections.push(build_synopsis(cmd, prog_name, command_name));
544
545    // .SH DESCRIPTION (using about text)
546    if let Some(about) = cmd.and_then(|c| c.get_about()) {
547        sections.push(".SH DESCRIPTION".to_string());
548        sections.push(roff_escape_block(&about.to_string()));
549    } else {
550        // Emit a stub DESCRIPTION section so it's always present
551        sections.push(".SH DESCRIPTION".to_string());
552        sections.push(format!("{prog}\\-{cmd_name_esc}"));
553    }
554
555    // .SH OPTIONS (only if command has named options)
556    if let Some(c) = cmd {
557        let options: Vec<_> = c
558            .get_arguments()
559            .filter(|a| a.get_long().is_some() || a.get_short().is_some())
560            .filter(|a| a.get_id().as_str() != "help" && a.get_id().as_str() != "version")
561            .collect();
562
563        if !options.is_empty() {
564            sections.push(".SH OPTIONS".to_string());
565            for arg in options {
566                let flag_parts: Vec<String> = {
567                    let mut fp = Vec::new();
568                    if let Some(short) = arg.get_short() {
569                        fp.push(format!("\\-{short}"));
570                    }
571                    if let Some(long) = arg.get_long() {
572                        fp.push(format!("\\-\\-{long}"));
573                    }
574                    fp
575                };
576                let flag_str = flag_parts.join(", ");
577
578                let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
579                sections.push(".TP".to_string());
580                if is_flag {
581                    sections.push(format!("\\fB{flag_str}\\fR"));
582                } else {
583                    let type_name_owned: String = arg
584                        .get_value_names()
585                        .and_then(|v| v.first().map(|s| s.to_string()))
586                        .unwrap_or_else(|| "VALUE".to_string());
587                    let type_name = type_name_owned.as_str();
588                    sections.push(format!("\\fB{flag_str}\\fR \\fI{type_name}\\fR"));
589                }
590                if let Some(help) = arg.get_help() {
591                    sections.push(roff_escape_block(&help.to_string()));
592                }
593                if let Some(default) = arg.get_default_values().first() {
594                    if !is_flag {
595                        sections.push(format!("Default: {}.", default.to_string_lossy()));
596                    }
597                }
598            }
599        }
600    }
601
602    // .SH ENVIRONMENT (static)
603    sections.push(".SH ENVIRONMENT".to_string());
604    for (name, desc) in ENV_ENTRIES {
605        sections.push(".TP".to_string());
606        sections.push(format!("\\fB{name}\\fR"));
607        sections.push(desc.to_string());
608    }
609
610    // .SH EXIT CODES (static — full table from spec)
611    sections.push(".SH EXIT CODES".to_string());
612    for (code, meaning) in EXIT_CODES {
613        sections.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
614    }
615
616    // .SH SEE ALSO
617    sections.push(".SH SEE ALSO".to_string());
618    let see_also = [
619        format!("\\fB{prog}\\fR(1)"),
620        format!("\\fB{prog}\\-list\\fR(1)"),
621        format!("\\fB{prog}\\-describe\\fR(1)"),
622        format!("\\fB{prog}\\-completion\\fR(1)"),
623    ];
624    sections.push(see_also.join(", "));
625
626    sections.join("\n")
627}
628
629/// Static environment variable entries for the ENVIRONMENT section.
630pub const ENV_ENTRIES: &[(&str, &str)] = &[
631    (
632        "APCORE_EXTENSIONS_ROOT",
633        "Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.",
634    ),
635    (
636        "APCORE_CLI_AUTO_APPROVE",
637        "Set to \\fB1\\fR to bypass approval prompts for modules that require human-in-the-loop confirmation.",
638    ),
639    (
640        "APCORE_CLI_LOGGING_LEVEL",
641        "CLI-specific logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
642         Takes priority over \\fBAPCORE_LOGGING_LEVEL\\fR. Default: WARNING.",
643    ),
644    (
645        "APCORE_LOGGING_LEVEL",
646        "Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
647         Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING.",
648    ),
649    (
650        "APCORE_AUTH_API_KEY",
651        "API key for authenticating with the apcore registry.",
652    ),
653];
654
655/// Static exit code entries for the EXIT CODES section.
656pub const EXIT_CODES: &[(&str, &str)] = &[
657    ("0", "Success."),
658    ("1", "Module execution error."),
659    ("2", "Invalid CLI input or missing argument."),
660    ("44", "Module not found, disabled, or failed to load."),
661    ("45", "Input failed JSON Schema validation."),
662    (
663        "46",
664        "Approval denied, timed out, or no interactive terminal available.",
665    ),
666    (
667        "47",
668        "Configuration error (extensions directory not found or unreadable).",
669    ),
670    ("48", "Schema contains a circular \\fB$ref\\fR."),
671    (
672        "77",
673        "ACL denied \\- insufficient permissions for this module.",
674    ),
675    ("130", "Execution cancelled by user (SIGINT / Ctrl\\-C)."),
676];
677
678/// Handler: look up a subcommand and return its roff man page.
679///
680/// Returns `Err(ShellError::UnknownCommand)` if `command_name` is not found
681/// among `root_cmd`'s subcommands and is not in `KNOWN_BUILTINS`.
682pub fn cmd_man(
683    command_name: &str,
684    root_cmd: &clap::Command,
685    prog_name: &str,
686    version: &str,
687) -> Result<String, ShellError> {
688    // Try live subcommand tree first
689    let cmd_opt = root_cmd
690        .get_subcommands()
691        .find(|c| c.get_name() == command_name);
692
693    // Fall back to known built-ins (commands that may not be wired yet)
694    if cmd_opt.is_none() && !KNOWN_BUILTINS.contains(&command_name) {
695        return Err(ShellError::UnknownCommand(command_name.to_string()));
696    }
697
698    Ok(generate_man_page(command_name, cmd_opt, prog_name, version))
699}
700
701// ---------------------------------------------------------------------------
702// Full program man page generation
703// ---------------------------------------------------------------------------
704
705/// Escape a string for roff output (single-line / inline context).
706fn roff_escape(s: &str) -> String {
707    s.replace('\\', "\\\\")
708        .replace('-', "\\-")
709        .replace('\'', "\\(aq")
710}
711
712/// Escape multi-line text for roff.
713///
714/// Applies `roff_escape` for character-level escaping, then prefixes any
715/// line that starts with `.` with the zero-width glyph `\&` to prevent
716/// groff/mandoc from treating the leading period as a control-request
717/// initiator.  Use this variant for module about/help strings that may
718/// contain prose spanning multiple lines.
719fn roff_escape_block(s: &str) -> String {
720    roff_escape(s)
721        .lines()
722        .map(|line| {
723            if line.starts_with('.') {
724                format!("\\&{line}")
725            } else {
726                line.to_string()
727            }
728        })
729        .collect::<Vec<_>>()
730        .join("\n")
731}
732
733/// Pre-parse `--man` from raw argv. Returns true if present.
734pub fn has_man_flag(args: &[String]) -> bool {
735    args.iter().any(|a| a == "--man")
736}
737
738/// Build a complete roff man page for the entire CLI program.
739///
740/// Covers all registered commands including downstream business
741/// commands. The `cmd` should be the fully-built root
742/// `clap::Command`.
743pub fn build_program_man_page(
744    cmd: &clap::Command,
745    prog_name: &str,
746    version: &str,
747    description: Option<&str>,
748    docs_url: Option<&str>,
749) -> String {
750    let desc = description
751        .map(|s| s.to_string())
752        .or_else(|| cmd.get_about().map(|s| s.to_string()))
753        .unwrap_or_else(|| "CLI".to_string());
754    let upper = prog_name.to_uppercase();
755    let mut s = Vec::new();
756
757    s.push(format!(
758        ".TH \"{upper}\" \"1\" \"\" \
759         \"{prog_name} {version}\" \"{prog_name} Manual\""
760    ));
761
762    s.push(".SH NAME".to_string());
763    s.push(format!("{prog_name} \\- {}", roff_escape(&desc)));
764
765    s.push(".SH SYNOPSIS".to_string());
766    s.push(format!(
767        "\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \
768         \\fIcommand\\fR [\\fIcommand\\-options\\fR]"
769    ));
770
771    s.push(".SH DESCRIPTION".to_string());
772    s.push(roff_escape_block(&desc));
773
774    // Global options
775    let global_args: Vec<_> = cmd
776        .get_arguments()
777        .filter(|a| !a.is_hide_set() && !matches!(a.get_id().as_str(), "help" | "version" | "man"))
778        .collect();
779    if !global_args.is_empty() {
780        s.push(".SH GLOBAL OPTIONS".to_string());
781        for arg in &global_args {
782            if let Some(long) = arg.get_long() {
783                s.push(".TP".to_string());
784                s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
785                if let Some(help) = arg.get_help() {
786                    s.push(roff_escape_block(&help.to_string()));
787                }
788            }
789        }
790    }
791
792    // Commands
793    let subcmds: Vec<_> = cmd
794        .get_subcommands()
795        .filter(|c| c.get_name() != "help")
796        .collect();
797    if !subcmds.is_empty() {
798        s.push(".SH COMMANDS".to_string());
799        for sub in &subcmds {
800            let name = sub.get_name();
801            let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
802            s.push(".TP".to_string());
803            s.push(format!("\\fB{prog_name} {}\\fR", roff_escape(name)));
804            if !about.is_empty() {
805                s.push(roff_escape_block(&about));
806            }
807
808            // Command args
809            for arg in sub.get_arguments() {
810                if arg.is_hide_set() || arg.get_id().as_str() == "help" {
811                    continue;
812                }
813                if let Some(long) = arg.get_long() {
814                    s.push(".RS".to_string());
815                    s.push(".TP".to_string());
816                    s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
817                    if let Some(help) = arg.get_help() {
818                        s.push(roff_escape_block(&help.to_string()));
819                    }
820                    s.push(".RE".to_string());
821                }
822            }
823
824            // Nested subcommands
825            for nested in sub.get_subcommands() {
826                if nested.get_name() == "help" {
827                    continue;
828                }
829                let nested_about = nested
830                    .get_about()
831                    .map(|a| a.to_string())
832                    .unwrap_or_default();
833                s.push(".TP".to_string());
834                s.push(format!(
835                    "\\fB{prog_name} {} {}\\fR",
836                    roff_escape(name),
837                    roff_escape(nested.get_name())
838                ));
839                if !nested_about.is_empty() {
840                    s.push(roff_escape_block(&nested_about));
841                }
842                for arg in nested.get_arguments() {
843                    if arg.is_hide_set() || arg.get_id().as_str() == "help" {
844                        continue;
845                    }
846                    if let Some(long) = arg.get_long() {
847                        s.push(".RS".to_string());
848                        s.push(".TP".to_string());
849                        s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
850                        if let Some(help) = arg.get_help() {
851                            s.push(roff_escape_block(&help.to_string()));
852                        }
853                        s.push(".RE".to_string());
854                    }
855                }
856            }
857        }
858    }
859
860    // Environment
861    s.push(".SH ENVIRONMENT".to_string());
862    for (name, env_desc) in ENV_ENTRIES {
863        s.push(".TP".to_string());
864        s.push(format!("\\fB{name}\\fR"));
865        s.push(env_desc.to_string());
866    }
867
868    // Exit codes
869    s.push(".SH EXIT CODES".to_string());
870    for (code, meaning) in EXIT_CODES {
871        s.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
872    }
873
874    s.push(".SH SEE ALSO".to_string());
875    s.push(format!(
876        "\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR \
877         for full option list."
878    ));
879    if let Some(url) = docs_url {
880        s.push(format!(
881            ".PP\nFull documentation at \\fI{}\\fR",
882            roff_escape(url)
883        ));
884    }
885
886    s.join("\n")
887}
888
889/// Format Unix epoch days as YYYY-MM-DD without external crates.
890fn format_roff_date(days_since_epoch: u64) -> String {
891    let mut remaining = days_since_epoch;
892    let mut year = 1970u32;
893    loop {
894        let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
895        let days_in_year = if leap { 366 } else { 365 };
896        if remaining < days_in_year {
897            break;
898        }
899        remaining -= days_in_year;
900        year += 1;
901    }
902    let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
903    let month_days = [
904        31u64,
905        if leap { 29 } else { 28 },
906        31,
907        30,
908        31,
909        30,
910        31,
911        31,
912        30,
913        31,
914        30,
915        31,
916    ];
917    let mut month = 1u32;
918    for &d in &month_days {
919        if remaining < d {
920            break;
921        }
922        remaining -= d;
923        month += 1;
924    }
925    let day = remaining + 1;
926    format!("{year:04}-{month:02}-{day:02}")
927}
928
929// ---------------------------------------------------------------------------
930// Unit tests
931// ---------------------------------------------------------------------------
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    // --- Task 1: ShellError and KNOWN_BUILTINS ---
938
939    #[test]
940    fn test_shell_error_unknown_command_message() {
941        let err = ShellError::UnknownCommand("bogus".to_string());
942        assert_eq!(err.to_string(), "unknown command 'bogus'");
943    }
944
945    #[test]
946    fn test_known_builtins_contains_required_commands() {
947        for cmd in &["exec", "list", "describe", "completion", "init", "man"] {
948            assert!(
949                KNOWN_BUILTINS.contains(cmd),
950                "KNOWN_BUILTINS must contain '{cmd}'"
951            );
952        }
953    }
954
955    #[test]
956    fn test_known_builtins_has_expected_count() {
957        assert_eq!(KNOWN_BUILTINS.len(), 14);
958    }
959
960    #[test]
961    fn test_known_builtin_descriptions_covers_all_builtins() {
962        // Single source of truth — every name in KNOWN_BUILTINS must have a
963        // matching entry in KNOWN_BUILTIN_DESCRIPTIONS so the bash/zsh/fish
964        // generators can render an accurate completion script (review #3).
965        for name in KNOWN_BUILTINS {
966            assert!(
967                KNOWN_BUILTIN_DESCRIPTIONS.iter().any(|(n, _)| n == name),
968                "KNOWN_BUILTIN_DESCRIPTIONS missing entry for '{name}'"
969            );
970        }
971        assert_eq!(
972            KNOWN_BUILTIN_DESCRIPTIONS.len(),
973            KNOWN_BUILTINS.len(),
974            "tables must stay the same length"
975        );
976    }
977
978    #[test]
979    fn test_bash_completion_lists_every_known_builtin() {
980        // Regression for review #3: the previous hardcoded "completion
981        // describe exec init list man" string only covered 6 of 14
982        // builtins. The generator must now surface every entry so users
983        // can tab-complete config / health / enable / disable / reload /
984        // usage / validate / describe-pipeline.
985        let script = generate_grouped_bash_completion("apcli");
986        for name in KNOWN_BUILTINS {
987            assert!(
988                script.contains(name),
989                "bash completion script missing builtin '{name}': {script}"
990            );
991        }
992    }
993
994    #[test]
995    fn test_zsh_completion_lists_every_known_builtin() {
996        let script = generate_grouped_zsh_completion("apcli");
997        for name in KNOWN_BUILTINS {
998            assert!(
999                script.contains(name),
1000                "zsh completion script missing builtin '{name}': {script}"
1001            );
1002        }
1003    }
1004
1005    #[test]
1006    fn test_fish_completion_lists_every_known_builtin() {
1007        let script = generate_grouped_fish_completion("apcli");
1008        for name in KNOWN_BUILTINS {
1009            assert!(
1010                script.contains(name),
1011                "fish completion script missing builtin '{name}': {script}"
1012            );
1013        }
1014    }
1015
1016    // --- Task 2: completion_command / cmd_completion ---
1017
1018    fn make_test_cmd(prog: &str) -> clap::Command {
1019        clap::Command::new(prog.to_string())
1020            .about("test")
1021            .subcommand(clap::Command::new("exec"))
1022            .subcommand(clap::Command::new("list"))
1023    }
1024
1025    #[test]
1026    fn test_cmd_completion_bash_nonempty() {
1027        let mut cmd = make_test_cmd("apcore-cli");
1028        let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
1029        assert!(
1030            !output.is_empty(),
1031            "bash completion output must not be empty"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_cmd_completion_zsh_nonempty() {
1037        let mut cmd = make_test_cmd("apcore-cli");
1038        let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
1039        assert!(
1040            !output.is_empty(),
1041            "zsh completion output must not be empty"
1042        );
1043    }
1044
1045    #[test]
1046    fn test_cmd_completion_fish_nonempty() {
1047        let mut cmd = make_test_cmd("apcore-cli");
1048        let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
1049        assert!(
1050            !output.is_empty(),
1051            "fish completion output must not be empty"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_cmd_completion_elvish_nonempty() {
1057        let mut cmd = make_test_cmd("apcore-cli");
1058        let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
1059        assert!(
1060            !output.is_empty(),
1061            "elvish completion output must not be empty"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_cmd_completion_bash_contains_prog_name() {
1067        let mut cmd = make_test_cmd("my-tool");
1068        let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
1069        assert!(
1070            output.contains("my-tool") || output.contains("my_tool"),
1071            "bash completion must reference the program name"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_completion_command_has_shell_arg() {
1077        let cmd = completion_command();
1078        let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
1079        assert!(
1080            arg.is_some(),
1081            "completion_command must have a 'shell' argument"
1082        );
1083    }
1084
1085    #[test]
1086    fn test_completion_command_name() {
1087        let cmd = completion_command();
1088        assert_eq!(cmd.get_name(), "completion");
1089    }
1090
1091    // --- Task 3: build_synopsis / generate_man_page / cmd_man ---
1092
1093    fn make_exec_cmd() -> clap::Command {
1094        clap::Command::new("exec")
1095            .about("Execute an apcore module")
1096            .arg(
1097                clap::Arg::new("module_id")
1098                    .value_name("MODULE_ID")
1099                    .required(true)
1100                    .help("Module ID to execute"),
1101            )
1102            .arg(
1103                clap::Arg::new("format")
1104                    .long("format")
1105                    .value_name("FORMAT")
1106                    .help("Output format")
1107                    .default_value("table"),
1108            )
1109    }
1110
1111    #[test]
1112    fn test_build_synopsis_no_cmd() {
1113        let synopsis = build_synopsis(None, "apcore-cli", "exec");
1114        // prog_name is now roff-escaped (review #20) so the literal hyphen
1115        // becomes "\-" — match the escaped form.
1116        assert!(synopsis.contains("apcore\\-cli"));
1117        assert!(synopsis.contains("exec"));
1118    }
1119
1120    #[test]
1121    fn test_build_synopsis_required_positional_no_brackets() {
1122        let cmd = make_exec_cmd();
1123        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
1124        assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
1125        assert!(
1126            !synopsis.contains("[\\fIMODULE_ID\\fR]"),
1127            "required arg must not have brackets"
1128        );
1129    }
1130
1131    #[test]
1132    fn test_build_synopsis_optional_option_has_brackets() {
1133        let cmd = make_exec_cmd();
1134        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
1135        assert!(
1136            synopsis.contains('['),
1137            "optional option must be wrapped in brackets"
1138        );
1139    }
1140
1141    #[test]
1142    fn test_generate_man_page_contains_th() {
1143        let cmd = make_exec_cmd();
1144        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1145        assert!(page.contains(".TH"), "man page must have .TH header");
1146    }
1147
1148    #[test]
1149    fn test_generate_man_page_contains_sh_name() {
1150        let cmd = make_exec_cmd();
1151        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1152        assert!(page.contains(".SH NAME"), "man page must have NAME section");
1153    }
1154
1155    #[test]
1156    fn test_generate_man_page_contains_sh_synopsis() {
1157        let cmd = make_exec_cmd();
1158        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1159        assert!(
1160            page.contains(".SH SYNOPSIS"),
1161            "man page must have SYNOPSIS section"
1162        );
1163    }
1164
1165    #[test]
1166    fn test_generate_man_page_contains_exit_codes() {
1167        let cmd = make_exec_cmd();
1168        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1169        assert!(
1170            page.contains(".SH EXIT CODES"),
1171            "man page must have EXIT CODES section"
1172        );
1173        assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
1174        assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
1175        assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
1176    }
1177
1178    #[test]
1179    fn test_generate_man_page_contains_environment() {
1180        let cmd = make_exec_cmd();
1181        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1182        assert!(
1183            page.contains(".SH ENVIRONMENT"),
1184            "man page must have ENVIRONMENT section"
1185        );
1186        assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
1187        assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
1188    }
1189
1190    #[test]
1191    fn test_generate_man_page_contains_see_also() {
1192        let cmd = make_exec_cmd();
1193        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1194        assert!(
1195            page.contains(".SH SEE ALSO"),
1196            "man page must have SEE ALSO section"
1197        );
1198        // prog_name is roff-escaped (review #20) so hyphens become "\-".
1199        assert!(page.contains("apcore\\-cli"));
1200    }
1201
1202    #[test]
1203    fn test_generate_man_page_th_includes_prog_and_version() {
1204        let cmd = make_exec_cmd();
1205        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1206        let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
1207        assert!(
1208            th_line.contains("APCORE-CLI-EXEC"),
1209            "TH must contain uppercased title"
1210        );
1211        assert!(th_line.contains("0.2.0"), "TH must contain version");
1212    }
1213
1214    #[test]
1215    fn test_generate_man_page_name_uses_description() {
1216        let cmd = make_exec_cmd();
1217        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1218        assert!(
1219            page.contains("Execute an apcore module"),
1220            "NAME must use about text"
1221        );
1222    }
1223
1224    #[test]
1225    fn test_generate_man_page_no_description_section_when_no_long_help() {
1226        let cmd = make_exec_cmd();
1227        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1228        assert!(page.contains(".SH DESCRIPTION"));
1229    }
1230
1231    #[test]
1232    fn test_cmd_man_known_builtin_returns_ok() {
1233        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1234        let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
1235        assert!(result.is_ok(), "known builtin 'list' must return Ok");
1236    }
1237
1238    #[test]
1239    fn test_cmd_man_registered_subcommand_returns_ok() {
1240        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1241        let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
1242        assert!(
1243            result.is_ok(),
1244            "registered subcommand 'exec' must return Ok"
1245        );
1246        let page = result.unwrap();
1247        assert!(page.contains(".TH"));
1248    }
1249
1250    #[test]
1251    fn test_cmd_man_unknown_command_returns_err() {
1252        let root = clap::Command::new("apcore-cli");
1253        let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
1254        assert!(result.is_err());
1255        match result.unwrap_err() {
1256            ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
1257        }
1258    }
1259
1260    #[test]
1261    fn test_cmd_man_exec_contains_options_section() {
1262        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1263        let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
1264        assert!(
1265            page.contains(".SH OPTIONS"),
1266            "exec man page must have OPTIONS section"
1267        );
1268    }
1269
1270    // --- roff_escape ---
1271
1272    #[test]
1273    fn test_roff_escape_backslash() {
1274        assert_eq!(roff_escape("a\\b"), "a\\\\b");
1275    }
1276
1277    #[test]
1278    fn test_roff_escape_hyphen() {
1279        assert_eq!(roff_escape("foo-bar"), "foo\\-bar");
1280    }
1281
1282    #[test]
1283    fn test_roff_escape_single_quote() {
1284        assert_eq!(roff_escape("it's"), "it\\(aqs");
1285    }
1286
1287    #[test]
1288    fn test_roff_escape_block_leading_dot_defused() {
1289        let text = "first line\n.NET Framework\nend";
1290        let escaped = roff_escape_block(text);
1291        assert!(
1292            escaped.contains("\\&.NET"),
1293            "leading dot must be prefixed with \\& to prevent roff control: {escaped:?}"
1294        );
1295    }
1296
1297    #[test]
1298    fn test_roff_escape_block_no_leading_dot_unchanged() {
1299        let text = "normal line\nalso normal";
1300        let escaped = roff_escape_block(text);
1301        assert_eq!(escaped, "normal line\nalso normal");
1302    }
1303
1304    // --- has_man_flag ---
1305
1306    #[test]
1307    fn test_has_man_flag_present() {
1308        let args = vec!["--man".to_string()];
1309        assert!(has_man_flag(&args));
1310    }
1311
1312    #[test]
1313    fn test_has_man_flag_absent() {
1314        let args = vec!["--help".to_string()];
1315        assert!(!has_man_flag(&args));
1316    }
1317
1318    // --- build_program_man_page ---
1319
1320    #[test]
1321    fn test_build_program_man_page_basic() {
1322        let cmd = clap::Command::new("t")
1323            .about("Test")
1324            .subcommand(clap::Command::new("sub").about("A sub"));
1325        let roff = build_program_man_page(&cmd, "t", "0.1.0", None, None);
1326        assert!(roff.contains(".TH \"T\""));
1327        assert!(roff.contains(".SH COMMANDS"));
1328        assert!(roff.contains("sub"));
1329        assert!(roff.contains(".SH EXIT CODES"));
1330    }
1331
1332    #[test]
1333    fn test_build_program_man_page_custom_description() {
1334        let cmd = clap::Command::new("t").about("Default");
1335        let roff = build_program_man_page(&cmd, "t", "0.1.0", Some("Custom"), None);
1336        assert!(roff.contains("Custom"));
1337    }
1338
1339    #[test]
1340    fn test_completion_bash_outputs_script() {
1341        let cmd = completion_command();
1342        let positionals: Vec<&str> = cmd
1343            .get_positionals()
1344            .filter_map(|a| a.get_id().as_str().into())
1345            .collect();
1346        // The arg is named "shell" with value_name "SHELL"
1347        assert!(
1348            !positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
1349            "completion must have shell arg, got {positionals:?}"
1350        );
1351    }
1352
1353    #[test]
1354    fn test_completion_zsh_outputs_script() {
1355        let cmd = completion_command();
1356        let shell_arg = cmd
1357            .get_arguments()
1358            .find(|a| a.get_id() == "shell")
1359            .expect("shell argument must exist");
1360        let possible = shell_arg.get_possible_values();
1361        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1362        assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
1363    }
1364
1365    #[test]
1366    fn test_completion_invalid_shell_exits_nonzero() {
1367        let cmd = completion_command();
1368        let shell_arg = cmd
1369            .get_arguments()
1370            .find(|a| a.get_id() == "shell")
1371            .expect("shell argument must exist");
1372        let possible = shell_arg.get_possible_values();
1373        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1374        assert!(
1375            !values.contains(&"invalid_shell"),
1376            "invalid_shell must not be accepted"
1377        );
1378    }
1379
1380    // --- Per-subcommand registrars (FE-13) ---
1381
1382    #[test]
1383    fn test_register_completion_command_attaches_completion() {
1384        let root = register_completion_command(Command::new("root"), "apcore-cli");
1385        let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
1386        assert!(
1387            names.contains(&"completion"),
1388            "must have 'completion' subcommand, got {names:?}"
1389        );
1390        assert!(
1391            !names.contains(&"man"),
1392            "man must be absent when only completion registrar called"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_register_man_command_attaches_man() {
1398        let root = register_man_command(Command::new("root"));
1399        let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
1400        assert!(
1401            names.contains(&"man"),
1402            "must have 'man' subcommand, got {names:?}"
1403        );
1404        assert!(
1405            !names.contains(&"completion"),
1406            "completion must be absent when only man registrar called"
1407        );
1408    }
1409}