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", "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/// `shell`     — the target shell (parsed from clap argument)
76/// `prog_name` — the program name to embed in the script
77/// `cmd`       — mutable reference to the root Command (required by clap_complete)
78pub fn cmd_completion(shell: Shell, prog_name: &str, cmd: &mut clap::Command) -> String {
79    let mut buf: Vec<u8> = Vec::new();
80    generate(shell, cmd, prog_name, &mut buf);
81    String::from_utf8_lossy(&buf).into_owned()
82}
83
84// ---------------------------------------------------------------------------
85// man_command / build_synopsis / generate_man_page / cmd_man
86// ---------------------------------------------------------------------------
87
88/// Build the `man` clap subcommand.
89pub fn man_command() -> Command {
90    Command::new("man")
91        .about("Generate a roff man page for COMMAND and print it to stdout")
92        .long_about(
93            "Generate a roff man page for COMMAND and print it to stdout.\n\n\
94             View immediately:\n\
95             \x20 apcore-cli man exec | man -l -\n\
96             \x20 apcore-cli man list | col -bx | less\n\n\
97             Install system-wide:\n\
98             \x20 apcore-cli man exec > /usr/local/share/man/man1/apcore-cli-exec.1\n\
99             \x20 mandb   # (Linux)  or  /usr/libexec/makewhatis  # (macOS)",
100        )
101        .arg(
102            clap::Arg::new("command")
103                .value_name("COMMAND")
104                .required(true)
105                .help("CLI subcommand to generate the man page for"),
106        )
107}
108
109/// Build the roff SYNOPSIS line from a clap Command's arguments.
110pub fn build_synopsis(cmd: Option<&clap::Command>, prog_name: &str, command_name: &str) -> String {
111    let Some(cmd) = cmd else {
112        return format!("\\fB{prog_name} {command_name}\\fR [OPTIONS]");
113    };
114
115    let mut parts = vec![format!("\\fB{prog_name} {command_name}\\fR")];
116
117    for arg in cmd.get_arguments() {
118        // Skip help/version flags injected by clap
119        let id = arg.get_id().as_str();
120        if id == "help" || id == "version" {
121            continue;
122        }
123
124        let is_positional = arg.get_long().is_none() && arg.get_short().is_none();
125        let is_required = arg.is_required_set();
126
127        if is_positional {
128            let meta_owned: String = arg
129                .get_value_names()
130                .and_then(|v| v.first().map(|s| s.to_string()))
131                .unwrap_or_else(|| "ARG".to_string());
132            let meta = meta_owned.as_str();
133            if is_required {
134                parts.push(format!("\\fI{meta}\\fR"));
135            } else {
136                parts.push(format!("[\\fI{meta}\\fR]"));
137            }
138        } else {
139            let flag = if let Some(long) = arg.get_long() {
140                format!("\\-\\-{long}")
141            } else {
142                format!("\\-{}", arg.get_short().unwrap())
143            };
144            let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
145            if is_flag {
146                parts.push(format!("[{flag}]"));
147            } else {
148                let type_name_owned: String = arg
149                    .get_value_names()
150                    .and_then(|v| v.first().map(|s| s.to_string()))
151                    .unwrap_or_else(|| "VALUE".to_string());
152                let type_name = type_name_owned.as_str();
153                if is_required {
154                    parts.push(format!("{flag} \\fI{type_name}\\fR"));
155                } else {
156                    parts.push(format!("[{flag} \\fI{type_name}\\fR]"));
157                }
158            }
159        }
160    }
161
162    parts.join(" ")
163}
164
165/// Build a complete roff man page string for a CLI subcommand.
166pub fn generate_man_page(
167    command_name: &str,
168    cmd: Option<&clap::Command>,
169    prog_name: &str,
170    version: &str,
171) -> String {
172    use std::time::{SystemTime, UNIX_EPOCH};
173
174    let today = {
175        let secs = SystemTime::now()
176            .duration_since(UNIX_EPOCH)
177            .map(|d| d.as_secs())
178            .unwrap_or(0);
179        let days = secs / 86400;
180        format_roff_date(days)
181    };
182
183    let title = format!("{}-{}", prog_name, command_name).to_uppercase();
184    let pkg_label = format!("{prog_name} {version}");
185    let manual_label = format!("{prog_name} Manual");
186
187    let mut sections: Vec<String> = Vec::new();
188
189    // .TH
190    sections.push(format!(
191        ".TH \"{title}\" \"1\" \"{today}\" \"{pkg_label}\" \"{manual_label}\""
192    ));
193
194    // .SH NAME
195    sections.push(".SH NAME".to_string());
196    let desc = cmd
197        .and_then(|c| c.get_about())
198        .map(|s| s.to_string())
199        .unwrap_or_else(|| command_name.to_string());
200    let name_desc = desc.lines().next().unwrap_or("").trim_end_matches('.');
201    sections.push(format!("{prog_name}-{command_name} \\- {name_desc}"));
202
203    // .SH SYNOPSIS
204    sections.push(".SH SYNOPSIS".to_string());
205    sections.push(build_synopsis(cmd, prog_name, command_name));
206
207    // .SH DESCRIPTION (using about text)
208    if let Some(about) = cmd.and_then(|c| c.get_about()) {
209        sections.push(".SH DESCRIPTION".to_string());
210        let escaped = about.to_string().replace('\\', "\\\\").replace('-', "\\-");
211        sections.push(escaped);
212    } else {
213        // Emit a stub DESCRIPTION section so it's always present
214        sections.push(".SH DESCRIPTION".to_string());
215        sections.push(format!("{prog_name}\\-{command_name}"));
216    }
217
218    // .SH OPTIONS (only if command has named options)
219    if let Some(c) = cmd {
220        let options: Vec<_> = c
221            .get_arguments()
222            .filter(|a| a.get_long().is_some() || a.get_short().is_some())
223            .filter(|a| a.get_id().as_str() != "help" && a.get_id().as_str() != "version")
224            .collect();
225
226        if !options.is_empty() {
227            sections.push(".SH OPTIONS".to_string());
228            for arg in options {
229                let flag_parts: Vec<String> = {
230                    let mut fp = Vec::new();
231                    if let Some(short) = arg.get_short() {
232                        fp.push(format!("\\-{short}"));
233                    }
234                    if let Some(long) = arg.get_long() {
235                        fp.push(format!("\\-\\-{long}"));
236                    }
237                    fp
238                };
239                let flag_str = flag_parts.join(", ");
240
241                let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
242                sections.push(".TP".to_string());
243                if is_flag {
244                    sections.push(format!("\\fB{flag_str}\\fR"));
245                } else {
246                    let type_name_owned: String = arg
247                        .get_value_names()
248                        .and_then(|v| v.first().map(|s| s.to_string()))
249                        .unwrap_or_else(|| "VALUE".to_string());
250                    let type_name = type_name_owned.as_str();
251                    sections.push(format!("\\fB{flag_str}\\fR \\fI{type_name}\\fR"));
252                }
253                if let Some(help) = arg.get_help() {
254                    sections.push(help.to_string());
255                }
256                if let Some(default) = arg.get_default_values().first() {
257                    if !is_flag {
258                        sections.push(format!("Default: {}.", default.to_string_lossy()));
259                    }
260                }
261            }
262        }
263    }
264
265    // .SH ENVIRONMENT (static)
266    sections.push(".SH ENVIRONMENT".to_string());
267    for (name, desc) in ENV_ENTRIES {
268        sections.push(".TP".to_string());
269        sections.push(format!("\\fB{name}\\fR"));
270        sections.push(desc.to_string());
271    }
272
273    // .SH EXIT CODES (static — full table from spec)
274    sections.push(".SH EXIT CODES".to_string());
275    for (code, meaning) in EXIT_CODES {
276        sections.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
277    }
278
279    // .SH SEE ALSO
280    sections.push(".SH SEE ALSO".to_string());
281    let see_also = [
282        format!("\\fB{prog_name}\\fR(1)"),
283        format!("\\fB{prog_name}\\-list\\fR(1)"),
284        format!("\\fB{prog_name}\\-describe\\fR(1)"),
285        format!("\\fB{prog_name}\\-completion\\fR(1)"),
286    ];
287    sections.push(see_also.join(", "));
288
289    sections.join("\n")
290}
291
292/// Static environment variable entries for the ENVIRONMENT section.
293pub const ENV_ENTRIES: &[(&str, &str)] = &[
294    (
295        "APCORE_EXTENSIONS_ROOT",
296        "Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.",
297    ),
298    (
299        "APCORE_CLI_AUTO_APPROVE",
300        "Set to \\fB1\\fR to bypass approval prompts for modules that require human-in-the-loop confirmation.",
301    ),
302    (
303        "APCORE_CLI_LOGGING_LEVEL",
304        "CLI-specific logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
305         Takes priority over \\fBAPCORE_LOGGING_LEVEL\\fR. Default: WARNING.",
306    ),
307    (
308        "APCORE_AUTH_API_KEY",
309        "API key for authenticating with the apcore registry.",
310    ),
311];
312
313/// Static exit code entries for the EXIT CODES section.
314pub const EXIT_CODES: &[(&str, &str)] = &[
315    ("0", "Success."),
316    ("1", "Module execution error."),
317    ("2", "Invalid CLI input or missing argument."),
318    ("44", "Module not found, disabled, or failed to load."),
319    ("45", "Input failed JSON Schema validation."),
320    (
321        "46",
322        "Approval denied, timed out, or no interactive terminal available.",
323    ),
324    (
325        "47",
326        "Configuration error (extensions directory not found or unreadable).",
327    ),
328    ("48", "Schema contains a circular \\fB$ref\\fR."),
329    (
330        "77",
331        "ACL denied \\- insufficient permissions for this module.",
332    ),
333    ("130", "Execution cancelled by user (SIGINT / Ctrl\\-C)."),
334];
335
336/// Handler: look up a subcommand and return its roff man page.
337///
338/// Returns `Err(ShellError::UnknownCommand)` if `command_name` is not found
339/// among `root_cmd`'s subcommands and is not in `KNOWN_BUILTINS`.
340pub fn cmd_man(
341    command_name: &str,
342    root_cmd: &clap::Command,
343    prog_name: &str,
344    version: &str,
345) -> Result<String, ShellError> {
346    // Try live subcommand tree first
347    let cmd_opt = root_cmd
348        .get_subcommands()
349        .find(|c| c.get_name() == command_name);
350
351    // Fall back to known built-ins (commands that may not be wired yet)
352    if cmd_opt.is_none() && !KNOWN_BUILTINS.contains(&command_name) {
353        return Err(ShellError::UnknownCommand(command_name.to_string()));
354    }
355
356    Ok(generate_man_page(command_name, cmd_opt, prog_name, version))
357}
358
359/// Format Unix epoch days as YYYY-MM-DD without external crates.
360fn format_roff_date(days_since_epoch: u64) -> String {
361    let mut remaining = days_since_epoch;
362    let mut year = 1970u32;
363    loop {
364        let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
365        let days_in_year = if leap { 366 } else { 365 };
366        if remaining < days_in_year {
367            break;
368        }
369        remaining -= days_in_year;
370        year += 1;
371    }
372    let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
373    let month_days = [
374        31u64,
375        if leap { 29 } else { 28 },
376        31,
377        30,
378        31,
379        30,
380        31,
381        31,
382        30,
383        31,
384        30,
385        31,
386    ];
387    let mut month = 1u32;
388    for &d in &month_days {
389        if remaining < d {
390            break;
391        }
392        remaining -= d;
393        month += 1;
394    }
395    let day = remaining + 1;
396    format!("{year:04}-{month:02}-{day:02}")
397}
398
399// ---------------------------------------------------------------------------
400// Unit tests
401// ---------------------------------------------------------------------------
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    // --- Task 1: ShellError and KNOWN_BUILTINS ---
408
409    #[test]
410    fn test_shell_error_unknown_command_message() {
411        let err = ShellError::UnknownCommand("bogus".to_string());
412        assert_eq!(err.to_string(), "unknown command 'bogus'");
413    }
414
415    #[test]
416    fn test_known_builtins_contains_required_commands() {
417        for cmd in &["exec", "list", "describe", "completion", "man"] {
418            assert!(
419                KNOWN_BUILTINS.contains(cmd),
420                "KNOWN_BUILTINS must contain '{cmd}'"
421            );
422        }
423    }
424
425    #[test]
426    fn test_known_builtins_has_expected_count() {
427        assert_eq!(KNOWN_BUILTINS.len(), 5);
428    }
429
430    // --- Task 2: completion_command / cmd_completion ---
431
432    fn make_test_cmd(prog: &str) -> clap::Command {
433        clap::Command::new(prog.to_string())
434            .about("test")
435            .subcommand(clap::Command::new("exec"))
436            .subcommand(clap::Command::new("list"))
437    }
438
439    #[test]
440    fn test_cmd_completion_bash_nonempty() {
441        let mut cmd = make_test_cmd("apcore-cli");
442        let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
443        assert!(
444            !output.is_empty(),
445            "bash completion output must not be empty"
446        );
447    }
448
449    #[test]
450    fn test_cmd_completion_zsh_nonempty() {
451        let mut cmd = make_test_cmd("apcore-cli");
452        let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
453        assert!(
454            !output.is_empty(),
455            "zsh completion output must not be empty"
456        );
457    }
458
459    #[test]
460    fn test_cmd_completion_fish_nonempty() {
461        let mut cmd = make_test_cmd("apcore-cli");
462        let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
463        assert!(
464            !output.is_empty(),
465            "fish completion output must not be empty"
466        );
467    }
468
469    #[test]
470    fn test_cmd_completion_elvish_nonempty() {
471        let mut cmd = make_test_cmd("apcore-cli");
472        let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
473        assert!(
474            !output.is_empty(),
475            "elvish completion output must not be empty"
476        );
477    }
478
479    #[test]
480    fn test_cmd_completion_bash_contains_prog_name() {
481        let mut cmd = make_test_cmd("my-tool");
482        let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
483        assert!(
484            output.contains("my-tool") || output.contains("my_tool"),
485            "bash completion must reference the program name"
486        );
487    }
488
489    #[test]
490    fn test_completion_command_has_shell_arg() {
491        let cmd = completion_command();
492        let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
493        assert!(
494            arg.is_some(),
495            "completion_command must have a 'shell' argument"
496        );
497    }
498
499    #[test]
500    fn test_completion_command_name() {
501        let cmd = completion_command();
502        assert_eq!(cmd.get_name(), "completion");
503    }
504
505    // --- Task 3: build_synopsis / generate_man_page / cmd_man ---
506
507    fn make_exec_cmd() -> clap::Command {
508        clap::Command::new("exec")
509            .about("Execute an apcore module")
510            .arg(
511                clap::Arg::new("module_id")
512                    .value_name("MODULE_ID")
513                    .required(true)
514                    .help("Module ID to execute"),
515            )
516            .arg(
517                clap::Arg::new("format")
518                    .long("format")
519                    .value_name("FORMAT")
520                    .help("Output format")
521                    .default_value("table"),
522            )
523    }
524
525    #[test]
526    fn test_build_synopsis_no_cmd() {
527        let synopsis = build_synopsis(None, "apcore-cli", "exec");
528        assert!(synopsis.contains("apcore-cli"));
529        assert!(synopsis.contains("exec"));
530    }
531
532    #[test]
533    fn test_build_synopsis_required_positional_no_brackets() {
534        let cmd = make_exec_cmd();
535        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
536        assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
537        assert!(
538            !synopsis.contains("[\\fIMODULE_ID\\fR]"),
539            "required arg must not have brackets"
540        );
541    }
542
543    #[test]
544    fn test_build_synopsis_optional_option_has_brackets() {
545        let cmd = make_exec_cmd();
546        let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
547        assert!(
548            synopsis.contains('['),
549            "optional option must be wrapped in brackets"
550        );
551    }
552
553    #[test]
554    fn test_generate_man_page_contains_th() {
555        let cmd = make_exec_cmd();
556        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
557        assert!(page.contains(".TH"), "man page must have .TH header");
558    }
559
560    #[test]
561    fn test_generate_man_page_contains_sh_name() {
562        let cmd = make_exec_cmd();
563        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
564        assert!(page.contains(".SH NAME"), "man page must have NAME section");
565    }
566
567    #[test]
568    fn test_generate_man_page_contains_sh_synopsis() {
569        let cmd = make_exec_cmd();
570        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
571        assert!(
572            page.contains(".SH SYNOPSIS"),
573            "man page must have SYNOPSIS section"
574        );
575    }
576
577    #[test]
578    fn test_generate_man_page_contains_exit_codes() {
579        let cmd = make_exec_cmd();
580        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
581        assert!(
582            page.contains(".SH EXIT CODES"),
583            "man page must have EXIT CODES section"
584        );
585        assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
586        assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
587        assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
588    }
589
590    #[test]
591    fn test_generate_man_page_contains_environment() {
592        let cmd = make_exec_cmd();
593        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
594        assert!(
595            page.contains(".SH ENVIRONMENT"),
596            "man page must have ENVIRONMENT section"
597        );
598        assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
599        assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
600    }
601
602    #[test]
603    fn test_generate_man_page_contains_see_also() {
604        let cmd = make_exec_cmd();
605        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
606        assert!(
607            page.contains(".SH SEE ALSO"),
608            "man page must have SEE ALSO section"
609        );
610        assert!(page.contains("apcore-cli"));
611    }
612
613    #[test]
614    fn test_generate_man_page_th_includes_prog_and_version() {
615        let cmd = make_exec_cmd();
616        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
617        let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
618        assert!(
619            th_line.contains("APCORE-CLI-EXEC"),
620            "TH must contain uppercased title"
621        );
622        assert!(th_line.contains("0.2.0"), "TH must contain version");
623    }
624
625    #[test]
626    fn test_generate_man_page_name_uses_description() {
627        let cmd = make_exec_cmd();
628        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
629        assert!(
630            page.contains("Execute an apcore module"),
631            "NAME must use about text"
632        );
633    }
634
635    #[test]
636    fn test_generate_man_page_no_description_section_when_no_long_help() {
637        let cmd = make_exec_cmd();
638        let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
639        assert!(page.contains(".SH DESCRIPTION"));
640    }
641
642    #[test]
643    fn test_cmd_man_known_builtin_returns_ok() {
644        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
645        let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
646        assert!(result.is_ok(), "known builtin 'list' must return Ok");
647    }
648
649    #[test]
650    fn test_cmd_man_registered_subcommand_returns_ok() {
651        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
652        let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
653        assert!(
654            result.is_ok(),
655            "registered subcommand 'exec' must return Ok"
656        );
657        let page = result.unwrap();
658        assert!(page.contains(".TH"));
659    }
660
661    #[test]
662    fn test_cmd_man_unknown_command_returns_err() {
663        let root = clap::Command::new("apcore-cli");
664        let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
665        assert!(result.is_err());
666        match result.unwrap_err() {
667            ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
668        }
669    }
670
671    #[test]
672    fn test_cmd_man_exec_contains_options_section() {
673        let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
674        let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
675        assert!(
676            page.contains(".SH OPTIONS"),
677            "exec man page must have OPTIONS section"
678        );
679    }
680
681    // --- Task 4: register_shell_commands ---
682
683    #[test]
684    fn test_register_shell_commands_adds_completion() {
685        let root = Command::new("apcore-cli");
686        let cmd = register_shell_commands(root, "apcore-cli");
687        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
688        assert!(
689            names.contains(&"completion"),
690            "must have 'completion' subcommand, got {names:?}"
691        );
692    }
693
694    #[test]
695    fn test_register_shell_commands_adds_man() {
696        let root = Command::new("apcore-cli");
697        let cmd = register_shell_commands(root, "apcore-cli");
698        let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
699        assert!(
700            names.contains(&"man"),
701            "must have 'man' subcommand, got {names:?}"
702        );
703    }
704
705    #[test]
706    fn test_completion_bash_outputs_script() {
707        let cmd = completion_command();
708        let positionals: Vec<&str> = cmd
709            .get_positionals()
710            .filter_map(|a| a.get_id().as_str().into())
711            .collect();
712        // The arg is named "shell" with value_name "SHELL"
713        assert!(
714            !positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
715            "completion must have shell arg, got {positionals:?}"
716        );
717    }
718
719    #[test]
720    fn test_completion_zsh_outputs_script() {
721        let cmd = completion_command();
722        let shell_arg = cmd
723            .get_arguments()
724            .find(|a| a.get_id() == "shell")
725            .expect("shell argument must exist");
726        let possible = shell_arg.get_possible_values();
727        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
728        assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
729    }
730
731    #[test]
732    fn test_completion_invalid_shell_exits_nonzero() {
733        let cmd = completion_command();
734        let shell_arg = cmd
735            .get_arguments()
736            .find(|a| a.get_id() == "shell")
737            .expect("shell argument must exist");
738        let possible = shell_arg.get_possible_values();
739        let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
740        assert!(
741            !values.contains(&"invalid_shell"),
742            "invalid_shell must not be accepted"
743        );
744    }
745}