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] = &[
32 "completion",
33 "config",
34 "describe",
35 "describe-pipeline",
36 "disable",
37 "enable",
38 "exec",
39 "health",
40 "init",
41 "list",
42 "man",
43 "reload",
44 "usage",
45 "validate",
46];
47
48pub const KNOWN_BUILTIN_DESCRIPTIONS: &[(&str, &str)] = &[
53 ("completion", "Generate shell completion script"),
54 ("config", "Read or update runtime configuration"),
55 ("describe", "Show module metadata and schema"),
56 (
57 "describe-pipeline",
58 "Describe the execution pipeline for a strategy",
59 ),
60 ("disable", "Disable a module at runtime"),
61 ("enable", "Enable a module at runtime"),
62 ("exec", "Execute an apcore module"),
63 ("health", "Show module or registry health status"),
64 ("init", "Scaffolding commands"),
65 ("list", "List available modules"),
66 ("man", "Generate man page"),
67 ("reload", "Reload a module's definition"),
68 ("usage", "Show module usage counters"),
69 ("validate", "Validate a module's input against its schema"),
70];
71
72pub fn register_completion_command(cli: Command, prog_name: &str) -> Command {
80 let _ = prog_name; cli.subcommand(completion_command())
82}
83
84pub fn register_man_command(cli: Command) -> Command {
90 cli.subcommand(man_command())
91}
92
93pub fn completion_command() -> clap::Command {
99 clap::Command::new("completion")
100 .about("Generate a shell completion script and print it to stdout")
101 .long_about(
102 "Generate a shell completion script and print it to stdout.\n\n\
103 Install examples:\n\
104 \x20 bash: eval \"$(apcore-cli completion bash)\"\n\
105 \x20 zsh: eval \"$(apcore-cli completion zsh)\"\n\
106 \x20 fish: apcore-cli completion fish | source\n\
107 \x20 elvish: eval (apcore-cli completion elvish)\n\
108 \x20 powershell: apcore-cli completion powershell | Out-String | Invoke-Expression",
109 )
110 .arg(
111 clap::Arg::new("shell")
112 .value_name("SHELL")
113 .required(true)
114 .value_parser(clap::value_parser!(Shell))
115 .help("Shell to generate completions for (bash, zsh, fish, elvish, powershell)"),
116 )
117}
118
119pub fn cmd_completion(shell: Shell, prog_name: &str, cmd: &mut clap::Command) -> String {
129 match shell {
130 Shell::Bash => generate_grouped_bash_completion(prog_name),
131 Shell::Zsh => generate_grouped_zsh_completion(prog_name),
132 Shell::Fish => generate_grouped_fish_completion(prog_name),
133 _ => {
134 let mut buf: Vec<u8> = Vec::new();
135 generate(shell, cmd, prog_name, &mut buf);
136 String::from_utf8_lossy(&buf).into_owned()
137 }
138 }
139}
140
141fn make_function_name(prog_name: &str) -> String {
148 let sanitised: String = prog_name
149 .chars()
150 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
151 .collect();
152 format!("_{sanitised}")
153}
154
155fn shell_quote(s: &str) -> String {
160 if s.chars()
161 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
162 {
163 return s.to_string();
164 }
165 format!("'{}'", s.replace('\'', "'\\''"))
166}
167
168fn module_list_cmd(quoted: &str) -> String {
170 format!(
171 "{quoted} list --format json 2>/dev/null \
172 | python3 -c \"import sys,json;\
173 [print(m['id']) for m in json.load(sys.stdin)]\" \
174 2>/dev/null"
175 )
176}
177
178fn groups_and_top_cmd(quoted: &str) -> String {
181 format!(
182 "{quoted} list --format json 2>/dev/null \
183 | python3 -c \"\
184 import sys,json\n\
185 ids=[m['id'] for m in json.load(sys.stdin)]\n\
186 groups=set()\n\
187 top=[]\n\
188 for i in ids:\n\
189 if '.' in i: groups.add(i.split('.')[0])\n\
190 else: top.append(i)\n\
191 print(' '.join(sorted(groups)+sorted(top)))\n\
192 \" 2>/dev/null"
193 )
194}
195
196fn group_cmds_cmd(quoted: &str) -> String {
199 format!(
200 "{quoted} list --format json 2>/dev/null \
201 | python3 -c \"\
202 import sys,json,os\n\
203 g=os.environ['_APCORE_GRP']\n\
204 ids=[m['id'] for m in json.load(sys.stdin)]\n\
205 for i in ids:\n\
206 if '.' in i and i.split('.')[0]==g: \
207 print(i.split('.',1)[1])\n\
208 \" 2>/dev/null"
209 )
210}
211
212pub fn generate_grouped_bash_completion(prog_name: &str) -> String {
222 let fn_name = make_function_name(prog_name);
223 let quoted = shell_quote(prog_name);
224 let ml = module_list_cmd("ed);
225 let gt = groups_and_top_cmd("ed);
226 let gc = group_cmds_cmd("ed);
227 let builtins = KNOWN_BUILTINS.join(" ");
230
231 format!(
232 "{fn_name}() {{\n\
233 \x20 local cur prev\n\
234 \x20 COMPREPLY=()\n\
235 \x20 cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n\
236 \x20 prev=\"${{COMP_WORDS[COMP_CWORD-1]}}\"\n\
237 \n\
238 \x20 if [[ ${{COMP_CWORD}} -eq 1 ]]; then\n\
239 \x20 local all_ids=$({gt})\n\
240 \x20 local builtins=\"{builtins}\"\n\
241 \x20 COMPREPLY=( $(compgen -W \
242 \"${{builtins}} ${{all_ids}}\" -- ${{cur}}) )\n\
243 \x20 return 0\n\
244 \x20 fi\n\
245 \n\
246 \x20 if [[ \"${{COMP_WORDS[1]}}\" == \"exec\" \
247 && ${{COMP_CWORD}} -eq 2 ]]; then\n\
248 \x20 local modules=$({ml})\n\
249 \x20 COMPREPLY=( $(compgen -W \
250 \"${{modules}}\" -- ${{cur}}) )\n\
251 \x20 return 0\n\
252 \x20 fi\n\
253 \n\
254 \x20 if [[ ${{COMP_CWORD}} -eq 2 ]]; then\n\
255 \x20 local grp=\"${{COMP_WORDS[1]}}\"\n\
256 \x20 local cmds=$(export \
257 _APCORE_GRP=\"$grp\"; {gc})\n\
258 \x20 COMPREPLY=( $(compgen -W \
259 \"${{cmds}}\" -- ${{cur}}) )\n\
260 \x20 return 0\n\
261 \x20 fi\n\
262 }}\n\
263 complete -F {fn_name} {quoted}\n"
264 )
265}
266
267pub fn generate_grouped_zsh_completion(prog_name: &str) -> String {
272 let fn_name = make_function_name(prog_name);
273 let quoted = shell_quote(prog_name);
274 let ml = module_list_cmd("ed);
275 let gt = groups_and_top_cmd("ed);
276 let gc = group_cmds_cmd("ed);
277
278 let zsh_commands = KNOWN_BUILTIN_DESCRIPTIONS
281 .iter()
282 .map(|(name, desc)| format!(" '{name}:{desc}'\n"))
283 .collect::<String>();
284
285 format!(
286 "#compdef {prog_name}\n\
287 \n\
288 {fn_name}() {{\n\
289 \x20 local -a commands groups_and_top\n\
290 \x20 commands=(\n\
291 {zsh_commands}\
292 \x20 )\n\
293 \n\
294 \x20 _arguments -C \\\n\
295 \x20 '1:command:->command' \\\n\
296 \x20 '*::arg:->args'\n\
297 \n\
298 \x20 case \"$state\" in\n\
299 \x20 command)\n\
300 \x20 groups_and_top=($({gt}))\n\
301 \x20 _describe -t commands \
302 '{prog_name} commands' commands\n\
303 \x20 compadd -a groups_and_top\n\
304 \x20 ;;\n\
305 \x20 args)\n\
306 \x20 case \"${{words[1]}}\" in\n\
307 \x20 exec)\n\
308 \x20 local modules\n\
309 \x20 modules=($({ml}))\n\
310 \x20 compadd -a modules\n\
311 \x20 ;;\n\
312 \x20 *)\n\
313 \x20 local -a group_cmds\n\
314 \x20 group_cmds=($(export \
315 _APCORE_GRP=\"${{words[1]}}\"; {gc}))\n\
316 \x20 compadd -a group_cmds\n\
317 \x20 ;;\n\
318 \x20 esac\n\
319 \x20 ;;\n\
320 \x20 esac\n\
321 }}\n\
322 \n\
323 compdef {fn_name} {quoted}\n"
324 )
325}
326
327pub fn generate_grouped_fish_completion(prog_name: &str) -> String {
332 let quoted = shell_quote(prog_name);
333
334 let ml_fish = module_list_cmd_fish("ed);
336 let gt_fish = groups_and_top_cmd_fish("ed);
337 let gc_fish_fn = group_cmds_fish_fn("ed);
338
339 let fish_builtins = KNOWN_BUILTIN_DESCRIPTIONS
342 .iter()
343 .map(|(name, desc)| {
344 format!("complete -c {quoted} -n \"__fish_use_subcommand\" -a {name} -d \"{desc}\"\n")
345 })
346 .collect::<String>();
347
348 format!(
349 "# Fish completions for {prog_name}\n\
350 \n\
351 {gc_fish_fn}\
352 \n\
353 {fish_builtins}\
354 complete -c {quoted} -n \"__fish_use_subcommand\" \
355 -a \"({gt_fish})\" \
356 -d \"Module group or command\"\n\
357 \n\
358 complete -c {quoted} \
359 -n \"__fish_seen_subcommand_from exec\" \
360 -a \"({ml_fish})\"\n"
361 )
362}
363
364fn module_list_cmd_fish(quoted: &str) -> String {
366 format!(
367 "{quoted} list --format json 2>/dev/null \
368 | python3 -c \\\"import sys,json;\
369 [print(m['id']) for m in json.load(sys.stdin)]\\\" \
370 2>/dev/null"
371 )
372}
373
374fn groups_and_top_cmd_fish(quoted: &str) -> String {
376 format!(
377 "{quoted} list --format json 2>/dev/null \
378 | python3 -c \\\"\
379 import sys,json\\n\
380 ids=[m['id'] for m in json.load(sys.stdin)]\\n\
381 groups=set()\\n\
382 top=[]\\n\
383 for i in ids:\\n\
384 if '.' in i: groups.add(i.split('.')[0])\\n\
385 else: top.append(i)\\n\
386 print('\\\\n'.join(sorted(groups)+sorted(top)))\\n\
387 \\\" 2>/dev/null"
388 )
389}
390
391fn group_cmds_fish_fn(quoted: &str) -> String {
393 format!(
394 "function __apcore_group_cmds\n\
395 \x20 set -l grp $argv[1]\n\
396 \x20 {quoted} list --format json 2>/dev/null\
397 | python3 -c \\\"\
398 import sys,json,os\\n\
399 g=os.environ['_APCORE_GRP']\\n\
400 ids=[m['id'] for m in json.load(sys.stdin)]\\n\
401 for i in ids:\\n\
402 if '.' in i and i.split('.')[0]==g: \
403 print(i.split('.',1)[1])\\n\
404 \\\" 2>/dev/null\n\
405 end\n"
406 )
407}
408
409pub fn man_command() -> Command {
415 Command::new("man")
416 .about("Generate a roff man page for COMMAND and print it to stdout")
417 .long_about(
418 "Generate a roff man page for COMMAND and print it to stdout.\n\n\
419 View immediately:\n\
420 \x20 apcore-cli man exec | man -l -\n\
421 \x20 apcore-cli man list | col -bx | less\n\n\
422 Install system-wide:\n\
423 \x20 apcore-cli man exec > /usr/local/share/man/man1/apcore-cli-exec.1\n\
424 \x20 mandb # (Linux) or /usr/libexec/makewhatis # (macOS)",
425 )
426 .arg(
427 clap::Arg::new("command")
428 .value_name("COMMAND")
429 .required(true)
430 .help("CLI subcommand to generate the man page for"),
431 )
432}
433
434pub fn build_synopsis(cmd: Option<&clap::Command>, prog_name: &str, command_name: &str) -> String {
436 let prog = roff_escape(prog_name);
440 let cmd_name = roff_escape(command_name);
441 let Some(cmd) = cmd else {
442 return format!("\\fB{prog} {cmd_name}\\fR [OPTIONS]");
443 };
444
445 let mut parts = vec![format!("\\fB{prog} {cmd_name}\\fR")];
446
447 for arg in cmd.get_arguments() {
448 let id = arg.get_id().as_str();
450 if id == "help" || id == "version" {
451 continue;
452 }
453
454 let is_positional = arg.get_long().is_none() && arg.get_short().is_none();
455 let is_required = arg.is_required_set();
456
457 if is_positional {
458 let meta_owned: String = arg
459 .get_value_names()
460 .and_then(|v| v.first().map(|s| s.to_string()))
461 .unwrap_or_else(|| "ARG".to_string());
462 let meta = meta_owned.as_str();
463 if is_required {
464 parts.push(format!("\\fI{meta}\\fR"));
465 } else {
466 parts.push(format!("[\\fI{meta}\\fR]"));
467 }
468 } else {
469 let flag = if let Some(long) = arg.get_long() {
470 format!("\\-\\-{long}")
471 } else {
472 format!("\\-{}", arg.get_short().unwrap())
473 };
474 let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
475 if is_flag {
476 parts.push(format!("[{flag}]"));
477 } else {
478 let type_name_owned: String = arg
479 .get_value_names()
480 .and_then(|v| v.first().map(|s| s.to_string()))
481 .unwrap_or_else(|| "VALUE".to_string());
482 let type_name = type_name_owned.as_str();
483 if is_required {
484 parts.push(format!("{flag} \\fI{type_name}\\fR"));
485 } else {
486 parts.push(format!("[{flag} \\fI{type_name}\\fR]"));
487 }
488 }
489 }
490 }
491
492 parts.join(" ")
493}
494
495pub fn generate_man_page(
497 command_name: &str,
498 cmd: Option<&clap::Command>,
499 prog_name: &str,
500 version: &str,
501) -> String {
502 use std::time::{SystemTime, UNIX_EPOCH};
503
504 let today = {
505 let secs = SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .map(|d| d.as_secs())
508 .unwrap_or(0);
509 let days = secs / 86400;
510 format_roff_date(days)
511 };
512
513 let prog = roff_escape(prog_name);
517 let cmd_name_esc = roff_escape(command_name);
518 let title = format!("{}-{}", prog_name, command_name).to_uppercase();
519 let pkg_label = format!("{prog} {}", roff_escape(version));
520 let manual_label = format!("{prog} Manual");
521
522 let mut sections: Vec<String> = Vec::new();
523
524 sections.push(format!(
526 ".TH \"{title}\" \"1\" \"{today}\" \"{pkg_label}\" \"{manual_label}\""
527 ));
528
529 sections.push(".SH NAME".to_string());
531 let desc = cmd
532 .and_then(|c| c.get_about())
533 .map(|s| s.to_string())
534 .unwrap_or_else(|| command_name.to_string());
535 let name_desc = desc.lines().next().unwrap_or("").trim_end_matches('.');
536 sections.push(format!(
537 "{prog}-{cmd_name_esc} \\- {}",
538 roff_escape(name_desc)
539 ));
540
541 sections.push(".SH SYNOPSIS".to_string());
543 sections.push(build_synopsis(cmd, prog_name, command_name));
544
545 if let Some(about) = cmd.and_then(|c| c.get_about()) {
547 sections.push(".SH DESCRIPTION".to_string());
548 sections.push(roff_escape_block(&about.to_string()));
549 } else {
550 sections.push(".SH DESCRIPTION".to_string());
552 sections.push(format!("{prog}\\-{cmd_name_esc}"));
553 }
554
555 if let Some(c) = cmd {
557 let options: Vec<_> = c
558 .get_arguments()
559 .filter(|a| a.get_long().is_some() || a.get_short().is_some())
560 .filter(|a| a.get_id().as_str() != "help" && a.get_id().as_str() != "version")
561 .collect();
562
563 if !options.is_empty() {
564 sections.push(".SH OPTIONS".to_string());
565 for arg in options {
566 let flag_parts: Vec<String> = {
567 let mut fp = Vec::new();
568 if let Some(short) = arg.get_short() {
569 fp.push(format!("\\-{short}"));
570 }
571 if let Some(long) = arg.get_long() {
572 fp.push(format!("\\-\\-{long}"));
573 }
574 fp
575 };
576 let flag_str = flag_parts.join(", ");
577
578 let is_flag = arg.get_num_args().is_some_and(|r| r.max_values() == 0);
579 sections.push(".TP".to_string());
580 if is_flag {
581 sections.push(format!("\\fB{flag_str}\\fR"));
582 } else {
583 let type_name_owned: String = arg
584 .get_value_names()
585 .and_then(|v| v.first().map(|s| s.to_string()))
586 .unwrap_or_else(|| "VALUE".to_string());
587 let type_name = type_name_owned.as_str();
588 sections.push(format!("\\fB{flag_str}\\fR \\fI{type_name}\\fR"));
589 }
590 if let Some(help) = arg.get_help() {
591 sections.push(roff_escape_block(&help.to_string()));
592 }
593 if let Some(default) = arg.get_default_values().first() {
594 if !is_flag {
595 sections.push(format!("Default: {}.", default.to_string_lossy()));
596 }
597 }
598 }
599 }
600 }
601
602 sections.push(".SH ENVIRONMENT".to_string());
604 for (name, desc) in ENV_ENTRIES {
605 sections.push(".TP".to_string());
606 sections.push(format!("\\fB{name}\\fR"));
607 sections.push(desc.to_string());
608 }
609
610 sections.push(".SH EXIT CODES".to_string());
612 for (code, meaning) in EXIT_CODES {
613 sections.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
614 }
615
616 sections.push(".SH SEE ALSO".to_string());
618 let see_also = [
619 format!("\\fB{prog}\\fR(1)"),
620 format!("\\fB{prog}\\-list\\fR(1)"),
621 format!("\\fB{prog}\\-describe\\fR(1)"),
622 format!("\\fB{prog}\\-completion\\fR(1)"),
623 ];
624 sections.push(see_also.join(", "));
625
626 sections.join("\n")
627}
628
629pub const ENV_ENTRIES: &[(&str, &str)] = &[
631 (
632 "APCORE_EXTENSIONS_ROOT",
633 "Path to the apcore extensions directory. Overrides the default \\fI./extensions\\fR.",
634 ),
635 (
636 "APCORE_CLI_AUTO_APPROVE",
637 "Set to \\fB1\\fR to bypass approval prompts for modules that require human-in-the-loop confirmation.",
638 ),
639 (
640 "APCORE_CLI_LOGGING_LEVEL",
641 "CLI-specific logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
642 Takes priority over \\fBAPCORE_LOGGING_LEVEL\\fR. Default: WARNING.",
643 ),
644 (
645 "APCORE_LOGGING_LEVEL",
646 "Global apcore logging verbosity. One of: DEBUG, INFO, WARNING, ERROR. \
647 Used as fallback when \\fBAPCORE_CLI_LOGGING_LEVEL\\fR is not set. Default: WARNING.",
648 ),
649 (
650 "APCORE_AUTH_API_KEY",
651 "API key for authenticating with the apcore registry.",
652 ),
653];
654
655pub const EXIT_CODES: &[(&str, &str)] = &[
657 ("0", "Success."),
658 ("1", "Module execution error."),
659 ("2", "Invalid CLI input or missing argument."),
660 ("44", "Module not found, disabled, or failed to load."),
661 ("45", "Input failed JSON Schema validation."),
662 (
663 "46",
664 "Approval denied, timed out, or no interactive terminal available.",
665 ),
666 (
667 "47",
668 "Configuration error (extensions directory not found or unreadable).",
669 ),
670 ("48", "Schema contains a circular \\fB$ref\\fR."),
671 (
672 "77",
673 "ACL denied \\- insufficient permissions for this module.",
674 ),
675 ("130", "Execution cancelled by user (SIGINT / Ctrl\\-C)."),
676];
677
678pub fn cmd_man(
683 command_name: &str,
684 root_cmd: &clap::Command,
685 prog_name: &str,
686 version: &str,
687) -> Result<String, ShellError> {
688 let cmd_opt = root_cmd
690 .get_subcommands()
691 .find(|c| c.get_name() == command_name);
692
693 if cmd_opt.is_none() && !KNOWN_BUILTINS.contains(&command_name) {
695 return Err(ShellError::UnknownCommand(command_name.to_string()));
696 }
697
698 Ok(generate_man_page(command_name, cmd_opt, prog_name, version))
699}
700
701fn roff_escape(s: &str) -> String {
707 s.replace('\\', "\\\\")
708 .replace('-', "\\-")
709 .replace('\'', "\\(aq")
710}
711
712fn roff_escape_block(s: &str) -> String {
720 roff_escape(s)
721 .lines()
722 .map(|line| {
723 if line.starts_with('.') {
724 format!("\\&{line}")
725 } else {
726 line.to_string()
727 }
728 })
729 .collect::<Vec<_>>()
730 .join("\n")
731}
732
733pub fn has_man_flag(args: &[String]) -> bool {
735 args.iter().any(|a| a == "--man")
736}
737
738pub fn build_program_man_page(
744 cmd: &clap::Command,
745 prog_name: &str,
746 version: &str,
747 description: Option<&str>,
748 docs_url: Option<&str>,
749) -> String {
750 let desc = description
751 .map(|s| s.to_string())
752 .or_else(|| cmd.get_about().map(|s| s.to_string()))
753 .unwrap_or_else(|| "CLI".to_string());
754 let upper = prog_name.to_uppercase();
755 let mut s = Vec::new();
756
757 s.push(format!(
758 ".TH \"{upper}\" \"1\" \"\" \
759 \"{prog_name} {version}\" \"{prog_name} Manual\""
760 ));
761
762 s.push(".SH NAME".to_string());
763 s.push(format!("{prog_name} \\- {}", roff_escape(&desc)));
764
765 s.push(".SH SYNOPSIS".to_string());
766 s.push(format!(
767 "\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \
768 \\fIcommand\\fR [\\fIcommand\\-options\\fR]"
769 ));
770
771 s.push(".SH DESCRIPTION".to_string());
772 s.push(roff_escape_block(&desc));
773
774 let global_args: Vec<_> = cmd
776 .get_arguments()
777 .filter(|a| !a.is_hide_set() && !matches!(a.get_id().as_str(), "help" | "version" | "man"))
778 .collect();
779 if !global_args.is_empty() {
780 s.push(".SH GLOBAL OPTIONS".to_string());
781 for arg in &global_args {
782 if let Some(long) = arg.get_long() {
783 s.push(".TP".to_string());
784 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
785 if let Some(help) = arg.get_help() {
786 s.push(roff_escape_block(&help.to_string()));
787 }
788 }
789 }
790 }
791
792 let subcmds: Vec<_> = cmd
794 .get_subcommands()
795 .filter(|c| c.get_name() != "help")
796 .collect();
797 if !subcmds.is_empty() {
798 s.push(".SH COMMANDS".to_string());
799 for sub in &subcmds {
800 let name = sub.get_name();
801 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
802 s.push(".TP".to_string());
803 s.push(format!("\\fB{prog_name} {}\\fR", roff_escape(name)));
804 if !about.is_empty() {
805 s.push(roff_escape_block(&about));
806 }
807
808 for arg in sub.get_arguments() {
810 if arg.is_hide_set() || arg.get_id().as_str() == "help" {
811 continue;
812 }
813 if let Some(long) = arg.get_long() {
814 s.push(".RS".to_string());
815 s.push(".TP".to_string());
816 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
817 if let Some(help) = arg.get_help() {
818 s.push(roff_escape_block(&help.to_string()));
819 }
820 s.push(".RE".to_string());
821 }
822 }
823
824 for nested in sub.get_subcommands() {
826 if nested.get_name() == "help" {
827 continue;
828 }
829 let nested_about = nested
830 .get_about()
831 .map(|a| a.to_string())
832 .unwrap_or_default();
833 s.push(".TP".to_string());
834 s.push(format!(
835 "\\fB{prog_name} {} {}\\fR",
836 roff_escape(name),
837 roff_escape(nested.get_name())
838 ));
839 if !nested_about.is_empty() {
840 s.push(roff_escape_block(&nested_about));
841 }
842 for arg in nested.get_arguments() {
843 if arg.is_hide_set() || arg.get_id().as_str() == "help" {
844 continue;
845 }
846 if let Some(long) = arg.get_long() {
847 s.push(".RS".to_string());
848 s.push(".TP".to_string());
849 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
850 if let Some(help) = arg.get_help() {
851 s.push(roff_escape_block(&help.to_string()));
852 }
853 s.push(".RE".to_string());
854 }
855 }
856 }
857 }
858 }
859
860 s.push(".SH ENVIRONMENT".to_string());
862 for (name, env_desc) in ENV_ENTRIES {
863 s.push(".TP".to_string());
864 s.push(format!("\\fB{name}\\fR"));
865 s.push(env_desc.to_string());
866 }
867
868 s.push(".SH EXIT CODES".to_string());
870 for (code, meaning) in EXIT_CODES {
871 s.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
872 }
873
874 s.push(".SH SEE ALSO".to_string());
875 s.push(format!(
876 "\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR \
877 for full option list."
878 ));
879 if let Some(url) = docs_url {
880 s.push(format!(
881 ".PP\nFull documentation at \\fI{}\\fR",
882 roff_escape(url)
883 ));
884 }
885
886 s.join("\n")
887}
888
889fn format_roff_date(days_since_epoch: u64) -> String {
891 let mut remaining = days_since_epoch;
892 let mut year = 1970u32;
893 loop {
894 let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
895 let days_in_year = if leap { 366 } else { 365 };
896 if remaining < days_in_year {
897 break;
898 }
899 remaining -= days_in_year;
900 year += 1;
901 }
902 let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
903 let month_days = [
904 31u64,
905 if leap { 29 } else { 28 },
906 31,
907 30,
908 31,
909 30,
910 31,
911 31,
912 30,
913 31,
914 30,
915 31,
916 ];
917 let mut month = 1u32;
918 for &d in &month_days {
919 if remaining < d {
920 break;
921 }
922 remaining -= d;
923 month += 1;
924 }
925 let day = remaining + 1;
926 format!("{year:04}-{month:02}-{day:02}")
927}
928
929#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
940 fn test_shell_error_unknown_command_message() {
941 let err = ShellError::UnknownCommand("bogus".to_string());
942 assert_eq!(err.to_string(), "unknown command 'bogus'");
943 }
944
945 #[test]
946 fn test_known_builtins_contains_required_commands() {
947 for cmd in &["exec", "list", "describe", "completion", "init", "man"] {
948 assert!(
949 KNOWN_BUILTINS.contains(cmd),
950 "KNOWN_BUILTINS must contain '{cmd}'"
951 );
952 }
953 }
954
955 #[test]
956 fn test_known_builtins_has_expected_count() {
957 assert_eq!(KNOWN_BUILTINS.len(), 14);
958 }
959
960 #[test]
961 fn test_known_builtin_descriptions_covers_all_builtins() {
962 for name in KNOWN_BUILTINS {
966 assert!(
967 KNOWN_BUILTIN_DESCRIPTIONS.iter().any(|(n, _)| n == name),
968 "KNOWN_BUILTIN_DESCRIPTIONS missing entry for '{name}'"
969 );
970 }
971 assert_eq!(
972 KNOWN_BUILTIN_DESCRIPTIONS.len(),
973 KNOWN_BUILTINS.len(),
974 "tables must stay the same length"
975 );
976 }
977
978 #[test]
979 fn test_bash_completion_lists_every_known_builtin() {
980 let script = generate_grouped_bash_completion("apcli");
986 for name in KNOWN_BUILTINS {
987 assert!(
988 script.contains(name),
989 "bash completion script missing builtin '{name}': {script}"
990 );
991 }
992 }
993
994 #[test]
995 fn test_zsh_completion_lists_every_known_builtin() {
996 let script = generate_grouped_zsh_completion("apcli");
997 for name in KNOWN_BUILTINS {
998 assert!(
999 script.contains(name),
1000 "zsh completion script missing builtin '{name}': {script}"
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn test_fish_completion_lists_every_known_builtin() {
1007 let script = generate_grouped_fish_completion("apcli");
1008 for name in KNOWN_BUILTINS {
1009 assert!(
1010 script.contains(name),
1011 "fish completion script missing builtin '{name}': {script}"
1012 );
1013 }
1014 }
1015
1016 fn make_test_cmd(prog: &str) -> clap::Command {
1019 clap::Command::new(prog.to_string())
1020 .about("test")
1021 .subcommand(clap::Command::new("exec"))
1022 .subcommand(clap::Command::new("list"))
1023 }
1024
1025 #[test]
1026 fn test_cmd_completion_bash_nonempty() {
1027 let mut cmd = make_test_cmd("apcore-cli");
1028 let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
1029 assert!(
1030 !output.is_empty(),
1031 "bash completion output must not be empty"
1032 );
1033 }
1034
1035 #[test]
1036 fn test_cmd_completion_zsh_nonempty() {
1037 let mut cmd = make_test_cmd("apcore-cli");
1038 let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
1039 assert!(
1040 !output.is_empty(),
1041 "zsh completion output must not be empty"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_cmd_completion_fish_nonempty() {
1047 let mut cmd = make_test_cmd("apcore-cli");
1048 let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
1049 assert!(
1050 !output.is_empty(),
1051 "fish completion output must not be empty"
1052 );
1053 }
1054
1055 #[test]
1056 fn test_cmd_completion_elvish_nonempty() {
1057 let mut cmd = make_test_cmd("apcore-cli");
1058 let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
1059 assert!(
1060 !output.is_empty(),
1061 "elvish completion output must not be empty"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_cmd_completion_bash_contains_prog_name() {
1067 let mut cmd = make_test_cmd("my-tool");
1068 let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
1069 assert!(
1070 output.contains("my-tool") || output.contains("my_tool"),
1071 "bash completion must reference the program name"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_completion_command_has_shell_arg() {
1077 let cmd = completion_command();
1078 let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
1079 assert!(
1080 arg.is_some(),
1081 "completion_command must have a 'shell' argument"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_completion_command_name() {
1087 let cmd = completion_command();
1088 assert_eq!(cmd.get_name(), "completion");
1089 }
1090
1091 fn make_exec_cmd() -> clap::Command {
1094 clap::Command::new("exec")
1095 .about("Execute an apcore module")
1096 .arg(
1097 clap::Arg::new("module_id")
1098 .value_name("MODULE_ID")
1099 .required(true)
1100 .help("Module ID to execute"),
1101 )
1102 .arg(
1103 clap::Arg::new("format")
1104 .long("format")
1105 .value_name("FORMAT")
1106 .help("Output format")
1107 .default_value("table"),
1108 )
1109 }
1110
1111 #[test]
1112 fn test_build_synopsis_no_cmd() {
1113 let synopsis = build_synopsis(None, "apcore-cli", "exec");
1114 assert!(synopsis.contains("apcore\\-cli"));
1117 assert!(synopsis.contains("exec"));
1118 }
1119
1120 #[test]
1121 fn test_build_synopsis_required_positional_no_brackets() {
1122 let cmd = make_exec_cmd();
1123 let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
1124 assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
1125 assert!(
1126 !synopsis.contains("[\\fIMODULE_ID\\fR]"),
1127 "required arg must not have brackets"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_build_synopsis_optional_option_has_brackets() {
1133 let cmd = make_exec_cmd();
1134 let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
1135 assert!(
1136 synopsis.contains('['),
1137 "optional option must be wrapped in brackets"
1138 );
1139 }
1140
1141 #[test]
1142 fn test_generate_man_page_contains_th() {
1143 let cmd = make_exec_cmd();
1144 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1145 assert!(page.contains(".TH"), "man page must have .TH header");
1146 }
1147
1148 #[test]
1149 fn test_generate_man_page_contains_sh_name() {
1150 let cmd = make_exec_cmd();
1151 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1152 assert!(page.contains(".SH NAME"), "man page must have NAME section");
1153 }
1154
1155 #[test]
1156 fn test_generate_man_page_contains_sh_synopsis() {
1157 let cmd = make_exec_cmd();
1158 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1159 assert!(
1160 page.contains(".SH SYNOPSIS"),
1161 "man page must have SYNOPSIS section"
1162 );
1163 }
1164
1165 #[test]
1166 fn test_generate_man_page_contains_exit_codes() {
1167 let cmd = make_exec_cmd();
1168 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1169 assert!(
1170 page.contains(".SH EXIT CODES"),
1171 "man page must have EXIT CODES section"
1172 );
1173 assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
1174 assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
1175 assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
1176 }
1177
1178 #[test]
1179 fn test_generate_man_page_contains_environment() {
1180 let cmd = make_exec_cmd();
1181 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1182 assert!(
1183 page.contains(".SH ENVIRONMENT"),
1184 "man page must have ENVIRONMENT section"
1185 );
1186 assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
1187 assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
1188 }
1189
1190 #[test]
1191 fn test_generate_man_page_contains_see_also() {
1192 let cmd = make_exec_cmd();
1193 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1194 assert!(
1195 page.contains(".SH SEE ALSO"),
1196 "man page must have SEE ALSO section"
1197 );
1198 assert!(page.contains("apcore\\-cli"));
1200 }
1201
1202 #[test]
1203 fn test_generate_man_page_th_includes_prog_and_version() {
1204 let cmd = make_exec_cmd();
1205 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1206 let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
1207 assert!(
1208 th_line.contains("APCORE-CLI-EXEC"),
1209 "TH must contain uppercased title"
1210 );
1211 assert!(th_line.contains("0.2.0"), "TH must contain version");
1212 }
1213
1214 #[test]
1215 fn test_generate_man_page_name_uses_description() {
1216 let cmd = make_exec_cmd();
1217 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1218 assert!(
1219 page.contains("Execute an apcore module"),
1220 "NAME must use about text"
1221 );
1222 }
1223
1224 #[test]
1225 fn test_generate_man_page_no_description_section_when_no_long_help() {
1226 let cmd = make_exec_cmd();
1227 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1228 assert!(page.contains(".SH DESCRIPTION"));
1229 }
1230
1231 #[test]
1232 fn test_cmd_man_known_builtin_returns_ok() {
1233 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1234 let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
1235 assert!(result.is_ok(), "known builtin 'list' must return Ok");
1236 }
1237
1238 #[test]
1239 fn test_cmd_man_registered_subcommand_returns_ok() {
1240 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1241 let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
1242 assert!(
1243 result.is_ok(),
1244 "registered subcommand 'exec' must return Ok"
1245 );
1246 let page = result.unwrap();
1247 assert!(page.contains(".TH"));
1248 }
1249
1250 #[test]
1251 fn test_cmd_man_unknown_command_returns_err() {
1252 let root = clap::Command::new("apcore-cli");
1253 let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
1254 assert!(result.is_err());
1255 match result.unwrap_err() {
1256 ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
1257 }
1258 }
1259
1260 #[test]
1261 fn test_cmd_man_exec_contains_options_section() {
1262 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1263 let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
1264 assert!(
1265 page.contains(".SH OPTIONS"),
1266 "exec man page must have OPTIONS section"
1267 );
1268 }
1269
1270 #[test]
1273 fn test_roff_escape_backslash() {
1274 assert_eq!(roff_escape("a\\b"), "a\\\\b");
1275 }
1276
1277 #[test]
1278 fn test_roff_escape_hyphen() {
1279 assert_eq!(roff_escape("foo-bar"), "foo\\-bar");
1280 }
1281
1282 #[test]
1283 fn test_roff_escape_single_quote() {
1284 assert_eq!(roff_escape("it's"), "it\\(aqs");
1285 }
1286
1287 #[test]
1288 fn test_roff_escape_block_leading_dot_defused() {
1289 let text = "first line\n.NET Framework\nend";
1290 let escaped = roff_escape_block(text);
1291 assert!(
1292 escaped.contains("\\&.NET"),
1293 "leading dot must be prefixed with \\& to prevent roff control: {escaped:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_roff_escape_block_no_leading_dot_unchanged() {
1299 let text = "normal line\nalso normal";
1300 let escaped = roff_escape_block(text);
1301 assert_eq!(escaped, "normal line\nalso normal");
1302 }
1303
1304 #[test]
1307 fn test_has_man_flag_present() {
1308 let args = vec!["--man".to_string()];
1309 assert!(has_man_flag(&args));
1310 }
1311
1312 #[test]
1313 fn test_has_man_flag_absent() {
1314 let args = vec!["--help".to_string()];
1315 assert!(!has_man_flag(&args));
1316 }
1317
1318 #[test]
1321 fn test_build_program_man_page_basic() {
1322 let cmd = clap::Command::new("t")
1323 .about("Test")
1324 .subcommand(clap::Command::new("sub").about("A sub"));
1325 let roff = build_program_man_page(&cmd, "t", "0.1.0", None, None);
1326 assert!(roff.contains(".TH \"T\""));
1327 assert!(roff.contains(".SH COMMANDS"));
1328 assert!(roff.contains("sub"));
1329 assert!(roff.contains(".SH EXIT CODES"));
1330 }
1331
1332 #[test]
1333 fn test_build_program_man_page_custom_description() {
1334 let cmd = clap::Command::new("t").about("Default");
1335 let roff = build_program_man_page(&cmd, "t", "0.1.0", Some("Custom"), None);
1336 assert!(roff.contains("Custom"));
1337 }
1338
1339 #[test]
1340 fn test_completion_bash_outputs_script() {
1341 let cmd = completion_command();
1342 let positionals: Vec<&str> = cmd
1343 .get_positionals()
1344 .filter_map(|a| a.get_id().as_str().into())
1345 .collect();
1346 assert!(
1348 !positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
1349 "completion must have shell arg, got {positionals:?}"
1350 );
1351 }
1352
1353 #[test]
1354 fn test_completion_zsh_outputs_script() {
1355 let cmd = completion_command();
1356 let shell_arg = cmd
1357 .get_arguments()
1358 .find(|a| a.get_id() == "shell")
1359 .expect("shell argument must exist");
1360 let possible = shell_arg.get_possible_values();
1361 let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1362 assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
1363 }
1364
1365 #[test]
1366 fn test_completion_invalid_shell_exits_nonzero() {
1367 let cmd = completion_command();
1368 let shell_arg = cmd
1369 .get_arguments()
1370 .find(|a| a.get_id() == "shell")
1371 .expect("shell argument must exist");
1372 let possible = shell_arg.get_possible_values();
1373 let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1374 assert!(
1375 !values.contains(&"invalid_shell"),
1376 "invalid_shell must not be accepted"
1377 );
1378 }
1379
1380 #[test]
1383 fn test_register_completion_command_attaches_completion() {
1384 let root = register_completion_command(Command::new("root"), "apcore-cli");
1385 let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
1386 assert!(
1387 names.contains(&"completion"),
1388 "must have 'completion' subcommand, got {names:?}"
1389 );
1390 assert!(
1391 !names.contains(&"man"),
1392 "man must be absent when only completion registrar called"
1393 );
1394 }
1395
1396 #[test]
1397 fn test_register_man_command_attaches_man() {
1398 let root = register_man_command(Command::new("root"));
1399 let names: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
1400 assert!(
1401 names.contains(&"man"),
1402 "must have 'man' subcommand, got {names:?}"
1403 );
1404 assert!(
1405 !names.contains(&"completion"),
1406 "completion must be absent when only man registrar called"
1407 );
1408 }
1409}