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