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