1use crate::parser::{ParseError, Parser};
32use crate::query::Registry;
33use crate::render::{DefaultRenderer, Renderer};
34use crate::resolver::Resolver;
35
36#[derive(Debug, thiserror::Error)]
38pub enum CliError {
39 #[error(transparent)]
44 Parse(#[from] ParseError),
45 #[error("command `{0}` has no handler registered")]
49 NoHandler(String),
50 #[error("handler error: {0}")]
54 Handler(#[from] Box<dyn std::error::Error + Send + Sync>),
55}
56
57pub struct Cli {
93 registry: Registry,
94 app_name: String,
95 version: Option<String>,
96 middlewares: Vec<Box<dyn crate::middleware::Middleware>>,
97 renderer: Box<dyn Renderer>,
98 query_support: bool,
99 warn_missing_dry_run: bool,
102}
103
104impl Cli {
105 pub fn new(commands: Vec<crate::model::Command>) -> Self {
112 Self {
113 registry: Registry::new(commands),
114 app_name: String::new(),
115 version: None,
116 middlewares: vec![],
117 renderer: Box::new(DefaultRenderer),
118 query_support: false,
119 warn_missing_dry_run: false,
120 }
121 }
122
123 pub fn app_name(mut self, name: impl Into<String>) -> Self {
127 self.app_name = name.into();
128 self
129 }
130
131 pub fn version(mut self, version: impl Into<String>) -> Self {
135 self.version = Some(version.into());
136 self
137 }
138
139 pub fn with_middleware<M: crate::middleware::Middleware + 'static>(mut self, m: M) -> Self {
161 self.middlewares.push(Box::new(m));
162 self
163 }
164
165 pub fn with_renderer<R: Renderer + 'static>(mut self, renderer: R) -> Self {
186 self.renderer = Box::new(renderer);
187 self
188 }
189
190 pub fn with_query_support(mut self) -> Self {
217 self.query_support = true;
218 let query_cmd = crate::model::Command::builder("query")
220 .summary("Query command metadata (agent discovery)")
221 .description(
222 "Structured JSON output for agent discovery. \
223 `query commands` lists all commands; `query <name>` returns metadata for one. \
224 Use `--fields <csv>` to request only specific top-level fields, reducing output \
225 size for agents that only need a subset of command metadata.",
226 )
227 .flag(
228 crate::model::Flag::builder("fields")
229 .description(
230 "Comma-separated list of top-level fields to include in JSON output \
231 (e.g. `canonical,summary,examples`). When omitted all fields are returned.",
232 )
233 .takes_value()
234 .build()
235 .expect("built-in fields flag should always build"),
236 )
237 .example(crate::model::Example::new(
238 "query commands",
239 "List all commands as JSON",
240 ))
241 .example(crate::model::Example::new(
242 "query deploy",
243 "Get metadata for the deploy command",
244 ))
245 .example(crate::model::Example::new(
246 "query deploy --fields canonical,summary,examples",
247 "Get only canonical name, summary, and examples for the deploy command",
248 ))
249 .example(crate::model::Example::new(
250 "query commands --fields canonical,summary",
251 "List all commands showing only canonical name and summary",
252 ))
253 .build()
254 .expect("built-in query command should always build");
255 self.registry.push(query_cmd);
256 self
257 }
258
259 pub fn warn_missing_dry_run(mut self, enabled: bool) -> Self {
290 self.warn_missing_dry_run = enabled;
291 self
292 }
293
294 pub fn run(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> Result<(), CliError> {
333 let argv: Vec<String> = args.into_iter().map(|a| a.as_ref().to_owned()).collect();
334 let argv_refs: Vec<&str> = argv.iter().map(String::as_str).collect();
335
336 if self.query_support && argv_refs.first().copied() == Some("query") {
338 return self.handle_query(&argv_refs[1..]);
339 }
340
341 if argv_refs.iter().any(|a| *a == "--help" || *a == "-h") {
343 let remaining: Vec<&str> = argv_refs
345 .iter()
346 .copied()
347 .filter(|a| *a != "--help" && *a != "-h")
348 .collect();
349
350 let help_text = self.resolve_help_text(&remaining);
351 print!("{}", help_text);
352 return Ok(());
353 }
354
355 if argv_refs.iter().any(|a| *a == "--version" || *a == "-V") {
357 match &self.version {
358 Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
359 Some(v) => println!("{}", v),
360 None => println!("(no version set)"),
361 }
362 return Ok(());
363 }
364
365 if argv_refs.is_empty() {
367 print!(
368 "{}",
369 self.renderer
370 .render_subcommand_list(self.registry.commands())
371 );
372 return Ok(());
373 }
374
375 let parser = Parser::new(self.registry.commands());
377 match parser.parse(&argv_refs) {
378 Ok(parsed) => {
379 if self.warn_missing_dry_run
381 && parsed.command.mutating
382 && !parsed.command.flags.iter().any(|f| f.name == "dry-run")
383 {
384 eprintln!(
385 "warning: mutating command '{}' has no --dry-run flag defined",
386 parsed.command.canonical
387 );
388 }
389
390 for mw in &self.middlewares {
392 mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
393 }
394
395 let handler_result = match &parsed.command.handler {
397 Some(handler) => {
398 handler(&parsed).map_err(|e| {
401 let msg = e.to_string();
404 let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
405 CliError::Handler(boxed)
406 })
407 }
408 None => Err(CliError::NoHandler(parsed.command.canonical.to_string())),
409 };
410
411 let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
413 match &handler_result {
414 Ok(()) => Ok(()),
415 Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
416 e.to_string(),
417 )),
418 };
419 for mw in &self.middlewares {
420 mw.after_dispatch(&parsed, &handler_result_for_mw);
421 }
422
423 handler_result
424 }
425 Err(parse_err) => {
426 for mw in &self.middlewares {
428 mw.on_parse_error(&parse_err);
429 }
430
431 eprintln!("error: {}", parse_err);
432 if let crate::parser::ParseError::Resolve(
433 crate::resolver::ResolveError::Unknown {
434 ref suggestions, ..
435 },
436 ) = parse_err
437 {
438 if !suggestions.is_empty() {
439 eprintln!("Did you mean one of: {}", suggestions.join(", "));
440 }
441 }
442 let help_text = self.resolve_help_text(&argv_refs);
444 eprint!("{}", help_text);
445 Err(CliError::Parse(parse_err))
446 }
447 }
448 }
449
450 pub fn run_env_args(&self) -> Result<(), CliError> {
459 self.run(std::env::args().skip(1))
460 }
461
462 pub fn run_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
489 match self.run(args) {
490 Ok(()) => std::process::exit(0),
491 Err(e) => {
492 eprintln!("error: {}", e);
493 std::process::exit(1);
494 }
495 }
496 }
497
498 pub fn run_env_args_and_exit(&self) -> ! {
500 self.run_and_exit(std::env::args().skip(1))
501 }
502
503 #[cfg(feature = "async")]
507 pub async fn run_async_and_exit(&self, args: impl IntoIterator<Item = impl AsRef<str>>) -> ! {
508 match self.run_async(args).await {
509 Ok(()) => std::process::exit(0),
510 Err(e) => {
511 eprintln!("error: {}", e);
512 std::process::exit(1);
513 }
514 }
515 }
516
517 #[cfg(feature = "async")]
519 pub async fn run_env_args_async_and_exit(&self) -> ! {
520 self.run_async_and_exit(std::env::args().skip(1)).await
521 }
522
523 #[cfg(feature = "async")]
541 pub async fn run_async(
542 &self,
543 args: impl IntoIterator<Item = impl AsRef<str>>,
544 ) -> Result<(), CliError> {
545 let args: Vec<String> = args.into_iter().map(|a| a.as_ref().to_string()).collect();
546 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
547
548 if self.query_support && argv.first().copied() == Some("query") {
550 let refs: Vec<&str> = argv.to_vec();
551 return self.handle_query(&refs[1..]);
552 }
553
554 if argv.iter().any(|a| *a == "--help" || *a == "-h") {
556 let remaining: Vec<&str> = argv
557 .iter()
558 .copied()
559 .filter(|a| *a != "--help" && *a != "-h")
560 .collect();
561 let help_text = self.resolve_help_text(&remaining);
562 print!("{}", help_text);
563 return Ok(());
564 }
565
566 if argv.iter().any(|a| *a == "--version" || *a == "-V") {
568 match &self.version {
569 Some(v) if !self.app_name.is_empty() => println!("{} {}", self.app_name, v),
570 Some(v) => println!("{}", v),
571 None => println!("(no version set)"),
572 }
573 return Ok(());
574 }
575
576 if argv.is_empty() {
578 print!(
579 "{}",
580 self.renderer
581 .render_subcommand_list(self.registry.commands())
582 );
583 return Ok(());
584 }
585
586 let parser = Parser::new(self.registry.commands());
588 match parser.parse(&argv) {
589 Ok(parsed) => {
590 if self.warn_missing_dry_run
592 && parsed.command.mutating
593 && !parsed.command.flags.iter().any(|f| f.name == "dry-run")
594 {
595 eprintln!(
596 "warning: mutating command '{}' has no --dry-run flag defined",
597 parsed.command.canonical
598 );
599 }
600
601 for mw in &self.middlewares {
603 mw.before_dispatch(&parsed).map_err(CliError::Handler)?;
604 }
605
606 let handler_result = if let Some(ref async_handler) = parsed.command.async_handler {
608 async_handler(&parsed).await.map_err(|e| {
609 let msg = e.to_string();
610 let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
611 CliError::Handler(boxed)
612 })
613 } else if let Some(ref handler) = parsed.command.handler {
614 handler(&parsed).map_err(|e| {
615 let msg = e.to_string();
616 let boxed: Box<dyn std::error::Error + Send + Sync> = msg.into();
617 CliError::Handler(boxed)
618 })
619 } else {
620 Err(CliError::NoHandler(parsed.command.canonical.clone()))
621 };
622
623 let handler_result_for_mw: Result<(), Box<dyn std::error::Error + Send + Sync>> =
625 match &handler_result {
626 Ok(()) => Ok(()),
627 Err(e) => Err(Box::<dyn std::error::Error + Send + Sync>::from(
628 e.to_string(),
629 )),
630 };
631 for mw in &self.middlewares {
632 mw.after_dispatch(&parsed, &handler_result_for_mw);
633 }
634
635 handler_result
636 }
637 Err(parse_err) => {
638 for mw in &self.middlewares {
640 mw.on_parse_error(&parse_err);
641 }
642
643 eprintln!("error: {}", parse_err);
644 if let crate::parser::ParseError::Resolve(
645 crate::resolver::ResolveError::Unknown {
646 ref suggestions, ..
647 },
648 ) = parse_err
649 {
650 if !suggestions.is_empty() {
651 eprintln!("Did you mean one of: {}", suggestions.join(", "));
652 }
653 }
654 let help_text = self.resolve_help_text(&argv);
655 eprint!("{}", help_text);
656 Err(CliError::Parse(parse_err))
657 }
658 }
659 }
660
661 #[cfg(feature = "async")]
663 pub async fn run_env_args_async(&self) -> Result<(), CliError> {
664 self.run_async(std::env::args().skip(1)).await
665 }
666
667 fn handle_query(&self, args: &[&str]) -> Result<(), CliError> {
670 let mut stream = false;
673 let mut fields_opt: Option<String> = None;
674 let mut positional: Vec<&str> = Vec::new();
675
676 let mut iter = args.iter().copied().peekable();
677 while let Some(arg) = iter.next() {
678 if arg == "--json" {
679 } else if arg == "--stream" {
681 stream = true;
682 } else if arg == "--fields" {
683 if let Some(val) = iter.next() {
684 fields_opt = Some(val.to_owned());
685 }
686 } else if let Some(val) = arg.strip_prefix("--fields=") {
687 fields_opt = Some(val.to_owned());
688 } else {
689 positional.push(arg);
690 }
691 }
692 let args = positional.as_slice();
693
694 let field_strings: Vec<String> = fields_opt
695 .as_deref()
696 .unwrap_or("")
697 .split(',')
698 .map(|f| f.trim().to_owned())
699 .filter(|f| !f.is_empty())
700 .collect();
701 let fields: Vec<&str> = field_strings.iter().map(String::as_str).collect();
702
703 match args.first().copied() {
704 None | Some("commands") => {
706 if stream {
707 let ndjson = self
708 .registry
709 .to_ndjson_with_fields(&fields)
710 .map_err(|e| {
711 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
712 e.to_string(),
713 ))
714 })?;
715 print!("{}", ndjson);
716 } else {
717 let json = self
718 .registry
719 .to_json_with_fields(&fields)
720 .map_err(|e| {
721 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
722 e.to_string(),
723 ))
724 })?;
725 println!("{}", json);
726 }
727 Ok(())
728 }
729 Some("examples") => {
731 let name = args.get(1).copied().ok_or_else(|| {
732 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
733 "usage: query examples <command-name>",
734 ))
735 })?;
736 let cmd = self
737 .registry
738 .get_command(name)
739 .or_else(|| {
740 let resolver = crate::resolver::Resolver::new(self.registry.commands());
741 resolver.resolve(name).ok()
742 })
743 .ok_or_else(|| {
744 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
745 format!("unknown command: `{}`", name),
746 ))
747 })?;
748 let json = serde_json::to_string_pretty(&cmd.examples).map_err(|e| {
749 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
750 e.to_string(),
751 ))
752 })?;
753 println!("{}", json);
754 Ok(())
755 }
756 Some(name) => {
758 let cmd = self.registry.get_command(name);
760 if let Some(cmd) = cmd {
761 if stream {
762 let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
763 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
764 e.to_string(),
765 ))
766 })?;
767 println!("{}", line);
768 } else {
769 let json =
770 crate::query::command_to_json_with_fields(cmd, &fields).map_err(|e| {
771 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
772 e.to_string(),
773 ))
774 })?;
775 println!("{}", json);
776 }
777 return Ok(());
778 }
779
780 let resolver = crate::resolver::Resolver::new(self.registry.commands());
782 match resolver.resolve(name) {
783 Ok(cmd) => {
784 if stream {
785 let line = crate::query::command_to_ndjson(cmd).map_err(|e| {
786 CliError::Handler(Box::<dyn std::error::Error + Send + Sync>::from(
787 e.to_string(),
788 ))
789 })?;
790 println!("{}", line);
791 } else {
792 let json = crate::query::command_to_json_with_fields(cmd, &fields)
793 .map_err(|e| {
794 CliError::Handler(
795 Box::<dyn std::error::Error + Send + Sync>::from(
796 e.to_string(),
797 ),
798 )
799 })?;
800 println!("{}", json);
801 }
802 Ok(())
803 }
804 Err(crate::resolver::ResolveError::Ambiguous { input, candidates }) => {
805 let json = serde_json::json!({
807 "error": "ambiguous",
808 "input": input,
809 "candidates": candidates,
810 });
811 println!("{}", json);
812 Ok(())
813 }
814 Err(crate::resolver::ResolveError::Unknown { .. }) => Err(CliError::Handler(
815 Box::<dyn std::error::Error + Send + Sync>::from(format!(
816 "unknown command: `{}`",
817 name
818 )),
819 )),
820 }
821 }
822 }
823 }
824
825 fn resolve_help_text(&self, argv: &[&str]) -> String {
829 if argv.is_empty() {
831 return self
832 .renderer
833 .render_subcommand_list(self.registry.commands());
834 }
835
836 let words: Vec<&str> = argv
838 .iter()
839 .copied()
840 .filter(|a| !a.starts_with('-'))
841 .collect();
842
843 if words.is_empty() {
844 return self
845 .renderer
846 .render_subcommand_list(self.registry.commands());
847 }
848
849 let resolver = Resolver::new(self.registry.commands());
851 let top_cmd = match resolver.resolve(words[0]) {
852 Ok(cmd) => cmd,
853 Err(_) => {
854 return self
855 .renderer
856 .render_subcommand_list(self.registry.commands())
857 }
858 };
859
860 let mut current = top_cmd;
862 for word in words.iter().skip(1) {
863 if current.subcommands.is_empty() {
864 break;
865 }
866 let sub_resolver = Resolver::new(¤t.subcommands);
867 match sub_resolver.resolve(word) {
868 Ok(sub) => current = sub,
869 Err(_) => break,
870 }
871 }
872
873 self.renderer.render_help(current)
874 }
875}
876
877#[cfg(test)]
880mod tests {
881 use super::*;
882 use crate::model::Command;
883 use std::sync::{Arc, Mutex};
884
885 fn make_cli_no_handler() -> Cli {
886 let cmd = Command::builder("greet")
887 .summary("Say hello")
888 .build()
889 .unwrap();
890 Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
891 }
892
893 fn make_cli_with_handler(called: Arc<Mutex<bool>>) -> Cli {
894 let cmd = Command::builder("greet")
895 .summary("Say hello")
896 .handler(Arc::new(move |_parsed| {
897 *called.lock().unwrap() = true;
898 Ok(())
899 }))
900 .build()
901 .unwrap();
902 Cli::new(vec![cmd]).app_name("testapp").version("1.2.3")
903 }
904
905 #[test]
906 fn test_run_empty_args() {
907 let cli = make_cli_no_handler();
908 let result = cli.run(std::iter::empty::<&str>());
909 assert!(result.is_ok(), "empty args should return Ok");
910 }
911
912 #[test]
913 fn test_run_help_flag() {
914 let cli = make_cli_no_handler();
915 let result = cli.run(["--help"]);
916 assert!(result.is_ok(), "--help should return Ok");
917 }
918
919 #[test]
920 fn test_run_help_flag_short() {
921 let cli = make_cli_no_handler();
922 let result = cli.run(["-h"]);
923 assert!(result.is_ok(), "-h should return Ok");
924 }
925
926 #[test]
927 fn test_run_version_flag() {
928 let cli = make_cli_no_handler();
929 let result = cli.run(["--version"]);
930 assert!(result.is_ok(), "--version should return Ok");
931 }
932
933 #[test]
934 fn test_run_version_flag_short() {
935 let cli = make_cli_no_handler();
936 let result = cli.run(["-V"]);
937 assert!(result.is_ok(), "-V should return Ok");
938 }
939
940 #[test]
941 fn test_run_no_handler() {
942 let cli = make_cli_no_handler();
943 let result = cli.run(["greet"]);
944 assert!(
945 matches!(result, Err(CliError::NoHandler(ref name)) if name == "greet"),
946 "expected NoHandler(\"greet\"), got {:?}",
947 result
948 );
949 }
950
951 #[test]
952 fn test_run_with_handler() {
953 let called = Arc::new(Mutex::new(false));
954 let cli = make_cli_with_handler(called.clone());
955 let result = cli.run(["greet"]);
956 assert!(result.is_ok(), "handler should succeed, got {:?}", result);
957 assert!(*called.lock().unwrap(), "handler should have been called");
958 }
959
960 #[test]
961 fn test_run_unknown_command() {
962 let cli = make_cli_no_handler();
963 let result = cli.run(["unknowncmd"]);
964 assert!(
965 matches!(result, Err(CliError::Parse(_))),
966 "unknown command should yield Parse error, got {:?}",
967 result
968 );
969 }
970
971 #[test]
972 fn test_run_handler_error_wrapped() {
973 use std::sync::Arc;
974 let cmd = crate::model::Command::builder("fail")
975 .handler(Arc::new(|_| {
976 Err(Box::<dyn std::error::Error>::from("something went wrong"))
977 }))
978 .build()
979 .unwrap();
980 let cli = super::Cli::new(vec![cmd]);
981 let result = cli.run(["fail"]);
982 assert!(result.is_err());
983 match result {
984 Err(super::CliError::Handler(e)) => {
985 assert!(e.to_string().contains("something went wrong"));
986 }
987 other => panic!("expected CliError::Handler, got {:?}", other),
988 }
989 }
990
991 #[test]
992 fn test_run_command_named_help_dispatches_correctly() {
993 use std::sync::atomic::{AtomicBool, Ordering};
997 use std::sync::Arc;
998 let called = Arc::new(AtomicBool::new(false));
999 let called2 = called.clone();
1000 let cmd = crate::model::Command::builder("help")
1001 .handler(Arc::new(move |_| {
1002 called2.store(true, Ordering::SeqCst);
1003 Ok(())
1004 }))
1005 .build()
1006 .unwrap();
1007 let cli = super::Cli::new(vec![cmd]);
1008 cli.run(["help"]).unwrap();
1009 assert!(
1010 called.load(Ordering::SeqCst),
1011 "handler should have been called"
1012 );
1013 }
1014
1015 #[test]
1016 fn test_middleware_before_dispatch_called() {
1017 use crate::middleware::Middleware;
1018 use std::sync::atomic::{AtomicBool, Ordering};
1019 use std::sync::Arc;
1020
1021 struct Flag(Arc<AtomicBool>);
1022 impl Middleware for Flag {
1023 fn before_dispatch(
1024 &self,
1025 _: &crate::model::ParsedCommand<'_>,
1026 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1027 self.0.store(true, Ordering::SeqCst);
1028 Ok(())
1029 }
1030 }
1031
1032 let called = Arc::new(AtomicBool::new(false));
1033 let handler_called = Arc::new(AtomicBool::new(false));
1034 let handler_called2 = handler_called.clone();
1035
1036 let cmd = crate::model::Command::builder("run")
1037 .handler(std::sync::Arc::new(move |_| {
1038 handler_called2.store(true, Ordering::SeqCst);
1039 Ok(())
1040 }))
1041 .build()
1042 .unwrap();
1043
1044 let cli = super::Cli::new(vec![cmd]).with_middleware(Flag(called.clone()));
1045 cli.run(["run"]).unwrap();
1046
1047 assert!(called.load(Ordering::SeqCst));
1048 assert!(handler_called.load(Ordering::SeqCst));
1049 }
1050
1051 #[test]
1052 fn test_middleware_can_abort_dispatch() {
1053 use crate::middleware::Middleware;
1054 struct Aborter;
1055 impl Middleware for Aborter {
1056 fn before_dispatch(
1057 &self,
1058 _: &crate::model::ParsedCommand<'_>,
1059 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1060 Err("aborted by middleware".into())
1061 }
1062 }
1063
1064 let cmd = crate::model::Command::builder("run")
1065 .handler(std::sync::Arc::new(|_| panic!("should not be called")))
1066 .build()
1067 .unwrap();
1068
1069 let cli = super::Cli::new(vec![cmd]).with_middleware(Aborter);
1070 assert!(cli.run(["run"]).is_err());
1071 }
1072
1073 #[test]
1074 fn test_query_commands_outputs_json() {
1075 use crate::model::Command;
1076 let cli = super::Cli::new(vec![
1077 Command::builder("deploy")
1078 .summary("Deploy")
1079 .build()
1080 .unwrap(),
1081 Command::builder("status")
1082 .summary("Status")
1083 .build()
1084 .unwrap(),
1085 ])
1086 .with_query_support();
1087
1088 assert!(cli.run(["query", "commands"]).is_ok());
1091 }
1092
1093 #[test]
1094 fn test_query_named_command_outputs_json() {
1095 use crate::model::Command;
1096 let cli = super::Cli::new(vec![Command::builder("deploy")
1097 .summary("Deploy svc")
1098 .build()
1099 .unwrap()])
1100 .with_query_support();
1101
1102 assert!(cli.run(["query", "deploy"]).is_ok());
1103 }
1104
1105 #[test]
1106 fn test_query_unknown_command_errors() {
1107 use crate::model::Command;
1108 let cli =
1109 super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1110
1111 assert!(cli.run(["query", "nonexistent"]).is_err());
1112 }
1113
1114 #[test]
1115 fn test_query_meta_command_appears_in_registry() {
1116 use crate::model::Command;
1117 let cli =
1118 super::Cli::new(vec![Command::builder("run").build().unwrap()]).with_query_support();
1119
1120 assert!(cli.registry.get_command("query").is_some());
1122 }
1123
1124 #[test]
1125 fn test_query_with_json_flag() {
1126 use crate::model::Command;
1127 let cli = super::Cli::new(vec![Command::builder("deploy")
1128 .summary("Deploy")
1129 .build()
1130 .unwrap()])
1131 .with_query_support();
1132 assert!(cli.run(["query", "deploy", "--json"]).is_ok());
1134 assert!(cli.run(["query", "commands", "--json"]).is_ok());
1135 }
1136
1137 #[test]
1138 fn test_query_ambiguous_returns_structured_json() {
1139 use crate::model::Command;
1140 let cli = super::Cli::new(vec![
1142 Command::builder("deploy")
1143 .summary("Deploy")
1144 .build()
1145 .unwrap(),
1146 Command::builder("describe")
1147 .summary("Describe")
1148 .build()
1149 .unwrap(),
1150 ])
1151 .with_query_support();
1152
1153 let result = cli.run(["query", "dep"]);
1156 assert!(
1157 result.is_ok(),
1158 "ambiguous query should return Ok(()) with JSON on stdout, got {:?}",
1159 result
1160 );
1161 }
1162
1163 #[test]
1164 fn test_query_examples_returns_examples() {
1165 use crate::model::{Command, Example};
1166 let cli = super::Cli::new(vec![Command::builder("deploy")
1167 .summary("Deploy svc")
1168 .example(Example::new(
1169 "Deploy to production",
1170 "deploy api --env prod",
1171 ))
1172 .build()
1173 .unwrap()])
1174 .with_query_support();
1175
1176 let result = cli.run(["query", "examples", "deploy"]);
1177 assert!(
1178 result.is_ok(),
1179 "query examples for known command should return Ok(()), got {:?}",
1180 result
1181 );
1182 }
1183
1184 #[test]
1185 fn test_query_examples_unknown_errors() {
1186 use crate::model::Command;
1187 let cli =
1188 super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1189
1190 let result = cli.run(["query", "examples", "nonexistent"]);
1191 assert!(
1192 result.is_err(),
1193 "query examples for unknown command should return Err, got {:?}",
1194 result
1195 );
1196 }
1197
1198 #[test]
1199 fn test_warn_missing_dry_run_enabled_dispatches_ok() {
1200 use crate::model::Command;
1202 use std::sync::atomic::{AtomicBool, Ordering};
1203 use std::sync::Arc;
1204
1205 let called = Arc::new(AtomicBool::new(false));
1206 let called2 = called.clone();
1207
1208 let cmd = Command::builder("delete")
1209 .summary("Delete a resource")
1210 .mutating()
1211 .handler(Arc::new(move |_| {
1212 called2.store(true, Ordering::SeqCst);
1213 Ok(())
1214 }))
1215 .build()
1216 .unwrap();
1217
1218 let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
1219 let result = cli.run(["delete"]);
1221 assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1222 assert!(called.load(Ordering::SeqCst), "handler should have been called");
1223 }
1224
1225 #[test]
1226 fn test_warn_missing_dry_run_with_dry_run_flag_no_warn() {
1227 use crate::model::{Command, Flag};
1229 use std::sync::Arc;
1230
1231 let cmd = Command::builder("delete")
1232 .summary("Delete a resource")
1233 .mutating()
1234 .flag(Flag::builder("dry-run").description("Simulate").build().unwrap())
1235 .handler(Arc::new(|_| Ok(())))
1236 .build()
1237 .unwrap();
1238
1239 let cli = super::Cli::new(vec![cmd]).warn_missing_dry_run(true);
1240 let result = cli.run(["delete"]);
1243 assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1244 }
1245
1246 #[test]
1247 fn test_warn_missing_dry_run_disabled_no_effect() {
1248 use crate::model::Command;
1250 use std::sync::Arc;
1251
1252 let cmd = Command::builder("delete")
1253 .summary("Delete a resource")
1254 .mutating()
1255 .handler(Arc::new(|_| Ok(())))
1256 .build()
1257 .unwrap();
1258
1259 let cli = super::Cli::new(vec![cmd]);
1261 let result = cli.run(["delete"]);
1262 assert!(result.is_ok(), "dispatch should succeed, got {:?}", result);
1263 }
1264
1265 #[cfg(feature = "async")]
1268 #[tokio::test]
1269 async fn test_run_async_empty_args() {
1270 let cli = make_cli_no_handler();
1271 let result = cli.run_async(std::iter::empty::<&str>()).await;
1272 assert!(
1273 result.is_ok(),
1274 "empty args should return Ok, got {:?}",
1275 result
1276 );
1277 }
1278
1279 #[cfg(feature = "async")]
1280 #[tokio::test]
1281 async fn test_run_async_help_flag() {
1282 let cli = make_cli_no_handler();
1283 let result = cli.run_async(["--help"]).await;
1284 assert!(result.is_ok(), "--help should return Ok, got {:?}", result);
1285 }
1286
1287 #[cfg(feature = "async")]
1288 #[tokio::test]
1289 async fn test_run_async_version_flag() {
1290 let cli = make_cli_no_handler();
1291 let result = cli.run_async(["--version"]).await;
1292 assert!(
1293 result.is_ok(),
1294 "--version should return Ok, got {:?}",
1295 result
1296 );
1297 }
1298
1299 #[cfg(feature = "async")]
1300 #[tokio::test]
1301 async fn test_run_async_with_handler() {
1302 use std::sync::atomic::{AtomicBool, Ordering};
1303 let called = Arc::new(AtomicBool::new(false));
1304 let called2 = called.clone();
1305 let cmd = Command::builder("greet")
1306 .summary("Say hello")
1307 .handler(Arc::new(move |_parsed| {
1308 called2.store(true, Ordering::SeqCst);
1309 Ok(())
1310 }))
1311 .build()
1312 .unwrap();
1313 let cli = super::Cli::new(vec![cmd])
1314 .app_name("testapp")
1315 .version("1.2.3");
1316 let result = cli.run_async(["greet"]).await;
1317 assert!(result.is_ok(), "handler should succeed, got {:?}", result);
1318 assert!(
1319 called.load(Ordering::SeqCst),
1320 "handler should have been called"
1321 );
1322 }
1323
1324 #[cfg(feature = "async")]
1325 #[tokio::test]
1326 async fn test_run_async_unknown_command() {
1327 let cli = make_cli_no_handler();
1328 let result = cli.run_async(["unknowncmd"]).await;
1329 assert!(
1330 matches!(result, Err(CliError::Parse(_))),
1331 "unknown command should yield Parse error, got {:?}",
1332 result
1333 );
1334 }
1335
1336 #[test]
1337 fn test_version_without_app_name() {
1338 let cmd = Command::builder("greet").build().unwrap();
1339 let cli = super::Cli::new(vec![cmd]).version("2.0.0");
1341 assert!(cli.run(["--version"]).is_ok());
1342 }
1343
1344 #[test]
1345 fn test_version_not_set() {
1346 let cmd = Command::builder("greet").build().unwrap();
1347 let cli = super::Cli::new(vec![cmd]);
1349 assert!(cli.run(["--version"]).is_ok());
1350 }
1351
1352 #[test]
1353 fn test_middleware_after_dispatch_called_on_success() {
1354 use crate::middleware::Middleware;
1355 use std::sync::atomic::{AtomicBool, Ordering};
1356 use std::sync::Arc;
1357
1358 struct AfterFlag(Arc<AtomicBool>);
1359 impl Middleware for AfterFlag {
1360 fn after_dispatch(
1361 &self,
1362 _: &crate::model::ParsedCommand<'_>,
1363 _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1364 ) {
1365 self.0.store(true, Ordering::SeqCst);
1366 }
1367 }
1368
1369 let called = Arc::new(AtomicBool::new(false));
1370 let cmd = Command::builder("run")
1371 .handler(Arc::new(|_| Ok(())))
1372 .build()
1373 .unwrap();
1374 let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1375 cli.run(["run"]).unwrap();
1376 assert!(called.load(Ordering::SeqCst));
1377 }
1378
1379 #[test]
1380 fn test_middleware_after_dispatch_called_on_error() {
1381 use crate::middleware::Middleware;
1382 use std::sync::atomic::{AtomicBool, Ordering};
1383 use std::sync::Arc;
1384
1385 struct AfterFlag(Arc<AtomicBool>);
1386 impl Middleware for AfterFlag {
1387 fn after_dispatch(
1388 &self,
1389 _: &crate::model::ParsedCommand<'_>,
1390 _: &Result<(), Box<dyn std::error::Error + Send + Sync>>,
1391 ) {
1392 self.0.store(true, Ordering::SeqCst);
1393 }
1394 }
1395
1396 let called = Arc::new(AtomicBool::new(false));
1397 let cmd = Command::builder("run")
1398 .handler(Arc::new(|_| Err("handler error".into())))
1399 .build()
1400 .unwrap();
1401 let cli = super::Cli::new(vec![cmd]).with_middleware(AfterFlag(called.clone()));
1402 let _ = cli.run(["run"]);
1403 assert!(called.load(Ordering::SeqCst));
1404 }
1405
1406 #[test]
1407 fn test_middleware_on_parse_error_called() {
1408 use crate::middleware::Middleware;
1409 use std::sync::atomic::{AtomicBool, Ordering};
1410 use std::sync::Arc;
1411
1412 struct OnErrFlag(Arc<AtomicBool>);
1413 impl Middleware for OnErrFlag {
1414 fn on_parse_error(&self, _: &crate::parser::ParseError) {
1415 self.0.store(true, Ordering::SeqCst);
1416 }
1417 }
1418
1419 let called = Arc::new(AtomicBool::new(false));
1420 let cmd = Command::builder("run").build().unwrap();
1421 let cli = super::Cli::new(vec![cmd]).with_middleware(OnErrFlag(called.clone()));
1422 let _ = cli.run(["unknown_xyz"]);
1423 assert!(called.load(Ordering::SeqCst));
1424 }
1425
1426 #[test]
1427 fn test_unknown_command_with_suggestions() {
1428 let cmd = Command::builder("greet").build().unwrap();
1430 let cli = super::Cli::new(vec![cmd]);
1431 let result = cli.run(["gree"]);
1432 assert!(result.is_err());
1434 }
1435
1436 #[test]
1437 fn test_help_for_subcommand() {
1438 let sub = Command::builder("rollback")
1440 .summary("Roll back")
1441 .build()
1442 .unwrap();
1443 let parent = Command::builder("deploy")
1444 .summary("Deploy")
1445 .subcommand(sub)
1446 .build()
1447 .unwrap();
1448 let cli = super::Cli::new(vec![parent]);
1449 let result = cli.run(["deploy", "rollback", "--help"]);
1450 assert!(result.is_ok());
1451 }
1452
1453 #[test]
1454 fn test_help_with_only_flags() {
1455 let cmd = Command::builder("greet").build().unwrap();
1457 let cli = super::Cli::new(vec![cmd]);
1458 let result = cli.run(["--flag", "--help"]);
1459 assert!(result.is_ok());
1460 }
1461
1462 #[test]
1463 fn test_help_for_unknown_command() {
1464 let cmd = Command::builder("greet").build().unwrap();
1466 let cli = super::Cli::new(vec![cmd]);
1467 let result = cli.run(["unknowncmd", "--help"]);
1468 assert!(result.is_ok());
1469 }
1470
1471 #[test]
1472 fn test_query_with_no_arg_outputs_json() {
1473 use crate::model::Command;
1475 let cli =
1476 super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1477 assert!(cli.run(["query"]).is_ok());
1478 }
1479
1480 #[test]
1481 fn test_query_examples_via_resolver() {
1482 use crate::model::{Command, Example};
1484 let cli = super::Cli::new(vec![Command::builder("deploy")
1485 .summary("Deploy")
1486 .example(Example::new("prod", "deploy prod"))
1487 .build()
1488 .unwrap()])
1489 .with_query_support();
1490 let result = cli.run(["query", "examples", "dep"]);
1492 assert!(
1493 result.is_ok(),
1494 "query examples via prefix should succeed, got {:?}",
1495 result
1496 );
1497 }
1498
1499 #[test]
1500 fn test_query_named_command_via_resolver() {
1501 use crate::model::Command;
1503 let cli = super::Cli::new(vec![Command::builder("deploy")
1504 .summary("Deploy")
1505 .build()
1506 .unwrap()])
1507 .with_query_support();
1508 let result = cli.run(["query", "dep"]);
1510 assert!(
1511 result.is_ok(),
1512 "query prefix-resolved name should succeed, got {:?}",
1513 result
1514 );
1515 }
1516
1517 #[test]
1518 fn test_query_examples_no_name_errors() {
1519 use crate::model::Command;
1520 let cli =
1521 super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1522 let result = cli.run(["query", "examples"]);
1524 assert!(result.is_err(), "query examples with no name should error");
1525 }
1526
1527 #[test]
1528 fn test_query_commands_stream_succeeds() {
1529 use crate::model::Command;
1530 let cli = super::Cli::new(vec![
1531 Command::builder("deploy").summary("Deploy").build().unwrap(),
1532 Command::builder("status").summary("Status").build().unwrap(),
1533 ])
1534 .with_query_support();
1535 let result = cli.run(["query", "commands", "--stream"]);
1536 assert!(
1537 result.is_ok(),
1538 "query commands --stream should return Ok, got {:?}",
1539 result
1540 );
1541 }
1542
1543 #[test]
1544 fn test_query_commands_stream_with_fields_succeeds() {
1545 use crate::model::Command;
1546 let cli = super::Cli::new(vec![
1547 Command::builder("deploy").summary("Deploy").build().unwrap(),
1548 ])
1549 .with_query_support();
1550 let result = cli.run(["query", "commands", "--stream", "--fields", "canonical,summary"]);
1551 assert!(
1552 result.is_ok(),
1553 "query commands --stream --fields should return Ok, got {:?}",
1554 result
1555 );
1556 }
1557
1558 #[test]
1559 fn test_query_named_command_stream_succeeds() {
1560 use crate::model::Command;
1561 let cli = super::Cli::new(vec![Command::builder("deploy")
1562 .summary("Deploy svc")
1563 .build()
1564 .unwrap()])
1565 .with_query_support();
1566 let result = cli.run(["query", "deploy", "--stream"]);
1567 assert!(
1568 result.is_ok(),
1569 "query <name> --stream should return Ok, got {:?}",
1570 result
1571 );
1572 }
1573
1574 #[test]
1575 fn test_query_named_command_via_resolver_stream_succeeds() {
1576 use crate::model::Command;
1577 let cli = super::Cli::new(vec![Command::builder("deploy")
1578 .summary("Deploy svc")
1579 .build()
1580 .unwrap()])
1581 .with_query_support();
1582 let result = cli.run(["query", "dep", "--stream"]);
1584 assert!(
1585 result.is_ok(),
1586 "query prefix-resolved --stream should return Ok, got {:?}",
1587 result
1588 );
1589 }
1590
1591 #[test]
1592 fn test_query_stream_bare_query_succeeds() {
1593 use crate::model::Command;
1595 let cli =
1596 super::Cli::new(vec![Command::builder("deploy").build().unwrap()]).with_query_support();
1597 let result = cli.run(["query", "--stream"]);
1598 assert!(
1599 result.is_ok(),
1600 "query --stream (no subcommand) should return Ok, got {:?}",
1601 result
1602 );
1603 }
1604}