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/// Format Unix epoch days as YYYY-MM-DD without external crates.
641fn format_roff_date(days_since_epoch: u64) -> String {
642    let mut remaining = days_since_epoch;
643    let mut year = 1970u32;
644    loop {
645        let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
646        let days_in_year = if leap { 366 } else { 365 };
647        if remaining < days_in_year {
648            break;
649        }
650        remaining -= days_in_year;
651        year += 1;
652    }
653    let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
654    let month_days = [
655        31u64,
656        if leap { 29 } else { 28 },
657        31,
658        30,
659        31,
660        30,
661        31,
662        31,
663        30,
664        31,
665        30,
666        31,
667    ];
668    let mut month = 1u32;
669    for &d in &month_days {
670        if remaining < d {
671            break;
672        }
673        remaining -= d;
674        month += 1;
675    }
676    let day = remaining + 1;
677    format!("{year:04}-{month:02}-{day:02}")
678}
679
680// ---------------------------------------------------------------------------
681// Unit tests
682// ---------------------------------------------------------------------------
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    // --- Task 1: ShellError and KNOWN_BUILTINS ---
689
690    #[test]
691    fn test_shell_error_unknown_command_message() {
692        let err = ShellError::UnknownCommand("bogus".to_string());
693        assert_eq!(err.to_string(), "unknown command 'bogus'");
694    }
695
696    #[test]
697    fn test_known_builtins_contains_required_commands() {
698        for cmd in &["exec", "list", "describe", "completion", "init", "man"] {
699            assert!(
700                KNOWN_BUILTINS.contains(cmd),
701                "KNOWN_BUILTINS must contain '{cmd}'"
702            );
703        }
704    }
705
706    #[test]
707    fn test_known_builtins_has_expected_count() {
708        assert_eq!(KNOWN_BUILTINS.len(), 6);
709    }
710
711    // --- Task 2: completion_command / cmd_completion ---
712
713    fn make_test_cmd(prog: &str) -> clap::Command {
714        clap::Command::new(prog.to_string())
715            .about("test")
716            .subcommand(clap::Command::new("exec"))
717            .subcommand(clap::Command::new("list"))
718    }
719
720    #[test]
721    fn test_cmd_completion_bash_nonempty() {
722        let mut cmd = make_test_cmd("apcore-cli");
723        let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
724        assert!(
725            !output.is_empty(),
726            "bash completion output must not be empty"
727        );
728    }
729
730    #[test]
731    fn test_cmd_completion_zsh_nonempty() {
732        let mut cmd = make_test_cmd("apcore-cli");
733        let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
734        assert!(
735            !output.is_empty(),
736            "zsh completion output must not be empty"
737        );
738    }
739
740    #[test]
741    fn test_cmd_completion_fish_nonempty() {
742        let mut cmd = make_test_cmd("apcore-cli");
743        let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
744        assert!(
745            !output.is_empty(),
746            "fish completion output must not be empty"
747        );
748    }
749
750    #[test]
751    fn test_cmd_completion_elvish_nonempty() {
752        let mut cmd = make_test_cmd("apcore-cli");
753        let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
754        assert!(
755            !output.is_empty(),
756            "elvish completion output must not be empty"
757        );
758    }
759
760    #[test]
761    fn test_cmd_completion_bash_contains_prog_name() {
762        let mut cmd = make_test_cmd("my-tool");
763        let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
764        assert!(
765            output.contains("my-tool") || output.contains("my_tool"),
766            "bash completion must reference the program name"
767        );
768    }
769
770    #[test]
771    fn test_completion_command_has_shell_arg() {
772        let cmd = completion_command();
773        let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
774        assert!(
775            arg.is_some(),
776            "completion_command must have a 'shell' argument"
777        );
778    }
779
780    #[test]
781    fn test_completion_command_name() {
782        let cmd = completion_command();
783        assert_eq!(cmd.get_name(), "completion");
784    }
785
786    // --- Task 3: build_synopsis / generate_man_page / cmd_man ---
787
788    fn make_exec_cmd() -> clap::Command {
789        clap::Command::new("exec")
790            .about("Execute an apcore module")
791            .arg(
792                clap::Arg::new("module_id")
793                    .value_name("MODULE_ID")
794                    .required(true)
795                    .help("Module ID to execute"),
796            )
797            .arg(
798                clap::Arg::new("format")
799                    .long("format")
800                    .value_name("FORMAT")
801                    .help("Output format")
802                    .default_value("table"),
803            )
804    }
805
806    #[test]
807    fn test_build_synopsis_no_cmd() {
808        let synopsis = build_synopsis(None, "apcore-cli", "exec");
809        assert!(synopsis.contains("apcore-cli"));
810        assert!(synopsis.contains("exec"));
811    }
812
813    #[test]
814    fn test_build_synopsis_required_positional_no_brackets() {
815        let cmd = make_exec_cmd();
816        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
817        assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
818        assert!(
819            !synopsis.contains("[\\fIMODULE_ID\\fR]"),
820            "required arg must not have brackets"
821        );
822    }
823
824    #[test]
825    fn test_build_synopsis_optional_option_has_brackets() {
826        let cmd = make_exec_cmd();
827        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
828        assert!(
829            synopsis.contains('['),
830            "optional option must be wrapped in brackets"
831        );
832    }
833
834    #[test]
835    fn test_generate_man_page_contains_th() {
836        let cmd = make_exec_cmd();
837        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
838        assert!(page.contains(".TH"), "man page must have .TH header");
839    }
840
841    #[test]
842    fn test_generate_man_page_contains_sh_name() {
843        let cmd = make_exec_cmd();
844        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
845        assert!(page.contains(".SH NAME"), "man page must have NAME section");
846    }
847
848    #[test]
849    fn test_generate_man_page_contains_sh_synopsis() {
850        let cmd = make_exec_cmd();
851        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
852        assert!(
853            page.contains(".SH SYNOPSIS"),
854            "man page must have SYNOPSIS section"
855        );
856    }
857
858    #[test]
859    fn test_generate_man_page_contains_exit_codes() {
860        let cmd = make_exec_cmd();
861        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
862        assert!(
863            page.contains(".SH EXIT CODES"),
864            "man page must have EXIT CODES section"
865        );
866        assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
867        assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
868        assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
869    }
870
871    #[test]
872    fn test_generate_man_page_contains_environment() {
873        let cmd = make_exec_cmd();
874        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
875        assert!(
876            page.contains(".SH ENVIRONMENT"),
877            "man page must have ENVIRONMENT section"
878        );
879        assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
880        assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
881    }
882
883    #[test]
884    fn test_generate_man_page_contains_see_also() {
885        let cmd = make_exec_cmd();
886        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
887        assert!(
888            page.contains(".SH SEE ALSO"),
889            "man page must have SEE ALSO section"
890        );
891        assert!(page.contains("apcore-cli"));
892    }
893
894    #[test]
895    fn test_generate_man_page_th_includes_prog_and_version() {
896        let cmd = make_exec_cmd();
897        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
898        let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
899        assert!(
900            th_line.contains("APCORE-CLI-EXEC"),
901            "TH must contain uppercased title"
902        );
903        assert!(th_line.contains("0.2.0"), "TH must contain version");
904    }
905
906    #[test]
907    fn test_generate_man_page_name_uses_description() {
908        let cmd = make_exec_cmd();
909        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
910        assert!(
911            page.contains("Execute an apcore module"),
912            "NAME must use about text"
913        );
914    }
915
916    #[test]
917    fn test_generate_man_page_no_description_section_when_no_long_help() {
918        let cmd = make_exec_cmd();
919        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
920        assert!(page.contains(".SH DESCRIPTION"));
921    }
922
923    #[test]
924    fn test_cmd_man_known_builtin_returns_ok() {
925        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
926        let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
927        assert!(result.is_ok(), "known builtin 'list' must return Ok");
928    }
929
930    #[test]
931    fn test_cmd_man_registered_subcommand_returns_ok() {
932        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
933        let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
934        assert!(
935            result.is_ok(),
936            "registered subcommand 'exec' must return Ok"
937        );
938        let page = result.unwrap();
939        assert!(page.contains(".TH"));
940    }
941
942    #[test]
943    fn test_cmd_man_unknown_command_returns_err() {
944        let root = clap::Command::new("apcore-cli");
945        let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
946        assert!(result.is_err());
947        match result.unwrap_err() {
948            ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
949        }
950    }
951
952    #[test]
953    fn test_cmd_man_exec_contains_options_section() {
954        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
955        let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
956        assert!(
957            page.contains(".SH OPTIONS"),
958            "exec man page must have OPTIONS section"
959        );
960    }
961
962    // --- Task 4: register_shell_commands ---
963
964    #[test]
965    fn test_register_shell_commands_adds_completion() {
966        let root = Command::new("apcore-cli");
967        let cmd = register_shell_commands(root, "apcore-cli");
968        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
969        assert!(
970            names.contains(&"completion"),
971            "must have 'completion' subcommand, got {names:?}"
972        );
973    }
974
975    #[test]
976    fn test_register_shell_commands_adds_man() {
977        let root = Command::new("apcore-cli");
978        let cmd = register_shell_commands(root, "apcore-cli");
979        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
980        assert!(
981            names.contains(&"man"),
982            "must have 'man' subcommand, got {names:?}"
983        );
984    }
985
986    #[test]
987    fn test_completion_bash_outputs_script() {
988        let cmd = completion_command();
989        let positionals: Vec<&str> = cmd
990            .get_positionals()
991            .filter_map(|a| a.get_id().as_str().into())
992            .collect();
993        // The arg is named "shell" with value_name "SHELL"
994        assert!(
995            !positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
996            "completion must have shell arg, got {positionals:?}"
997        );
998    }
999
1000    #[test]
1001    fn test_completion_zsh_outputs_script() {
1002        let cmd = completion_command();
1003        let shell_arg = cmd
1004            .get_arguments()
1005            .find(|a| a.get_id() == "shell")
1006            .expect("shell argument must exist");
1007        let possible = shell_arg.get_possible_values();
1008        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1009        assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
1010    }
1011
1012    #[test]
1013    fn test_completion_invalid_shell_exits_nonzero() {
1014        let cmd = completion_command();
1015        let shell_arg = cmd
1016            .get_arguments()
1017            .find(|a| a.get_id() == "shell")
1018            .expect("shell argument must exist");
1019        let possible = shell_arg.get_possible_values();
1020        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1021        assert!(
1022            !values.contains(&"invalid_shell"),
1023            "invalid_shell must not be accepted"
1024        );
1025    }
1026}