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