1use std::path::PathBuf;
32use std::sync::Arc;
33
34use rustyline::completion::{Completer, Pair};
35use rustyline::error::ReadlineError;
36use rustyline::highlight::Highlighter;
37use rustyline::hint::Hinter;
38use rustyline::validate::Validator;
39use rustyline::{CompletionType, Config, Context, Editor, Helper};
40
41use crate::config::schema::CommandsConfig;
42use crate::context::ExecutionContext;
43use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
44use crate::help::HelpFormatter;
45use crate::parser::{ParsedArgs, ReplParser};
46use crate::registry::CommandRegistry;
47
48struct DcliCompleter {
69 registry: Arc<CommandRegistry>,
71
72 config: Option<Arc<CommandsConfig>>,
75}
76
77impl DcliCompleter {
78 fn new(registry: Arc<CommandRegistry>, config: Option<Arc<CommandsConfig>>) -> Self {
79 Self { registry, config }
80 }
81
82 fn flags_for(&self, command_name: &str) -> Vec<String> {
87 let config = match &self.config {
88 Some(c) => c,
89 None => return vec![],
90 };
91
92 let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
93 Some(d) => d,
94 None => return vec![],
95 };
96
97 let mut flags = Vec::new();
98 for opt in &cmd_def.options {
99 if let Some(long) = &opt.long {
100 flags.push(format!("--{}", long));
101 }
102 if let Some(short) = &opt.short {
103 flags.push(format!("-{}", short));
104 }
105 }
106 flags
107 }
108}
109
110impl Completer for DcliCompleter {
111 type Candidate = Pair;
112
113 fn complete(
114 &self,
115 line: &str,
116 pos: usize,
117 _ctx: &Context<'_>,
118 ) -> rustyline::Result<(usize, Vec<Pair>)> {
119 let line = &line[..pos];
121 let tokens: Vec<&str> = line.split_whitespace().collect();
122
123 let completing_first_token =
126 tokens.is_empty() || (tokens.len() == 1 && !line.ends_with(' '));
127
128 if completing_first_token {
129 let prefix = tokens.first().copied().unwrap_or("");
130 let start = pos - prefix.len();
131
132 let mut candidates: Vec<Pair> = self
133 .registry
134 .list_commands()
135 .into_iter()
136 .flat_map(|def| {
137 let mut names = vec![def.name.clone()];
138 names.extend(def.aliases.clone());
139 names
140 })
141 .filter(|name| name.starts_with(prefix))
142 .map(|name| Pair {
143 display: name.clone(),
144 replacement: name,
145 })
146 .collect();
147
148 candidates.sort_by(|a, b| a.display.cmp(&b.display));
149 return Ok((start, candidates));
150 }
151
152 let command_token = tokens[0];
155 let canonical = match self.registry.resolve_name(command_token) {
156 Some(name) => name.to_string(),
157 None => return Ok((pos, vec![])),
158 };
159
160 let current_word = if line.ends_with(' ') {
162 ""
163 } else {
164 tokens.last().copied().unwrap_or("")
165 };
166
167 let is_flag_context = current_word.is_empty() || current_word.starts_with('-');
170
171 if !is_flag_context {
172 return Ok((pos, vec![]));
173 }
174
175 let start = pos - current_word.len();
176 let mut candidates: Vec<Pair> = self
177 .flags_for(&canonical)
178 .into_iter()
179 .filter(|flag| flag.starts_with(current_word))
180 .map(|flag| Pair {
181 display: flag.clone(),
182 replacement: flag,
183 })
184 .collect();
185
186 candidates.sort_by(|a, b| a.display.cmp(&b.display));
187 Ok((start, candidates))
188 }
189}
190
191struct DcliHelper {
199 completer: DcliCompleter,
200}
201
202impl DcliHelper {
203 fn new(registry: Arc<CommandRegistry>, config: Option<Arc<CommandsConfig>>) -> Self {
204 Self {
205 completer: DcliCompleter::new(registry, config),
206 }
207 }
208}
209
210impl Helper for DcliHelper {}
211
212impl Completer for DcliHelper {
213 type Candidate = Pair;
214
215 fn complete(
216 &self,
217 line: &str,
218 pos: usize,
219 ctx: &Context<'_>,
220 ) -> rustyline::Result<(usize, Vec<Pair>)> {
221 self.completer.complete(line, pos, ctx)
222 }
223}
224
225impl Hinter for DcliHelper {
227 type Hint = String;
228}
229
230impl Highlighter for DcliHelper {}
231
232impl Validator for DcliHelper {}
233
234pub struct ReplInterface {
272 registry: Arc<CommandRegistry>,
275
276 context: Box<dyn ExecutionContext>,
278
279 prompt: String,
281
282 editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
284
285 history_path: Option<PathBuf>,
287
288 config: Option<Arc<CommandsConfig>>,
291
292 help_formatter: Option<Box<dyn HelpFormatter>>,
295}
296
297impl ReplInterface {
298 pub fn new(
341 registry: CommandRegistry,
342 context: Box<dyn ExecutionContext>,
343 prompt: String,
344 config: Option<CommandsConfig>,
345 help_formatter: Option<Box<dyn HelpFormatter>>,
346 ) -> Result<Self> {
347 let registry = Arc::new(registry);
349
350 let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
352
353 let rl_config = Config::builder()
355 .completion_type(CompletionType::List)
356 .build();
357
358 let helper = DcliHelper::new(Arc::clone(®istry), config.clone());
359
360 let mut editor = Editor::with_config(rl_config).map_err(|e| {
361 ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
362 })?;
363 editor.set_helper(Some(helper));
364
365 let history_path = Self::get_history_path(&prompt);
367
368 let mut repl = Self {
369 registry,
370 context,
371 prompt: format!("{} > ", prompt),
372 editor,
373 history_path,
374 config,
375 help_formatter,
376 };
377
378 repl.load_history();
379
380 Ok(repl)
381 }
382
383 fn try_handle_help(&self, line: &str) -> Option<String> {
399 let config = self.config.as_deref()?;
400 let formatter = self.help_formatter.as_deref()?;
401
402 let trimmed = line.trim();
403
404 if trimmed == "--help" || trimmed == "-h" {
405 return Some(formatter.format_app(config));
406 }
407
408 if let Some(rest) = trimmed
409 .strip_prefix("--help ")
410 .or_else(|| trimmed.strip_prefix("-h "))
411 {
412 let cmd = rest.trim();
413 if !cmd.is_empty() {
414 return Some(formatter.format_command(config, cmd));
415 }
416 }
417
418 let parts: Vec<&str> = trimmed.split_whitespace().collect();
419 if parts.len() >= 2 {
420 let last = *parts.last().unwrap();
421 if last == "--help" || last == "-h" {
422 return Some(formatter.format_command(config, parts[0]));
423 }
424 }
425
426 None
427 }
428
429 fn has_secure_arg(
435 &self,
436 command_name: &str,
437 parsed_args: &std::collections::HashMap<String, String>,
438 ) -> bool {
439 let config = match &self.config {
440 Some(c) => c,
441 None => return false,
442 };
443
444 let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
445 Some(d) => d,
446 None => return false,
447 };
448
449 cmd_def
450 .arguments
451 .iter()
452 .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
453 }
454
455 fn get_history_path(app_name: &str) -> Option<PathBuf> {
463 dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
464 }
465
466 fn load_history(&mut self) {
468 if let Some(ref path) = self.history_path {
469 if let Some(parent) = path.parent() {
470 let _ = std::fs::create_dir_all(parent);
471 }
472 let _ = self.editor.load_history(path);
473 }
474 }
475
476 fn save_history(&mut self) {
478 if let Some(ref path) = self.history_path {
479 if let Err(e) = self.editor.save_history(path) {
480 eprintln!("Warning: Failed to save command history: {}", e);
481 }
482 }
483 }
484
485 pub fn run(mut self) -> Result<()> {
521 loop {
522 let readline = self.editor.readline(&self.prompt);
523
524 match readline {
525 Ok(line) => {
526 let line = line.trim();
527 if line.is_empty() {
528 continue;
529 }
530
531 if line == "exit" || line == "quit" {
532 println!("Goodbye!");
533 break;
534 }
535
536 match self.execute_line(line) {
540 Ok(()) => {}
541 Err(e) => {
542 display_error(&e);
543 }
544 }
545 }
546
547 Err(ReadlineError::Interrupted) => {
548 println!("^C");
549 continue;
550 }
551
552 Err(ReadlineError::Eof) => {
553 println!("exit");
554 break;
555 }
556
557 Err(err) => {
558 eprintln!("Error reading input: {}", err);
559 break;
560 }
561 }
562 }
563
564 self.save_history();
565 Ok(())
566 }
567
568 fn execute_line(&mut self, line: &str) -> Result<()> {
577 if let Some(output) = self.try_handle_help(line) {
578 print!("{}", output);
579 return Ok(());
580 }
581
582 let parser = ReplParser::new(&self.registry);
583 let parsed = parser.parse_line(line)?;
584
585 if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
588 let _ = self.editor.add_history_entry(line);
589 }
590
591 let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
604 if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
605 handler.execute(&mut *self.context, &parsed_args)?;
606 } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
607 futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
608 } else {
609 return Err(DynamicCliError::Execution(
610 ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
611 ));
612 }
613
614 Ok(())
615 }
616}
617
618impl Drop for ReplInterface {
619 fn drop(&mut self) {
620 self.save_history();
621 }
622}
623
624#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::config::schema::{
632 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
633 };
634 use rustyline::history::History;
635 use std::collections::HashMap;
636
637 #[derive(Default)]
638 struct TestContext {
639 executed_commands: Vec<String>,
640 }
641
642 impl ExecutionContext for TestContext {
643 fn as_any(&self) -> &dyn std::any::Any {
644 self
645 }
646 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
647 self
648 }
649 }
650
651 struct TestHandler {
652 name: String,
653 }
654
655 impl crate::executor::CommandHandler for TestHandler {
656 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
657 let ctx = crate::context::downcast_mut::<TestContext>(context)
658 .expect("Failed to downcast context");
659 ctx.executed_commands.push(self.name.clone());
660 Ok(())
661 }
662 }
663
664 fn create_test_registry() -> CommandRegistry {
665 let mut registry = CommandRegistry::new();
666 let cmd_def = CommandDefinition {
667 name: "test".to_string(),
668 aliases: vec!["t".to_string()],
669 description: "Test command".to_string(),
670 required: false,
671 arguments: vec![],
672 options: vec![],
673 implementation: "test_handler".to_string(),
674 };
675 registry
676 .register_sync(
677 cmd_def,
678 Box::new(TestHandler {
679 name: "test".to_string(),
680 }),
681 )
682 .unwrap();
683 registry
684 }
685
686 fn make_help_config() -> CommandsConfig {
687 use crate::config::schema::{CommandsConfig, Metadata};
688 CommandsConfig {
689 metadata: Metadata {
690 version: "1.0.0".to_string(),
691 prompt: "testapp".to_string(),
692 prompt_suffix: " > ".to_string(),
693 },
694 commands: vec![CommandDefinition {
695 name: "hello".to_string(),
696 aliases: vec!["hi".to_string()],
697 description: "Say hello".to_string(),
698 required: false,
699 arguments: vec![],
700 options: vec![OptionDefinition {
701 name: "loud".to_string(),
702 short: Some("l".to_string()),
703 long: Some("loud".to_string()),
704 option_type: ArgumentType::Bool,
705 required: false,
706 default: Some("false".to_string()),
707 description: "Loud greeting".to_string(),
708 choices: vec![],
709 repeatable: false,
710 option_parameters: HashMap::new(),
711 }],
712 implementation: "hello_handler".to_string(),
713 }],
714 global_options: vec![],
715 }
716 }
717
718 #[test]
721 fn test_repl_interface_creation() {
722 let registry = create_test_registry();
723 let context = Box::new(TestContext::default());
724 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
725 assert!(repl.is_ok());
726 }
727
728 #[test]
729 fn test_repl_interface_creation_with_config() {
730 let registry = create_test_registry();
731 let context = Box::new(TestContext::default());
732 let config = make_help_config();
733 let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
734 assert!(repl.is_ok());
735 }
736
737 #[test]
740 fn test_repl_execute_line() {
741 let registry = create_test_registry();
742 let context = Box::new(TestContext::default());
743 let mut repl =
744 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
745 let result = repl.execute_line("test");
746 assert!(result.is_ok());
747 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
748 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
749 }
750
751 #[test]
752 fn test_repl_execute_with_alias() {
753 let registry = create_test_registry();
754 let context = Box::new(TestContext::default());
755 let mut repl =
756 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
757 assert!(repl.execute_line("t").is_ok());
758 }
759
760 #[test]
761 fn test_repl_execute_unknown_command() {
762 let registry = create_test_registry();
763 let context = Box::new(TestContext::default());
764 let mut repl =
765 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
766 let result = repl.execute_line("unknown");
767 assert!(result.is_err());
768 match result.unwrap_err() {
769 DynamicCliError::Parse(_) => {}
770 other => panic!("Expected Parse error, got: {:?}", other),
771 }
772 }
773
774 #[test]
775 fn test_repl_empty_line() {
776 let registry = create_test_registry();
777 let context = Box::new(TestContext::default());
778 let mut repl =
779 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
780 assert!(repl.execute_line("").is_err());
781 }
782
783 #[test]
784 fn test_repl_command_with_args() {
785 let mut registry = CommandRegistry::new();
786 let cmd_def = CommandDefinition {
787 name: "greet".to_string(),
788 aliases: vec![],
789 description: "Greet someone".to_string(),
790 required: false,
791 arguments: vec![ArgumentDefinition {
792 name: "name".to_string(),
793 arg_type: ArgumentType::String,
794 required: true,
795 description: "Name".to_string(),
796 validation: vec![],
797 secure: false,
798 }],
799 options: vec![],
800 implementation: "greet_handler".to_string(),
801 };
802
803 struct GreetHandler;
804 impl crate::executor::CommandHandler for GreetHandler {
805 fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
806 assert_eq!(args.get_scalar("name"), Some("Alice"));
807 Ok(())
808 }
809 }
810
811 registry
812 .register_sync(cmd_def, Box::new(GreetHandler))
813 .unwrap();
814 let context = Box::new(TestContext::default());
815 let mut repl =
816 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
817 assert!(repl.execute_line("greet Alice").is_ok());
818 }
819
820 #[test]
823 fn test_repl_history_path() {
824 let path = ReplInterface::get_history_path("myapp");
825 if let Some(p) = path {
826 let path_str = p.to_str().unwrap();
827 assert!(path_str.contains("myapp"), "path should contain app name");
828 assert!(
829 path_str.ends_with("history"),
830 "path should end with 'history', got: {}",
831 path_str
832 );
833 }
834 }
835
836 #[test]
839 fn test_try_handle_help_without_formatter_returns_none() {
840 let registry = create_test_registry();
841 let context = Box::new(TestContext::default());
842 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
843 assert!(repl.try_handle_help("--help").is_none());
844 assert!(repl.try_handle_help("-h").is_none());
845 }
846
847 #[test]
848 fn test_try_handle_help_global() {
849 use crate::help::DefaultHelpFormatter;
850 colored::control::set_override(false);
851 let registry = create_test_registry();
852 let context = Box::new(TestContext::default());
853 let config = make_help_config();
854 let repl = ReplInterface::new(
855 registry,
856 context,
857 "test".to_string(),
858 Some(config),
859 Some(Box::new(DefaultHelpFormatter::new())),
860 )
861 .unwrap();
862 let out = repl.try_handle_help("--help");
863 assert!(out.is_some());
864 let out = out.unwrap();
865 assert!(out.contains("testapp"));
866 assert!(out.contains("hello"));
867 }
868
869 #[test]
870 fn test_try_handle_help_short_flag() {
871 use crate::help::DefaultHelpFormatter;
872 colored::control::set_override(false);
873 let registry = create_test_registry();
874 let context = Box::new(TestContext::default());
875 let config = make_help_config();
876 let repl = ReplInterface::new(
877 registry,
878 context,
879 "test".to_string(),
880 Some(config),
881 Some(Box::new(DefaultHelpFormatter::new())),
882 )
883 .unwrap();
884 let out = repl.try_handle_help("-h");
885 assert!(out.is_some());
886 assert!(out.unwrap().contains("testapp"));
887 }
888
889 #[test]
890 fn test_try_handle_help_with_command_prefix() {
891 use crate::help::DefaultHelpFormatter;
892 colored::control::set_override(false);
893 let registry = create_test_registry();
894 let context = Box::new(TestContext::default());
895 let config = make_help_config();
896 let repl = ReplInterface::new(
897 registry,
898 context,
899 "test".to_string(),
900 Some(config),
901 Some(Box::new(DefaultHelpFormatter::new())),
902 )
903 .unwrap();
904 let out = repl.try_handle_help("--help hello");
905 assert!(out.is_some());
906 assert!(out.unwrap().contains("hello"));
907 let out2 = repl.try_handle_help("-h hello");
908 assert!(out2.is_some());
909 }
910
911 #[test]
912 fn test_try_handle_help_command_suffix() {
913 use crate::help::DefaultHelpFormatter;
914 colored::control::set_override(false);
915 let registry = create_test_registry();
916 let context = Box::new(TestContext::default());
917 let config = make_help_config();
918 let repl = ReplInterface::new(
919 registry,
920 context,
921 "test".to_string(),
922 Some(config),
923 Some(Box::new(DefaultHelpFormatter::new())),
924 )
925 .unwrap();
926 let out = repl.try_handle_help("hello --help");
927 assert!(out.is_some());
928 assert!(out.unwrap().contains("hello"));
929 let out2 = repl.try_handle_help("hello -h");
930 assert!(out2.is_some());
931 }
932
933 #[test]
934 fn test_try_handle_help_alias() {
935 use crate::help::DefaultHelpFormatter;
936 colored::control::set_override(false);
937 let registry = create_test_registry();
938 let context = Box::new(TestContext::default());
939 let config = make_help_config();
940 let repl = ReplInterface::new(
941 registry,
942 context,
943 "test".to_string(),
944 Some(config),
945 Some(Box::new(DefaultHelpFormatter::new())),
946 )
947 .unwrap();
948 let out = repl.try_handle_help("--help hi");
949 assert!(out.is_some());
950 assert!(out.unwrap().contains("hello"));
951 }
952
953 #[test]
954 fn test_execute_line_help_intercepted() {
955 use crate::help::DefaultHelpFormatter;
956 colored::control::set_override(false);
957 let registry = create_test_registry();
958 let context = Box::new(TestContext::default());
959 let config = make_help_config();
960 let mut repl = ReplInterface::new(
961 registry,
962 context,
963 "test".to_string(),
964 Some(config),
965 Some(Box::new(DefaultHelpFormatter::new())),
966 )
967 .unwrap();
968 assert!(repl.execute_line("--help").is_ok());
969 }
970
971 #[test]
972 fn test_execute_line_normal_command_still_works_with_formatter() {
973 use crate::help::DefaultHelpFormatter;
974 let registry = create_test_registry();
975 let context = Box::new(TestContext::default());
976 let config = make_help_config();
977 let mut repl = ReplInterface::new(
978 registry,
979 context,
980 "test".to_string(),
981 Some(config),
982 Some(Box::new(DefaultHelpFormatter::new())),
983 )
984 .unwrap();
985 assert!(repl.execute_line("test").is_ok());
986 }
987
988 #[test]
991 fn test_completer_commands_empty_input() {
992 let registry = Arc::new(create_test_registry());
993 let completer = DcliCompleter::new(Arc::clone(®istry), None);
994 let history = rustyline::history::DefaultHistory::new();
995 let ctx = rustyline::Context::new(&history);
996 let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
997 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
998 assert!(names.contains(&"test"));
999 assert!(names.contains(&"t"));
1000 }
1001
1002 #[test]
1003 fn test_completer_commands_prefix_filter() {
1004 let registry = Arc::new(create_test_registry());
1005 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1006 let history = rustyline::history::DefaultHistory::new();
1007 let ctx = rustyline::Context::new(&history);
1008 let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1009 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1010 assert!(names.contains(&"test"));
1011 assert!(!names.contains(&"t"));
1012 }
1013
1014 #[test]
1015 fn test_completer_flags_after_command() {
1016 let config = Arc::new(make_help_config());
1017 let mut registry = CommandRegistry::new();
1019 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1020 struct DummyHandler;
1021 impl crate::executor::CommandHandler for DummyHandler {
1022 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1023 Ok(())
1024 }
1025 }
1026 registry
1027 .register_sync(cmd_def, Box::new(DummyHandler))
1028 .unwrap();
1029 let registry = Arc::new(registry);
1030
1031 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1032 let history = rustyline::history::DefaultHistory::new();
1033 let ctx = rustyline::Context::new(&history);
1034
1035 let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1037 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1038 assert!(
1039 names.contains(&"--loud"),
1040 "expected --loud, got {:?}",
1041 names
1042 );
1043 assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1044 }
1045
1046 #[test]
1047 fn test_completer_flags_prefix_filter() {
1048 let config = Arc::new(make_help_config());
1049 let mut registry = CommandRegistry::new();
1050 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1051 struct DummyHandler;
1052 impl crate::executor::CommandHandler for DummyHandler {
1053 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1054 Ok(())
1055 }
1056 }
1057 registry
1058 .register_sync(cmd_def, Box::new(DummyHandler))
1059 .unwrap();
1060 let registry = Arc::new(registry);
1061
1062 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1063 let history = rustyline::history::DefaultHistory::new();
1064 let ctx = rustyline::Context::new(&history);
1065
1066 let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1068 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1069 assert!(names.contains(&"--loud"));
1070 assert!(!names.contains(&"-l"));
1071 }
1072
1073 #[test]
1074 fn test_completer_no_flags_for_unknown_command() {
1075 let config = Arc::new(make_help_config());
1076 let registry = Arc::new(create_test_registry());
1077 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1078 let history = rustyline::history::DefaultHistory::new();
1079 let ctx = rustyline::Context::new(&history);
1080 let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1082 assert!(candidates.is_empty());
1083 }
1084
1085 fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1089 use crate::config::schema::{CommandsConfig, Metadata};
1090
1091 let cmd_def = CommandDefinition {
1092 name: "login".to_string(),
1093 aliases: vec![],
1094 description: "Login command".to_string(),
1095 required: false,
1096 arguments: vec![
1097 ArgumentDefinition {
1098 name: "username".to_string(),
1099 arg_type: ArgumentType::String,
1100 required: true,
1101 description: "Username".to_string(),
1102 validation: vec![],
1103 secure: false,
1104 },
1105 ArgumentDefinition {
1106 name: "password".to_string(),
1107 arg_type: ArgumentType::String,
1108 required: true,
1109 description: "Password".to_string(),
1110 validation: vec![],
1111 secure: true,
1112 },
1113 ],
1114 options: vec![],
1115 implementation: "login_handler".to_string(),
1116 };
1117
1118 struct LoginHandler;
1119 impl crate::executor::CommandHandler for LoginHandler {
1120 fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1121 Ok(())
1122 }
1123 }
1124
1125 let mut registry = CommandRegistry::new();
1126 registry
1127 .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1128 .unwrap();
1129
1130 let config = CommandsConfig {
1131 metadata: Metadata {
1132 version: "1.0.0".to_string(),
1133 prompt: "testapp".to_string(),
1134 prompt_suffix: " > ".to_string(),
1135 },
1136 commands: vec![cmd_def],
1137 global_options: vec![],
1138 };
1139
1140 (registry, config)
1141 }
1142
1143 #[test]
1144 fn test_has_secure_arg_returns_false_without_config() {
1145 let registry = create_test_registry();
1146 let context = Box::new(TestContext::default());
1147 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1148
1149 let mut args = HashMap::new();
1150 args.insert("password".to_string(), "secret".to_string());
1151
1152 assert!(!repl.has_secure_arg("login", &args));
1153 }
1154
1155 #[test]
1156 fn test_has_secure_arg_returns_false_when_no_secure_field() {
1157 let registry = create_test_registry();
1158 let context = Box::new(TestContext::default());
1159 let config = make_help_config();
1160 let repl =
1161 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1162
1163 let mut args = HashMap::new();
1164 args.insert("loud".to_string(), "true".to_string());
1165
1166 assert!(!repl.has_secure_arg("hello", &args));
1167 }
1168
1169 #[test]
1170 fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1171 let (registry, config) = make_secure_registry_and_config();
1172 let context = Box::new(TestContext::default());
1173 let repl =
1174 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1175
1176 let mut args = HashMap::new();
1177 args.insert("username".to_string(), "alice".to_string());
1178 args.insert("password".to_string(), "secret".to_string());
1179
1180 assert!(repl.has_secure_arg("login", &args));
1181 }
1182
1183 #[test]
1184 fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1185 let (registry, config) = make_secure_registry_and_config();
1186 let context = Box::new(TestContext::default());
1187 let repl =
1188 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1189
1190 let mut args = HashMap::new();
1192 args.insert("username".to_string(), "alice".to_string());
1193
1194 assert!(!repl.has_secure_arg("login", &args));
1195 }
1196
1197 #[test]
1198 fn test_has_secure_arg_returns_false_for_unknown_command() {
1199 let (registry, config) = make_secure_registry_and_config();
1200 let context = Box::new(TestContext::default());
1201 let repl =
1202 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1203
1204 let mut args = HashMap::new();
1205 args.insert("password".to_string(), "secret".to_string());
1206
1207 assert!(!repl.has_secure_arg("nonexistent", &args));
1208 }
1209
1210 #[test]
1213 fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1214 let (registry, config) = make_secure_registry_and_config();
1215 let context = Box::new(TestContext::default());
1216 let mut repl =
1217 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1218
1219 let result = repl.execute_line("login alice secret");
1220 assert!(result.is_ok());
1221
1222 let history = repl.editor.history();
1224 let in_history = (0..history.len()).any(|i| {
1225 history
1226 .get(i, rustyline::history::SearchDirection::Forward)
1227 .ok()
1228 .flatten()
1229 .map(|e| e.entry.as_ref() == "login alice secret")
1230 .unwrap_or(false)
1231 });
1232 assert!(
1233 !in_history,
1234 "secure command line must not be written to history"
1235 );
1236 }
1237
1238 #[test]
1239 fn test_execute_line_without_secure_arg_adds_to_history() {
1240 let registry = create_test_registry();
1241 let context = Box::new(TestContext::default());
1242 let mut repl =
1243 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1244
1245 let result = repl.execute_line("test");
1246 assert!(result.is_ok());
1247
1248 let history = repl.editor.history();
1250 let in_history = (0..history.len()).any(|i| {
1251 history
1252 .get(i, rustyline::history::SearchDirection::Forward)
1253 .ok()
1254 .flatten()
1255 .map(|e| e.entry.as_ref() == "test")
1256 .unwrap_or(false)
1257 });
1258 assert!(
1259 in_history,
1260 "non-secure command line must be written to history"
1261 );
1262 }
1263}