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