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 roff_escape(s: &str) -> String {
646 s.replace('\\', "\\\\")
647 .replace('-', "\\-")
648 .replace('\'', "\\(aq")
649}
650
651pub fn has_man_flag(args: &[String]) -> bool {
653 args.iter().any(|a| a == "--man")
654}
655
656pub fn build_program_man_page(
662 cmd: &clap::Command,
663 prog_name: &str,
664 version: &str,
665 description: Option<&str>,
666 docs_url: Option<&str>,
667) -> String {
668 let desc = description
669 .map(|s| s.to_string())
670 .or_else(|| cmd.get_about().map(|s| s.to_string()))
671 .unwrap_or_else(|| "CLI".to_string());
672 let upper = prog_name.to_uppercase();
673 let mut s = Vec::new();
674
675 s.push(format!(
676 ".TH \"{upper}\" \"1\" \"\" \
677 \"{prog_name} {version}\" \"{prog_name} Manual\""
678 ));
679
680 s.push(".SH NAME".to_string());
681 s.push(format!("{prog_name} \\- {}", roff_escape(&desc)));
682
683 s.push(".SH SYNOPSIS".to_string());
684 s.push(format!(
685 "\\fB{prog_name}\\fR [\\fIglobal\\-options\\fR] \
686 \\fIcommand\\fR [\\fIcommand\\-options\\fR]"
687 ));
688
689 s.push(".SH DESCRIPTION".to_string());
690 s.push(roff_escape(&desc));
691
692 let global_args: Vec<_> = cmd
694 .get_arguments()
695 .filter(|a| !a.is_hide_set() && !matches!(a.get_id().as_str(), "help" | "version" | "man"))
696 .collect();
697 if !global_args.is_empty() {
698 s.push(".SH GLOBAL OPTIONS".to_string());
699 for arg in &global_args {
700 if let Some(long) = arg.get_long() {
701 s.push(".TP".to_string());
702 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
703 if let Some(help) = arg.get_help() {
704 s.push(roff_escape(&help.to_string()));
705 }
706 }
707 }
708 }
709
710 let subcmds: Vec<_> = cmd
712 .get_subcommands()
713 .filter(|c| c.get_name() != "help")
714 .collect();
715 if !subcmds.is_empty() {
716 s.push(".SH COMMANDS".to_string());
717 for sub in &subcmds {
718 let name = sub.get_name();
719 let about = sub.get_about().map(|a| a.to_string()).unwrap_or_default();
720 s.push(".TP".to_string());
721 s.push(format!("\\fB{prog_name} {}\\fR", roff_escape(name)));
722 if !about.is_empty() {
723 s.push(roff_escape(&about));
724 }
725
726 for arg in sub.get_arguments() {
728 if arg.is_hide_set() || arg.get_id().as_str() == "help" {
729 continue;
730 }
731 if let Some(long) = arg.get_long() {
732 s.push(".RS".to_string());
733 s.push(".TP".to_string());
734 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
735 if let Some(help) = arg.get_help() {
736 s.push(roff_escape(&help.to_string()));
737 }
738 s.push(".RE".to_string());
739 }
740 }
741
742 for nested in sub.get_subcommands() {
744 if nested.get_name() == "help" {
745 continue;
746 }
747 let nested_about = nested
748 .get_about()
749 .map(|a| a.to_string())
750 .unwrap_or_default();
751 s.push(".TP".to_string());
752 s.push(format!(
753 "\\fB{prog_name} {} {}\\fR",
754 roff_escape(name),
755 roff_escape(nested.get_name())
756 ));
757 if !nested_about.is_empty() {
758 s.push(roff_escape(&nested_about));
759 }
760 for arg in nested.get_arguments() {
761 if arg.is_hide_set() || arg.get_id().as_str() == "help" {
762 continue;
763 }
764 if let Some(long) = arg.get_long() {
765 s.push(".RS".to_string());
766 s.push(".TP".to_string());
767 s.push(format!("\\fB\\-\\-{}\\fR", roff_escape(long)));
768 if let Some(help) = arg.get_help() {
769 s.push(roff_escape(&help.to_string()));
770 }
771 s.push(".RE".to_string());
772 }
773 }
774 }
775 }
776 }
777
778 s.push(".SH ENVIRONMENT".to_string());
780 for (name, env_desc) in ENV_ENTRIES {
781 s.push(".TP".to_string());
782 s.push(format!("\\fB{name}\\fR"));
783 s.push(env_desc.to_string());
784 }
785
786 s.push(".SH EXIT CODES".to_string());
788 for (code, meaning) in EXIT_CODES {
789 s.push(format!(".TP\n\\fB{code}\\fR\n{meaning}"));
790 }
791
792 s.push(".SH SEE ALSO".to_string());
793 s.push(format!(
794 "\\fB{prog_name} \\-\\-help \\-\\-verbose\\fR \
795 for full option list."
796 ));
797 if let Some(url) = docs_url {
798 s.push(format!(
799 ".PP\nFull documentation at \\fI{}\\fR",
800 roff_escape(url)
801 ));
802 }
803
804 s.join("\n")
805}
806
807fn format_roff_date(days_since_epoch: u64) -> String {
809 let mut remaining = days_since_epoch;
810 let mut year = 1970u32;
811 loop {
812 let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
813 let days_in_year = if leap { 366 } else { 365 };
814 if remaining < days_in_year {
815 break;
816 }
817 remaining -= days_in_year;
818 year += 1;
819 }
820 let leap = year.is_multiple_of(4) && !year.is_multiple_of(100) || year.is_multiple_of(400);
821 let month_days = [
822 31u64,
823 if leap { 29 } else { 28 },
824 31,
825 30,
826 31,
827 30,
828 31,
829 31,
830 30,
831 31,
832 30,
833 31,
834 ];
835 let mut month = 1u32;
836 for &d in &month_days {
837 if remaining < d {
838 break;
839 }
840 remaining -= d;
841 month += 1;
842 }
843 let day = remaining + 1;
844 format!("{year:04}-{month:02}-{day:02}")
845}
846
847#[cfg(test)]
852mod tests {
853 use super::*;
854
855 #[test]
858 fn test_shell_error_unknown_command_message() {
859 let err = ShellError::UnknownCommand("bogus".to_string());
860 assert_eq!(err.to_string(), "unknown command 'bogus'");
861 }
862
863 #[test]
864 fn test_known_builtins_contains_required_commands() {
865 for cmd in &["exec", "list", "describe", "completion", "init", "man"] {
866 assert!(
867 KNOWN_BUILTINS.contains(cmd),
868 "KNOWN_BUILTINS must contain '{cmd}'"
869 );
870 }
871 }
872
873 #[test]
874 fn test_known_builtins_has_expected_count() {
875 assert_eq!(KNOWN_BUILTINS.len(), 6);
876 }
877
878 fn make_test_cmd(prog: &str) -> clap::Command {
881 clap::Command::new(prog.to_string())
882 .about("test")
883 .subcommand(clap::Command::new("exec"))
884 .subcommand(clap::Command::new("list"))
885 }
886
887 #[test]
888 fn test_cmd_completion_bash_nonempty() {
889 let mut cmd = make_test_cmd("apcore-cli");
890 let output = cmd_completion(Shell::Bash, "apcore-cli", &mut cmd);
891 assert!(
892 !output.is_empty(),
893 "bash completion output must not be empty"
894 );
895 }
896
897 #[test]
898 fn test_cmd_completion_zsh_nonempty() {
899 let mut cmd = make_test_cmd("apcore-cli");
900 let output = cmd_completion(Shell::Zsh, "apcore-cli", &mut cmd);
901 assert!(
902 !output.is_empty(),
903 "zsh completion output must not be empty"
904 );
905 }
906
907 #[test]
908 fn test_cmd_completion_fish_nonempty() {
909 let mut cmd = make_test_cmd("apcore-cli");
910 let output = cmd_completion(Shell::Fish, "apcore-cli", &mut cmd);
911 assert!(
912 !output.is_empty(),
913 "fish completion output must not be empty"
914 );
915 }
916
917 #[test]
918 fn test_cmd_completion_elvish_nonempty() {
919 let mut cmd = make_test_cmd("apcore-cli");
920 let output = cmd_completion(Shell::Elvish, "apcore-cli", &mut cmd);
921 assert!(
922 !output.is_empty(),
923 "elvish completion output must not be empty"
924 );
925 }
926
927 #[test]
928 fn test_cmd_completion_bash_contains_prog_name() {
929 let mut cmd = make_test_cmd("my-tool");
930 let output = cmd_completion(Shell::Bash, "my-tool", &mut cmd);
931 assert!(
932 output.contains("my-tool") || output.contains("my_tool"),
933 "bash completion must reference the program name"
934 );
935 }
936
937 #[test]
938 fn test_completion_command_has_shell_arg() {
939 let cmd = completion_command();
940 let arg = cmd.get_arguments().find(|a| a.get_id() == "shell");
941 assert!(
942 arg.is_some(),
943 "completion_command must have a 'shell' argument"
944 );
945 }
946
947 #[test]
948 fn test_completion_command_name() {
949 let cmd = completion_command();
950 assert_eq!(cmd.get_name(), "completion");
951 }
952
953 fn make_exec_cmd() -> clap::Command {
956 clap::Command::new("exec")
957 .about("Execute an apcore module")
958 .arg(
959 clap::Arg::new("module_id")
960 .value_name("MODULE_ID")
961 .required(true)
962 .help("Module ID to execute"),
963 )
964 .arg(
965 clap::Arg::new("format")
966 .long("format")
967 .value_name("FORMAT")
968 .help("Output format")
969 .default_value("table"),
970 )
971 }
972
973 #[test]
974 fn test_build_synopsis_no_cmd() {
975 let synopsis = build_synopsis(None, "apcore-cli", "exec");
976 assert!(synopsis.contains("apcore-cli"));
977 assert!(synopsis.contains("exec"));
978 }
979
980 #[test]
981 fn test_build_synopsis_required_positional_no_brackets() {
982 let cmd = make_exec_cmd();
983 let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
984 assert!(synopsis.contains("MODULE_ID"), "synopsis: {synopsis}");
985 assert!(
986 !synopsis.contains("[\\fIMODULE_ID\\fR]"),
987 "required arg must not have brackets"
988 );
989 }
990
991 #[test]
992 fn test_build_synopsis_optional_option_has_brackets() {
993 let cmd = make_exec_cmd();
994 let synopsis = build_synopsis(Some(&cmd), "apcore-cli", "exec");
995 assert!(
996 synopsis.contains('['),
997 "optional option must be wrapped in brackets"
998 );
999 }
1000
1001 #[test]
1002 fn test_generate_man_page_contains_th() {
1003 let cmd = make_exec_cmd();
1004 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1005 assert!(page.contains(".TH"), "man page must have .TH header");
1006 }
1007
1008 #[test]
1009 fn test_generate_man_page_contains_sh_name() {
1010 let cmd = make_exec_cmd();
1011 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1012 assert!(page.contains(".SH NAME"), "man page must have NAME section");
1013 }
1014
1015 #[test]
1016 fn test_generate_man_page_contains_sh_synopsis() {
1017 let cmd = make_exec_cmd();
1018 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1019 assert!(
1020 page.contains(".SH SYNOPSIS"),
1021 "man page must have SYNOPSIS section"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_generate_man_page_contains_exit_codes() {
1027 let cmd = make_exec_cmd();
1028 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1029 assert!(
1030 page.contains(".SH EXIT CODES"),
1031 "man page must have EXIT CODES section"
1032 );
1033 assert!(page.contains("\\fB0\\fR"), "must contain exit code 0");
1034 assert!(page.contains("\\fB44\\fR"), "must contain exit code 44");
1035 assert!(page.contains("\\fB130\\fR"), "must contain exit code 130");
1036 }
1037
1038 #[test]
1039 fn test_generate_man_page_contains_environment() {
1040 let cmd = make_exec_cmd();
1041 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1042 assert!(
1043 page.contains(".SH ENVIRONMENT"),
1044 "man page must have ENVIRONMENT section"
1045 );
1046 assert!(page.contains("APCORE_EXTENSIONS_ROOT"));
1047 assert!(page.contains("APCORE_CLI_LOGGING_LEVEL"));
1048 }
1049
1050 #[test]
1051 fn test_generate_man_page_contains_see_also() {
1052 let cmd = make_exec_cmd();
1053 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1054 assert!(
1055 page.contains(".SH SEE ALSO"),
1056 "man page must have SEE ALSO section"
1057 );
1058 assert!(page.contains("apcore-cli"));
1059 }
1060
1061 #[test]
1062 fn test_generate_man_page_th_includes_prog_and_version() {
1063 let cmd = make_exec_cmd();
1064 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1065 let th_line = page.lines().find(|l| l.starts_with(".TH")).unwrap();
1066 assert!(
1067 th_line.contains("APCORE-CLI-EXEC"),
1068 "TH must contain uppercased title"
1069 );
1070 assert!(th_line.contains("0.2.0"), "TH must contain version");
1071 }
1072
1073 #[test]
1074 fn test_generate_man_page_name_uses_description() {
1075 let cmd = make_exec_cmd();
1076 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1077 assert!(
1078 page.contains("Execute an apcore module"),
1079 "NAME must use about text"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_generate_man_page_no_description_section_when_no_long_help() {
1085 let cmd = make_exec_cmd();
1086 let page = generate_man_page("exec", Some(&cmd), "apcore-cli", "0.2.0");
1087 assert!(page.contains(".SH DESCRIPTION"));
1088 }
1089
1090 #[test]
1091 fn test_cmd_man_known_builtin_returns_ok() {
1092 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1093 let result = cmd_man("list", &root, "apcore-cli", "0.2.0");
1094 assert!(result.is_ok(), "known builtin 'list' must return Ok");
1095 }
1096
1097 #[test]
1098 fn test_cmd_man_registered_subcommand_returns_ok() {
1099 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1100 let result = cmd_man("exec", &root, "apcore-cli", "0.2.0");
1101 assert!(
1102 result.is_ok(),
1103 "registered subcommand 'exec' must return Ok"
1104 );
1105 let page = result.unwrap();
1106 assert!(page.contains(".TH"));
1107 }
1108
1109 #[test]
1110 fn test_cmd_man_unknown_command_returns_err() {
1111 let root = clap::Command::new("apcore-cli");
1112 let result = cmd_man("nonexistent", &root, "apcore-cli", "0.2.0");
1113 assert!(result.is_err());
1114 match result.unwrap_err() {
1115 ShellError::UnknownCommand(name) => assert_eq!(name, "nonexistent"),
1116 }
1117 }
1118
1119 #[test]
1120 fn test_cmd_man_exec_contains_options_section() {
1121 let root = clap::Command::new("apcore-cli").subcommand(make_exec_cmd());
1122 let page = cmd_man("exec", &root, "apcore-cli", "0.2.0").unwrap();
1123 assert!(
1124 page.contains(".SH OPTIONS"),
1125 "exec man page must have OPTIONS section"
1126 );
1127 }
1128
1129 #[test]
1132 fn test_roff_escape_backslash() {
1133 assert_eq!(roff_escape("a\\b"), "a\\\\b");
1134 }
1135
1136 #[test]
1137 fn test_roff_escape_hyphen() {
1138 assert_eq!(roff_escape("foo-bar"), "foo\\-bar");
1139 }
1140
1141 #[test]
1142 fn test_roff_escape_single_quote() {
1143 assert_eq!(roff_escape("it's"), "it\\(aqs");
1144 }
1145
1146 #[test]
1149 fn test_has_man_flag_present() {
1150 let args = vec!["--man".to_string()];
1151 assert!(has_man_flag(&args));
1152 }
1153
1154 #[test]
1155 fn test_has_man_flag_absent() {
1156 let args = vec!["--help".to_string()];
1157 assert!(!has_man_flag(&args));
1158 }
1159
1160 #[test]
1163 fn test_build_program_man_page_basic() {
1164 let cmd = clap::Command::new("t")
1165 .about("Test")
1166 .subcommand(clap::Command::new("sub").about("A sub"));
1167 let roff = build_program_man_page(&cmd, "t", "0.1.0", None, None);
1168 assert!(roff.contains(".TH \"T\""));
1169 assert!(roff.contains(".SH COMMANDS"));
1170 assert!(roff.contains("sub"));
1171 assert!(roff.contains(".SH EXIT CODES"));
1172 }
1173
1174 #[test]
1175 fn test_build_program_man_page_custom_description() {
1176 let cmd = clap::Command::new("t").about("Default");
1177 let roff = build_program_man_page(&cmd, "t", "0.1.0", Some("Custom"), None);
1178 assert!(roff.contains("Custom"));
1179 }
1180
1181 #[test]
1184 fn test_register_shell_commands_adds_completion() {
1185 let root = Command::new("apcore-cli");
1186 let cmd = register_shell_commands(root, "apcore-cli");
1187 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
1188 assert!(
1189 names.contains(&"completion"),
1190 "must have 'completion' subcommand, got {names:?}"
1191 );
1192 }
1193
1194 #[test]
1195 fn test_register_shell_commands_adds_man() {
1196 let root = Command::new("apcore-cli");
1197 let cmd = register_shell_commands(root, "apcore-cli");
1198 let names: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
1199 assert!(
1200 names.contains(&"man"),
1201 "must have 'man' subcommand, got {names:?}"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_completion_bash_outputs_script() {
1207 let cmd = completion_command();
1208 let positionals: Vec<&str> = cmd
1209 .get_positionals()
1210 .filter_map(|a| a.get_id().as_str().into())
1211 .collect();
1212 assert!(
1214 !positionals.is_empty() || cmd.get_arguments().any(|a| a.get_id() == "shell"),
1215 "completion must have shell arg, got {positionals:?}"
1216 );
1217 }
1218
1219 #[test]
1220 fn test_completion_zsh_outputs_script() {
1221 let cmd = completion_command();
1222 let shell_arg = cmd
1223 .get_arguments()
1224 .find(|a| a.get_id() == "shell")
1225 .expect("shell argument must exist");
1226 let possible = shell_arg.get_possible_values();
1227 let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1228 assert!(values.contains(&"zsh"), "zsh must be a valid SHELL value");
1229 }
1230
1231 #[test]
1232 fn test_completion_invalid_shell_exits_nonzero() {
1233 let cmd = completion_command();
1234 let shell_arg = cmd
1235 .get_arguments()
1236 .find(|a| a.get_id() == "shell")
1237 .expect("shell argument must exist");
1238 let possible = shell_arg.get_possible_values();
1239 let values: Vec<&str> = possible.iter().map(|v| v.get_name()).collect();
1240 assert!(
1241 !values.contains(&"invalid_shell"),
1242 "invalid_shell must not be accepted"
1243 );
1244 }
1245}