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::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 if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
597 handler.execute(&mut *self.context, &parsed.arguments)?;
598 } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
599 futures::executor::block_on(handler.execute(&mut *self.context, &parsed.arguments))?;
600 } else {
601 return Err(DynamicCliError::Execution(
602 ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
603 ));
604 }
605
606 Ok(())
607 }
608}
609
610impl Drop for ReplInterface {
611 fn drop(&mut self) {
612 self.save_history();
613 }
614}
615
616#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::config::schema::{
624 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
625 };
626 use rustyline::history::History;
627 use std::collections::HashMap;
628
629 #[derive(Default)]
630 struct TestContext {
631 executed_commands: Vec<String>,
632 }
633
634 impl ExecutionContext for TestContext {
635 fn as_any(&self) -> &dyn std::any::Any {
636 self
637 }
638 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
639 self
640 }
641 }
642
643 struct TestHandler {
644 name: String,
645 }
646
647 impl crate::executor::CommandHandler for TestHandler {
648 fn execute(
649 &self,
650 context: &mut dyn ExecutionContext,
651 _args: &HashMap<String, String>,
652 ) -> Result<()> {
653 let ctx = crate::context::downcast_mut::<TestContext>(context)
654 .expect("Failed to downcast context");
655 ctx.executed_commands.push(self.name.clone());
656 Ok(())
657 }
658 }
659
660 fn create_test_registry() -> CommandRegistry {
661 let mut registry = CommandRegistry::new();
662 let cmd_def = CommandDefinition {
663 name: "test".to_string(),
664 aliases: vec!["t".to_string()],
665 description: "Test command".to_string(),
666 required: false,
667 arguments: vec![],
668 options: vec![],
669 implementation: "test_handler".to_string(),
670 };
671 registry
672 .register_sync(
673 cmd_def,
674 Box::new(TestHandler {
675 name: "test".to_string(),
676 }),
677 )
678 .unwrap();
679 registry
680 }
681
682 fn make_help_config() -> CommandsConfig {
683 use crate::config::schema::{CommandsConfig, Metadata};
684 CommandsConfig {
685 metadata: Metadata {
686 version: "1.0.0".to_string(),
687 prompt: "testapp".to_string(),
688 prompt_suffix: " > ".to_string(),
689 },
690 commands: vec![CommandDefinition {
691 name: "hello".to_string(),
692 aliases: vec!["hi".to_string()],
693 description: "Say hello".to_string(),
694 required: false,
695 arguments: vec![],
696 options: vec![OptionDefinition {
697 name: "loud".to_string(),
698 short: Some("l".to_string()),
699 long: Some("loud".to_string()),
700 option_type: ArgumentType::Bool,
701 required: false,
702 default: Some("false".to_string()),
703 description: "Loud greeting".to_string(),
704 choices: vec![],
705 }],
706 implementation: "hello_handler".to_string(),
707 }],
708 global_options: vec![],
709 }
710 }
711
712 #[test]
715 fn test_repl_interface_creation() {
716 let registry = create_test_registry();
717 let context = Box::new(TestContext::default());
718 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
719 assert!(repl.is_ok());
720 }
721
722 #[test]
723 fn test_repl_interface_creation_with_config() {
724 let registry = create_test_registry();
725 let context = Box::new(TestContext::default());
726 let config = make_help_config();
727 let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
728 assert!(repl.is_ok());
729 }
730
731 #[test]
734 fn test_repl_execute_line() {
735 let registry = create_test_registry();
736 let context = Box::new(TestContext::default());
737 let mut repl =
738 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
739 let result = repl.execute_line("test");
740 assert!(result.is_ok());
741 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
742 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
743 }
744
745 #[test]
746 fn test_repl_execute_with_alias() {
747 let registry = create_test_registry();
748 let context = Box::new(TestContext::default());
749 let mut repl =
750 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
751 assert!(repl.execute_line("t").is_ok());
752 }
753
754 #[test]
755 fn test_repl_execute_unknown_command() {
756 let registry = create_test_registry();
757 let context = Box::new(TestContext::default());
758 let mut repl =
759 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
760 let result = repl.execute_line("unknown");
761 assert!(result.is_err());
762 match result.unwrap_err() {
763 DynamicCliError::Parse(_) => {}
764 other => panic!("Expected Parse error, got: {:?}", other),
765 }
766 }
767
768 #[test]
769 fn test_repl_empty_line() {
770 let registry = create_test_registry();
771 let context = Box::new(TestContext::default());
772 let mut repl =
773 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
774 assert!(repl.execute_line("").is_err());
775 }
776
777 #[test]
778 fn test_repl_command_with_args() {
779 let mut registry = CommandRegistry::new();
780 let cmd_def = CommandDefinition {
781 name: "greet".to_string(),
782 aliases: vec![],
783 description: "Greet someone".to_string(),
784 required: false,
785 arguments: vec![ArgumentDefinition {
786 name: "name".to_string(),
787 arg_type: ArgumentType::String,
788 required: true,
789 description: "Name".to_string(),
790 validation: vec![],
791 secure: false,
792 }],
793 options: vec![],
794 implementation: "greet_handler".to_string(),
795 };
796
797 struct GreetHandler;
798 impl crate::executor::CommandHandler for GreetHandler {
799 fn execute(
800 &self,
801 _ctx: &mut dyn ExecutionContext,
802 args: &HashMap<String, String>,
803 ) -> Result<()> {
804 assert_eq!(args.get("name"), Some(&"Alice".to_string()));
805 Ok(())
806 }
807 }
808
809 registry
810 .register_sync(cmd_def, Box::new(GreetHandler))
811 .unwrap();
812 let context = Box::new(TestContext::default());
813 let mut repl =
814 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
815 assert!(repl.execute_line("greet Alice").is_ok());
816 }
817
818 #[test]
821 fn test_repl_history_path() {
822 let path = ReplInterface::get_history_path("myapp");
823 if let Some(p) = path {
824 let path_str = p.to_str().unwrap();
825 assert!(path_str.contains("myapp"), "path should contain app name");
826 assert!(
827 path_str.ends_with("history"),
828 "path should end with 'history', got: {}",
829 path_str
830 );
831 }
832 }
833
834 #[test]
837 fn test_try_handle_help_without_formatter_returns_none() {
838 let registry = create_test_registry();
839 let context = Box::new(TestContext::default());
840 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
841 assert!(repl.try_handle_help("--help").is_none());
842 assert!(repl.try_handle_help("-h").is_none());
843 }
844
845 #[test]
846 fn test_try_handle_help_global() {
847 use crate::help::DefaultHelpFormatter;
848 colored::control::set_override(false);
849 let registry = create_test_registry();
850 let context = Box::new(TestContext::default());
851 let config = make_help_config();
852 let repl = ReplInterface::new(
853 registry,
854 context,
855 "test".to_string(),
856 Some(config),
857 Some(Box::new(DefaultHelpFormatter::new())),
858 )
859 .unwrap();
860 let out = repl.try_handle_help("--help");
861 assert!(out.is_some());
862 let out = out.unwrap();
863 assert!(out.contains("testapp"));
864 assert!(out.contains("hello"));
865 }
866
867 #[test]
868 fn test_try_handle_help_short_flag() {
869 use crate::help::DefaultHelpFormatter;
870 colored::control::set_override(false);
871 let registry = create_test_registry();
872 let context = Box::new(TestContext::default());
873 let config = make_help_config();
874 let repl = ReplInterface::new(
875 registry,
876 context,
877 "test".to_string(),
878 Some(config),
879 Some(Box::new(DefaultHelpFormatter::new())),
880 )
881 .unwrap();
882 let out = repl.try_handle_help("-h");
883 assert!(out.is_some());
884 assert!(out.unwrap().contains("testapp"));
885 }
886
887 #[test]
888 fn test_try_handle_help_with_command_prefix() {
889 use crate::help::DefaultHelpFormatter;
890 colored::control::set_override(false);
891 let registry = create_test_registry();
892 let context = Box::new(TestContext::default());
893 let config = make_help_config();
894 let repl = ReplInterface::new(
895 registry,
896 context,
897 "test".to_string(),
898 Some(config),
899 Some(Box::new(DefaultHelpFormatter::new())),
900 )
901 .unwrap();
902 let out = repl.try_handle_help("--help hello");
903 assert!(out.is_some());
904 assert!(out.unwrap().contains("hello"));
905 let out2 = repl.try_handle_help("-h hello");
906 assert!(out2.is_some());
907 }
908
909 #[test]
910 fn test_try_handle_help_command_suffix() {
911 use crate::help::DefaultHelpFormatter;
912 colored::control::set_override(false);
913 let registry = create_test_registry();
914 let context = Box::new(TestContext::default());
915 let config = make_help_config();
916 let repl = ReplInterface::new(
917 registry,
918 context,
919 "test".to_string(),
920 Some(config),
921 Some(Box::new(DefaultHelpFormatter::new())),
922 )
923 .unwrap();
924 let out = repl.try_handle_help("hello --help");
925 assert!(out.is_some());
926 assert!(out.unwrap().contains("hello"));
927 let out2 = repl.try_handle_help("hello -h");
928 assert!(out2.is_some());
929 }
930
931 #[test]
932 fn test_try_handle_help_alias() {
933 use crate::help::DefaultHelpFormatter;
934 colored::control::set_override(false);
935 let registry = create_test_registry();
936 let context = Box::new(TestContext::default());
937 let config = make_help_config();
938 let repl = ReplInterface::new(
939 registry,
940 context,
941 "test".to_string(),
942 Some(config),
943 Some(Box::new(DefaultHelpFormatter::new())),
944 )
945 .unwrap();
946 let out = repl.try_handle_help("--help hi");
947 assert!(out.is_some());
948 assert!(out.unwrap().contains("hello"));
949 }
950
951 #[test]
952 fn test_execute_line_help_intercepted() {
953 use crate::help::DefaultHelpFormatter;
954 colored::control::set_override(false);
955 let registry = create_test_registry();
956 let context = Box::new(TestContext::default());
957 let config = make_help_config();
958 let mut repl = ReplInterface::new(
959 registry,
960 context,
961 "test".to_string(),
962 Some(config),
963 Some(Box::new(DefaultHelpFormatter::new())),
964 )
965 .unwrap();
966 assert!(repl.execute_line("--help").is_ok());
967 }
968
969 #[test]
970 fn test_execute_line_normal_command_still_works_with_formatter() {
971 use crate::help::DefaultHelpFormatter;
972 let registry = create_test_registry();
973 let context = Box::new(TestContext::default());
974 let config = make_help_config();
975 let mut repl = ReplInterface::new(
976 registry,
977 context,
978 "test".to_string(),
979 Some(config),
980 Some(Box::new(DefaultHelpFormatter::new())),
981 )
982 .unwrap();
983 assert!(repl.execute_line("test").is_ok());
984 }
985
986 #[test]
989 fn test_completer_commands_empty_input() {
990 let registry = Arc::new(create_test_registry());
991 let completer = DcliCompleter::new(Arc::clone(®istry), None);
992 let history = rustyline::history::DefaultHistory::new();
993 let ctx = rustyline::Context::new(&history);
994 let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
995 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
996 assert!(names.contains(&"test"));
997 assert!(names.contains(&"t"));
998 }
999
1000 #[test]
1001 fn test_completer_commands_prefix_filter() {
1002 let registry = Arc::new(create_test_registry());
1003 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1004 let history = rustyline::history::DefaultHistory::new();
1005 let ctx = rustyline::Context::new(&history);
1006 let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1007 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1008 assert!(names.contains(&"test"));
1009 assert!(!names.contains(&"t"));
1010 }
1011
1012 #[test]
1013 fn test_completer_flags_after_command() {
1014 let config = Arc::new(make_help_config());
1015 let mut registry = CommandRegistry::new();
1017 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1018 struct DummyHandler;
1019 impl crate::executor::CommandHandler for DummyHandler {
1020 fn execute(
1021 &self,
1022 _: &mut dyn ExecutionContext,
1023 _: &HashMap<String, String>,
1024 ) -> Result<()> {
1025 Ok(())
1026 }
1027 }
1028 registry
1029 .register_sync(cmd_def, Box::new(DummyHandler))
1030 .unwrap();
1031 let registry = Arc::new(registry);
1032
1033 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1034 let history = rustyline::history::DefaultHistory::new();
1035 let ctx = rustyline::Context::new(&history);
1036
1037 let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1039 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1040 assert!(
1041 names.contains(&"--loud"),
1042 "expected --loud, got {:?}",
1043 names
1044 );
1045 assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1046 }
1047
1048 #[test]
1049 fn test_completer_flags_prefix_filter() {
1050 let config = Arc::new(make_help_config());
1051 let mut registry = CommandRegistry::new();
1052 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1053 struct DummyHandler;
1054 impl crate::executor::CommandHandler for DummyHandler {
1055 fn execute(
1056 &self,
1057 _: &mut dyn ExecutionContext,
1058 _: &HashMap<String, String>,
1059 ) -> Result<()> {
1060 Ok(())
1061 }
1062 }
1063 registry
1064 .register_sync(cmd_def, Box::new(DummyHandler))
1065 .unwrap();
1066 let registry = Arc::new(registry);
1067
1068 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1069 let history = rustyline::history::DefaultHistory::new();
1070 let ctx = rustyline::Context::new(&history);
1071
1072 let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1074 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1075 assert!(names.contains(&"--loud"));
1076 assert!(!names.contains(&"-l"));
1077 }
1078
1079 #[test]
1080 fn test_completer_no_flags_for_unknown_command() {
1081 let config = Arc::new(make_help_config());
1082 let registry = Arc::new(create_test_registry());
1083 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1084 let history = rustyline::history::DefaultHistory::new();
1085 let ctx = rustyline::Context::new(&history);
1086 let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1088 assert!(candidates.is_empty());
1089 }
1090
1091 fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1095 use crate::config::schema::{CommandsConfig, Metadata};
1096
1097 let cmd_def = CommandDefinition {
1098 name: "login".to_string(),
1099 aliases: vec![],
1100 description: "Login command".to_string(),
1101 required: false,
1102 arguments: vec![
1103 ArgumentDefinition {
1104 name: "username".to_string(),
1105 arg_type: ArgumentType::String,
1106 required: true,
1107 description: "Username".to_string(),
1108 validation: vec![],
1109 secure: false,
1110 },
1111 ArgumentDefinition {
1112 name: "password".to_string(),
1113 arg_type: ArgumentType::String,
1114 required: true,
1115 description: "Password".to_string(),
1116 validation: vec![],
1117 secure: true,
1118 },
1119 ],
1120 options: vec![],
1121 implementation: "login_handler".to_string(),
1122 };
1123
1124 struct LoginHandler;
1125 impl crate::executor::CommandHandler for LoginHandler {
1126 fn execute(
1127 &self,
1128 _ctx: &mut dyn ExecutionContext,
1129 _args: &HashMap<String, String>,
1130 ) -> Result<()> {
1131 Ok(())
1132 }
1133 }
1134
1135 let mut registry = CommandRegistry::new();
1136 registry
1137 .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1138 .unwrap();
1139
1140 let config = CommandsConfig {
1141 metadata: Metadata {
1142 version: "1.0.0".to_string(),
1143 prompt: "testapp".to_string(),
1144 prompt_suffix: " > ".to_string(),
1145 },
1146 commands: vec![cmd_def],
1147 global_options: vec![],
1148 };
1149
1150 (registry, config)
1151 }
1152
1153 #[test]
1154 fn test_has_secure_arg_returns_false_without_config() {
1155 let registry = create_test_registry();
1156 let context = Box::new(TestContext::default());
1157 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1158
1159 let mut args = HashMap::new();
1160 args.insert("password".to_string(), "secret".to_string());
1161
1162 assert!(!repl.has_secure_arg("login", &args));
1163 }
1164
1165 #[test]
1166 fn test_has_secure_arg_returns_false_when_no_secure_field() {
1167 let registry = create_test_registry();
1168 let context = Box::new(TestContext::default());
1169 let config = make_help_config();
1170 let repl =
1171 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1172
1173 let mut args = HashMap::new();
1174 args.insert("loud".to_string(), "true".to_string());
1175
1176 assert!(!repl.has_secure_arg("hello", &args));
1177 }
1178
1179 #[test]
1180 fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1181 let (registry, config) = make_secure_registry_and_config();
1182 let context = Box::new(TestContext::default());
1183 let repl =
1184 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1185
1186 let mut args = HashMap::new();
1187 args.insert("username".to_string(), "alice".to_string());
1188 args.insert("password".to_string(), "secret".to_string());
1189
1190 assert!(repl.has_secure_arg("login", &args));
1191 }
1192
1193 #[test]
1194 fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1195 let (registry, config) = make_secure_registry_and_config();
1196 let context = Box::new(TestContext::default());
1197 let repl =
1198 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1199
1200 let mut args = HashMap::new();
1202 args.insert("username".to_string(), "alice".to_string());
1203
1204 assert!(!repl.has_secure_arg("login", &args));
1205 }
1206
1207 #[test]
1208 fn test_has_secure_arg_returns_false_for_unknown_command() {
1209 let (registry, config) = make_secure_registry_and_config();
1210 let context = Box::new(TestContext::default());
1211 let repl =
1212 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1213
1214 let mut args = HashMap::new();
1215 args.insert("password".to_string(), "secret".to_string());
1216
1217 assert!(!repl.has_secure_arg("nonexistent", &args));
1218 }
1219
1220 #[test]
1223 fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1224 let (registry, config) = make_secure_registry_and_config();
1225 let context = Box::new(TestContext::default());
1226 let mut repl =
1227 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1228
1229 let result = repl.execute_line("login alice secret");
1230 assert!(result.is_ok());
1231
1232 let history = repl.editor.history();
1234 let in_history = (0..history.len()).any(|i| {
1235 history
1236 .get(i, rustyline::history::SearchDirection::Forward)
1237 .ok()
1238 .flatten()
1239 .map(|e| e.entry.as_ref() == "login alice secret")
1240 .unwrap_or(false)
1241 });
1242 assert!(
1243 !in_history,
1244 "secure command line must not be written to history"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_execute_line_without_secure_arg_adds_to_history() {
1250 let registry = create_test_registry();
1251 let context = Box::new(TestContext::default());
1252 let mut repl =
1253 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1254
1255 let result = repl.execute_line("test");
1256 assert!(result.is_ok());
1257
1258 let history = repl.editor.history();
1260 let in_history = (0..history.len()).any(|i| {
1261 history
1262 .get(i, rustyline::history::SearchDirection::Forward)
1263 .ok()
1264 .flatten()
1265 .map(|e| e.entry.as_ref() == "test")
1266 .unwrap_or(false)
1267 });
1268 assert!(
1269 in_history,
1270 "non-secure command line must be written to history"
1271 );
1272 }
1273}