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