1use clap::Command;
5use clap_complete::{generate, Shell};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
14pub enum ShellError {
15 #[error("unknown command '{0}'")]
16 UnknownCommand(String),
17}
18
19pub const KNOWN_BUILTINS: &[&str] = &["exec", "list", "describe", "completion", "init", "man"];
30
31pub fn register_shell_commands(cli: Command, prog_name: &str) -> Command {
42 let _ = prog_name; cli.subcommand(completion_command())
44 .subcommand(man_command())
45}
46
47pub 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
73pub 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
95fn 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
109fn 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
122fn 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
132fn 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
150fn 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
166pub 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("ed);
179 let gt = groups_and_top_cmd("ed);
180 let gc = group_cmds_cmd("ed);
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
218pub 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("ed);
226 let gt = groups_and_top_cmd("ed);
227 let gc = group_cmds_cmd("ed);
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
276pub fn generate_grouped_fish_completion(prog_name: &str) -> String {
281 let quoted = shell_quote(prog_name);
282
283 let ml_fish = module_list_cmd_fish("ed);
285 let gt_fish = groups_and_top_cmd_fish("ed);
286 let gc_fish_fn = group_cmds_fish_fn("ed);
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
315fn 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
325fn 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
342fn 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
360pub 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
385pub 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 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
441pub 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 sections.push(format!(
467 ".TH \"{title}\" \"1\" \"{today}\" \"{pkg_label}\" \"{manual_label}\""
468 ));
469
470 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 sections.push(".SH SYNOPSIS".to_string());
481 sections.push(build_synopsis(cmd, prog_name, command_name));
482
483 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 sections.push(".SH DESCRIPTION".to_string());
491 sections.push(format!("{prog_name}\\-{command_name}"));
492 }
493
494 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 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 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 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
568pub 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
594pub 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
617pub fn cmd_man(
622 command_name: &str,
623 root_cmd: &clap::Command,
624 prog_name: &str,
625 version: &str,
626) -> Result<String, ShellError> {
627 let cmd_opt = root_cmd
629 .get_subcommands()
630 .find(|c| c.get_name() == command_name);
631
632 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
640fn 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#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[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 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 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 #[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 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}