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, ParseError, 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 try_handle_load(&mut self, line: &str) -> Option<Result<()>> {
454 let path = line.trim().strip_prefix(":load ").map(str::trim)?;
455
456 if path.is_empty() {
457 return Some(Err(DynamicCliError::Parse(ParseError::InvalidSyntax {
458 details: "`:load` requires a file path".to_string(),
459 hint: Some("Usage: :load <path/to/script.txt>".to_string()),
460 })));
461 }
462
463 Some(self.load_script(path))
464 }
465
466 fn load_script(&mut self, path: &str) -> Result<()> {
471 let content = std::fs::read_to_string(path).map_err(|e| {
472 DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
473 "failed to read script file {}: {}",
474 path,
475 e
476 )))
477 })?;
478
479 let mut attempted = 0usize;
480 let mut succeeded = 0usize;
481
482 for (idx, raw_line) in content.lines().enumerate() {
483 let line_number = idx + 1;
484 let script_line = raw_line.trim();
485
486 if script_line.is_empty() || script_line.starts_with('#') {
487 continue;
488 }
489
490 attempted += 1;
491
492 match self.execute_line(script_line) {
493 Ok(()) => succeeded += 1,
494 Err(e) => {
495 eprintln!(" :load {} — line {}:", path, line_number);
496 display_error(&e);
497 }
498 }
499 }
500
501 println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
502 Ok(())
503 }
504
505 fn has_secure_arg(
511 &self,
512 command_name: &str,
513 parsed_args: &std::collections::HashMap<String, String>,
514 ) -> bool {
515 let config = match &self.config {
516 Some(c) => c,
517 None => return false,
518 };
519
520 let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
521 Some(d) => d,
522 None => return false,
523 };
524
525 cmd_def
526 .arguments
527 .iter()
528 .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
529 }
530
531 fn get_history_path(app_name: &str) -> Option<PathBuf> {
539 dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
540 }
541
542 fn load_history(&mut self) {
544 if let Some(ref path) = self.history_path {
545 if let Some(parent) = path.parent() {
546 let _ = std::fs::create_dir_all(parent);
547 }
548 let _ = self.editor.load_history(path);
549 }
550 }
551
552 fn save_history(&mut self) {
554 if let Some(ref path) = self.history_path {
555 if let Err(e) = self.editor.save_history(path) {
556 eprintln!("Warning: Failed to save command history: {}", e);
557 }
558 }
559 }
560
561 pub fn run(mut self) -> Result<()> {
597 loop {
598 let readline = self.editor.readline(&self.prompt);
599
600 match readline {
601 Ok(line) => {
602 let line = line.trim();
603 if line.is_empty() {
604 continue;
605 }
606
607 if line == "exit" || line == "quit" {
608 println!("Goodbye!");
609 break;
610 }
611
612 match self.execute_line(line) {
616 Ok(()) => {}
617 Err(e) => {
618 display_error(&e);
619 }
620 }
621 }
622
623 Err(ReadlineError::Interrupted) => {
624 println!("^C");
625 continue;
626 }
627
628 Err(ReadlineError::Eof) => {
629 println!("exit");
630 break;
631 }
632
633 Err(err) => {
634 eprintln!("Error reading input: {}", err);
635 break;
636 }
637 }
638 }
639
640 self.save_history();
641 Ok(())
642 }
643
644 fn execute_line(&mut self, line: &str) -> Result<()> {
653 if let Some(output) = self.try_handle_help(line) {
654 print!("{}", output);
655 return Ok(());
656 }
657
658 if let Some(result) = self.try_handle_load(line) {
659 return result;
660 }
661
662 let parser = ReplParser::new(&self.registry);
663 let parsed = parser.parse_line(line)?;
664
665 if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
668 let _ = self.editor.add_history_entry(line);
669 }
670
671 let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
684 if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
685 handler.execute(&mut *self.context, &parsed_args)?;
686 } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
687 futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
688 } else {
689 return Err(DynamicCliError::Execution(
690 ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
691 ));
692 }
693
694 Ok(())
695 }
696}
697
698impl Drop for ReplInterface {
699 fn drop(&mut self) {
700 self.save_history();
701 }
702}
703
704#[cfg(test)]
709mod tests {
710 use super::*;
711 use crate::config::schema::{
712 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
713 };
714 use rustyline::history::History;
715 use std::collections::HashMap;
716
717 #[derive(Default)]
718 struct TestContext {
719 executed_commands: Vec<String>,
720 }
721
722 impl ExecutionContext for TestContext {
723 fn as_any(&self) -> &dyn std::any::Any {
724 self
725 }
726 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
727 self
728 }
729 }
730
731 struct TestHandler {
732 name: String,
733 }
734
735 impl crate::executor::CommandHandler for TestHandler {
736 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
737 let ctx = crate::context::downcast_mut::<TestContext>(context)
738 .expect("Failed to downcast context");
739 ctx.executed_commands.push(self.name.clone());
740 Ok(())
741 }
742 }
743
744 fn create_test_registry() -> CommandRegistry {
745 let mut registry = CommandRegistry::new();
746 let cmd_def = CommandDefinition {
747 name: "test".to_string(),
748 aliases: vec!["t".to_string()],
749 description: "Test command".to_string(),
750 required: false,
751 arguments: vec![],
752 options: vec![],
753 implementation: "test_handler".to_string(),
754 continue_on_failure: false,
755 requires_success: false,
756 };
757 registry
758 .register_sync(
759 cmd_def,
760 Box::new(TestHandler {
761 name: "test".to_string(),
762 }),
763 )
764 .unwrap();
765 registry
766 }
767
768 fn make_help_config() -> CommandsConfig {
769 use crate::config::schema::{CommandsConfig, Metadata};
770 CommandsConfig {
771 metadata: Metadata {
772 version: "1.0.0".to_string(),
773 prompt: "testapp".to_string(),
774 prompt_suffix: " > ".to_string(),
775 },
776 commands: vec![CommandDefinition {
777 name: "hello".to_string(),
778 aliases: vec!["hi".to_string()],
779 description: "Say hello".to_string(),
780 required: false,
781 arguments: vec![],
782 options: vec![OptionDefinition {
783 name: "loud".to_string(),
784 short: Some("l".to_string()),
785 long: Some("loud".to_string()),
786 option_type: ArgumentType::Bool,
787 required: false,
788 default: Some("false".to_string()),
789 description: "Loud greeting".to_string(),
790 choices: vec![],
791 repeatable: false,
792 option_parameters: HashMap::new(),
793 }],
794 implementation: "hello_handler".to_string(),
795 continue_on_failure: false,
796 requires_success: false,
797 }],
798 global_options: vec![],
799 }
800 }
801
802 #[test]
805 fn test_repl_interface_creation() {
806 let registry = create_test_registry();
807 let context = Box::new(TestContext::default());
808 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
809 assert!(repl.is_ok());
810 }
811
812 #[test]
813 fn test_repl_interface_creation_with_config() {
814 let registry = create_test_registry();
815 let context = Box::new(TestContext::default());
816 let config = make_help_config();
817 let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
818 assert!(repl.is_ok());
819 }
820
821 #[test]
824 fn test_repl_execute_line() {
825 let registry = create_test_registry();
826 let context = Box::new(TestContext::default());
827 let mut repl =
828 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
829 let result = repl.execute_line("test");
830 assert!(result.is_ok());
831 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
832 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
833 }
834
835 #[test]
836 fn test_repl_execute_with_alias() {
837 let registry = create_test_registry();
838 let context = Box::new(TestContext::default());
839 let mut repl =
840 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
841 assert!(repl.execute_line("t").is_ok());
842 }
843
844 #[test]
845 fn test_repl_execute_unknown_command() {
846 let registry = create_test_registry();
847 let context = Box::new(TestContext::default());
848 let mut repl =
849 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
850 let result = repl.execute_line("unknown");
851 assert!(result.is_err());
852 match result.unwrap_err() {
853 DynamicCliError::Parse(_) => {}
854 other => panic!("Expected Parse error, got: {:?}", other),
855 }
856 }
857
858 #[test]
859 fn test_repl_empty_line() {
860 let registry = create_test_registry();
861 let context = Box::new(TestContext::default());
862 let mut repl =
863 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
864 assert!(repl.execute_line("").is_err());
865 }
866
867 #[test]
868 fn test_repl_command_with_args() {
869 let mut registry = CommandRegistry::new();
870 let cmd_def = CommandDefinition {
871 name: "greet".to_string(),
872 aliases: vec![],
873 description: "Greet someone".to_string(),
874 required: false,
875 arguments: vec![ArgumentDefinition {
876 name: "name".to_string(),
877 arg_type: ArgumentType::String,
878 required: true,
879 description: "Name".to_string(),
880 validation: vec![],
881 secure: false,
882 }],
883 options: vec![],
884 implementation: "greet_handler".to_string(),
885 continue_on_failure: false,
886 requires_success: false,
887 };
888
889 struct GreetHandler;
890 impl crate::executor::CommandHandler for GreetHandler {
891 fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
892 assert_eq!(args.get_scalar("name"), Some("Alice"));
893 Ok(())
894 }
895 }
896
897 registry
898 .register_sync(cmd_def, Box::new(GreetHandler))
899 .unwrap();
900 let context = Box::new(TestContext::default());
901 let mut repl =
902 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
903 assert!(repl.execute_line("greet Alice").is_ok());
904 }
905
906 #[test]
909 fn test_repl_history_path() {
910 let path = ReplInterface::get_history_path("myapp");
911 if let Some(p) = path {
912 let path_str = p.to_str().unwrap();
913 assert!(path_str.contains("myapp"), "path should contain app name");
914 assert!(
915 path_str.ends_with("history"),
916 "path should end with 'history', got: {}",
917 path_str
918 );
919 }
920 }
921
922 #[test]
925 fn test_try_handle_help_without_formatter_returns_none() {
926 let registry = create_test_registry();
927 let context = Box::new(TestContext::default());
928 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
929 assert!(repl.try_handle_help("--help").is_none());
930 assert!(repl.try_handle_help("-h").is_none());
931 }
932
933 #[test]
934 fn test_try_handle_help_global() {
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");
949 assert!(out.is_some());
950 let out = out.unwrap();
951 assert!(out.contains("testapp"));
952 assert!(out.contains("hello"));
953 }
954
955 #[test]
956 fn test_try_handle_help_short_flag() {
957 use crate::help::DefaultHelpFormatter;
958 colored::control::set_override(false);
959 let registry = create_test_registry();
960 let context = Box::new(TestContext::default());
961 let config = make_help_config();
962 let repl = ReplInterface::new(
963 registry,
964 context,
965 "test".to_string(),
966 Some(config),
967 Some(Box::new(DefaultHelpFormatter::new())),
968 )
969 .unwrap();
970 let out = repl.try_handle_help("-h");
971 assert!(out.is_some());
972 assert!(out.unwrap().contains("testapp"));
973 }
974
975 #[test]
976 fn test_try_handle_help_with_command_prefix() {
977 use crate::help::DefaultHelpFormatter;
978 colored::control::set_override(false);
979 let registry = create_test_registry();
980 let context = Box::new(TestContext::default());
981 let config = make_help_config();
982 let repl = ReplInterface::new(
983 registry,
984 context,
985 "test".to_string(),
986 Some(config),
987 Some(Box::new(DefaultHelpFormatter::new())),
988 )
989 .unwrap();
990 let out = repl.try_handle_help("--help hello");
991 assert!(out.is_some());
992 assert!(out.unwrap().contains("hello"));
993 let out2 = repl.try_handle_help("-h hello");
994 assert!(out2.is_some());
995 }
996
997 #[test]
998 fn test_try_handle_help_command_suffix() {
999 use crate::help::DefaultHelpFormatter;
1000 colored::control::set_override(false);
1001 let registry = create_test_registry();
1002 let context = Box::new(TestContext::default());
1003 let config = make_help_config();
1004 let repl = ReplInterface::new(
1005 registry,
1006 context,
1007 "test".to_string(),
1008 Some(config),
1009 Some(Box::new(DefaultHelpFormatter::new())),
1010 )
1011 .unwrap();
1012 let out = repl.try_handle_help("hello --help");
1013 assert!(out.is_some());
1014 assert!(out.unwrap().contains("hello"));
1015 let out2 = repl.try_handle_help("hello -h");
1016 assert!(out2.is_some());
1017 }
1018
1019 #[test]
1020 fn test_try_handle_help_alias() {
1021 use crate::help::DefaultHelpFormatter;
1022 colored::control::set_override(false);
1023 let registry = create_test_registry();
1024 let context = Box::new(TestContext::default());
1025 let config = make_help_config();
1026 let repl = ReplInterface::new(
1027 registry,
1028 context,
1029 "test".to_string(),
1030 Some(config),
1031 Some(Box::new(DefaultHelpFormatter::new())),
1032 )
1033 .unwrap();
1034 let out = repl.try_handle_help("--help hi");
1035 assert!(out.is_some());
1036 assert!(out.unwrap().contains("hello"));
1037 }
1038
1039 #[test]
1040 fn test_execute_line_help_intercepted() {
1041 use crate::help::DefaultHelpFormatter;
1042 colored::control::set_override(false);
1043 let registry = create_test_registry();
1044 let context = Box::new(TestContext::default());
1045 let config = make_help_config();
1046 let mut repl = ReplInterface::new(
1047 registry,
1048 context,
1049 "test".to_string(),
1050 Some(config),
1051 Some(Box::new(DefaultHelpFormatter::new())),
1052 )
1053 .unwrap();
1054 assert!(repl.execute_line("--help").is_ok());
1055 }
1056
1057 #[test]
1058 fn test_execute_line_normal_command_still_works_with_formatter() {
1059 use crate::help::DefaultHelpFormatter;
1060 let registry = create_test_registry();
1061 let context = Box::new(TestContext::default());
1062 let config = make_help_config();
1063 let mut repl = ReplInterface::new(
1064 registry,
1065 context,
1066 "test".to_string(),
1067 Some(config),
1068 Some(Box::new(DefaultHelpFormatter::new())),
1069 )
1070 .unwrap();
1071 assert!(repl.execute_line("test").is_ok());
1072 }
1073
1074 #[test]
1077 fn test_completer_commands_empty_input() {
1078 let registry = Arc::new(create_test_registry());
1079 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1080 let history = rustyline::history::DefaultHistory::new();
1081 let ctx = rustyline::Context::new(&history);
1082 let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1083 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1084 assert!(names.contains(&"test"));
1085 assert!(names.contains(&"t"));
1086 }
1087
1088 #[test]
1089 fn test_completer_commands_prefix_filter() {
1090 let registry = Arc::new(create_test_registry());
1091 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1092 let history = rustyline::history::DefaultHistory::new();
1093 let ctx = rustyline::Context::new(&history);
1094 let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1095 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1096 assert!(names.contains(&"test"));
1097 assert!(!names.contains(&"t"));
1098 }
1099
1100 #[test]
1101 fn test_completer_flags_after_command() {
1102 let config = Arc::new(make_help_config());
1103 let mut registry = CommandRegistry::new();
1105 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1106 struct DummyHandler;
1107 impl crate::executor::CommandHandler for DummyHandler {
1108 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1109 Ok(())
1110 }
1111 }
1112 registry
1113 .register_sync(cmd_def, Box::new(DummyHandler))
1114 .unwrap();
1115 let registry = Arc::new(registry);
1116
1117 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1118 let history = rustyline::history::DefaultHistory::new();
1119 let ctx = rustyline::Context::new(&history);
1120
1121 let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1123 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1124 assert!(
1125 names.contains(&"--loud"),
1126 "expected --loud, got {:?}",
1127 names
1128 );
1129 assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1130 }
1131
1132 #[test]
1133 fn test_completer_flags_prefix_filter() {
1134 let config = Arc::new(make_help_config());
1135 let mut registry = CommandRegistry::new();
1136 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1137 struct DummyHandler;
1138 impl crate::executor::CommandHandler for DummyHandler {
1139 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1140 Ok(())
1141 }
1142 }
1143 registry
1144 .register_sync(cmd_def, Box::new(DummyHandler))
1145 .unwrap();
1146 let registry = Arc::new(registry);
1147
1148 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1149 let history = rustyline::history::DefaultHistory::new();
1150 let ctx = rustyline::Context::new(&history);
1151
1152 let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1154 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1155 assert!(names.contains(&"--loud"));
1156 assert!(!names.contains(&"-l"));
1157 }
1158
1159 #[test]
1160 fn test_completer_no_flags_for_unknown_command() {
1161 let config = Arc::new(make_help_config());
1162 let registry = Arc::new(create_test_registry());
1163 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1164 let history = rustyline::history::DefaultHistory::new();
1165 let ctx = rustyline::Context::new(&history);
1166 let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1168 assert!(candidates.is_empty());
1169 }
1170
1171 fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1175 use crate::config::schema::{CommandsConfig, Metadata};
1176
1177 let cmd_def = CommandDefinition {
1178 name: "login".to_string(),
1179 aliases: vec![],
1180 description: "Login command".to_string(),
1181 required: false,
1182 arguments: vec![
1183 ArgumentDefinition {
1184 name: "username".to_string(),
1185 arg_type: ArgumentType::String,
1186 required: true,
1187 description: "Username".to_string(),
1188 validation: vec![],
1189 secure: false,
1190 },
1191 ArgumentDefinition {
1192 name: "password".to_string(),
1193 arg_type: ArgumentType::String,
1194 required: true,
1195 description: "Password".to_string(),
1196 validation: vec![],
1197 secure: true,
1198 },
1199 ],
1200 options: vec![],
1201 implementation: "login_handler".to_string(),
1202 continue_on_failure: false,
1203 requires_success: false,
1204 };
1205
1206 struct LoginHandler;
1207 impl crate::executor::CommandHandler for LoginHandler {
1208 fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1209 Ok(())
1210 }
1211 }
1212
1213 let mut registry = CommandRegistry::new();
1214 registry
1215 .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1216 .unwrap();
1217
1218 let config = CommandsConfig {
1219 metadata: Metadata {
1220 version: "1.0.0".to_string(),
1221 prompt: "testapp".to_string(),
1222 prompt_suffix: " > ".to_string(),
1223 },
1224 commands: vec![cmd_def],
1225 global_options: vec![],
1226 };
1227
1228 (registry, config)
1229 }
1230
1231 #[test]
1232 fn test_has_secure_arg_returns_false_without_config() {
1233 let registry = create_test_registry();
1234 let context = Box::new(TestContext::default());
1235 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1236
1237 let mut args = HashMap::new();
1238 args.insert("password".to_string(), "secret".to_string());
1239
1240 assert!(!repl.has_secure_arg("login", &args));
1241 }
1242
1243 #[test]
1244 fn test_has_secure_arg_returns_false_when_no_secure_field() {
1245 let registry = create_test_registry();
1246 let context = Box::new(TestContext::default());
1247 let config = make_help_config();
1248 let repl =
1249 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1250
1251 let mut args = HashMap::new();
1252 args.insert("loud".to_string(), "true".to_string());
1253
1254 assert!(!repl.has_secure_arg("hello", &args));
1255 }
1256
1257 #[test]
1258 fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1259 let (registry, config) = make_secure_registry_and_config();
1260 let context = Box::new(TestContext::default());
1261 let repl =
1262 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1263
1264 let mut args = HashMap::new();
1265 args.insert("username".to_string(), "alice".to_string());
1266 args.insert("password".to_string(), "secret".to_string());
1267
1268 assert!(repl.has_secure_arg("login", &args));
1269 }
1270
1271 #[test]
1272 fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1273 let (registry, config) = make_secure_registry_and_config();
1274 let context = Box::new(TestContext::default());
1275 let repl =
1276 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1277
1278 let mut args = HashMap::new();
1280 args.insert("username".to_string(), "alice".to_string());
1281
1282 assert!(!repl.has_secure_arg("login", &args));
1283 }
1284
1285 #[test]
1286 fn test_has_secure_arg_returns_false_for_unknown_command() {
1287 let (registry, config) = make_secure_registry_and_config();
1288 let context = Box::new(TestContext::default());
1289 let repl =
1290 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1291
1292 let mut args = HashMap::new();
1293 args.insert("password".to_string(), "secret".to_string());
1294
1295 assert!(!repl.has_secure_arg("nonexistent", &args));
1296 }
1297
1298 #[test]
1301 fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1302 let (registry, config) = make_secure_registry_and_config();
1303 let context = Box::new(TestContext::default());
1304 let mut repl =
1305 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1306
1307 let result = repl.execute_line("login alice secret");
1308 assert!(result.is_ok());
1309
1310 let history = repl.editor.history();
1312 let in_history = (0..history.len()).any(|i| {
1313 history
1314 .get(i, rustyline::history::SearchDirection::Forward)
1315 .ok()
1316 .flatten()
1317 .map(|e| e.entry.as_ref() == "login alice secret")
1318 .unwrap_or(false)
1319 });
1320 assert!(
1321 !in_history,
1322 "secure command line must not be written to history"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_execute_line_without_secure_arg_adds_to_history() {
1328 let registry = create_test_registry();
1329 let context = Box::new(TestContext::default());
1330 let mut repl =
1331 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1332
1333 let result = repl.execute_line("test");
1334 assert!(result.is_ok());
1335
1336 let history = repl.editor.history();
1338 let in_history = (0..history.len()).any(|i| {
1339 history
1340 .get(i, rustyline::history::SearchDirection::Forward)
1341 .ok()
1342 .flatten()
1343 .map(|e| e.entry.as_ref() == "test")
1344 .unwrap_or(false)
1345 });
1346 assert!(
1347 in_history,
1348 "non-secure command line must be written to history"
1349 );
1350 }
1351
1352 fn write_script(content: &str) -> tempfile::NamedTempFile {
1355 use std::io::Write;
1356 let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1357 file.write_all(content.as_bytes())
1358 .expect("failed to write temp script file");
1359 file
1360 }
1361
1362 #[test]
1363 fn test_load_executes_each_line_via_execute_line() {
1364 let registry = create_test_registry();
1365 let context = Box::new(TestContext::default());
1366 let mut repl =
1367 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1368
1369 let script = write_script("test\nt\n");
1370 let line = format!(":load {}", script.path().display());
1371
1372 assert!(repl.execute_line(&line).is_ok());
1373
1374 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1375 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1376 }
1377
1378 #[test]
1379 fn test_load_skips_blank_lines_and_comments() {
1380 let registry = create_test_registry();
1381 let context = Box::new(TestContext::default());
1382 let mut repl =
1383 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1384
1385 let script = write_script("# a comment\n\ntest\n \n# another\n");
1386 let line = format!(":load {}", script.path().display());
1387
1388 assert!(repl.execute_line(&line).is_ok());
1389
1390 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1391 assert_eq!(ctx.executed_commands, vec!["test"]);
1392 }
1393
1394 #[test]
1395 fn test_load_continues_past_a_failing_line() {
1396 let registry = create_test_registry();
1397 let context = Box::new(TestContext::default());
1398 let mut repl =
1399 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1400
1401 let script = write_script("test\nunknown_command\ntest\n");
1402 let line = format!(":load {}", script.path().display());
1403
1404 let result = repl.execute_line(&line);
1408 assert!(result.is_ok());
1409
1410 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1411 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1412 }
1413
1414 #[test]
1415 fn test_load_missing_path_argument_is_an_error() {
1416 let registry = create_test_registry();
1417 let context = Box::new(TestContext::default());
1418 let mut repl =
1419 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1420
1421 let result = repl.execute_line(":load");
1422 assert!(result.is_err());
1423 }
1424
1425 #[test]
1426 fn test_load_missing_file_is_an_error() {
1427 let registry = create_test_registry();
1428 let context = Box::new(TestContext::default());
1429 let mut repl =
1430 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1431
1432 let result = repl.execute_line(":load /nonexistent/path/to/script.txt");
1433 assert!(result.is_err());
1434 }
1435
1436 #[test]
1437 fn test_load_line_itself_is_not_added_to_history() {
1438 let registry = create_test_registry();
1439 let context = Box::new(TestContext::default());
1440 let mut repl =
1441 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1442
1443 let script = write_script("test\n");
1444 let line = format!(":load {}", script.path().display());
1445 assert!(repl.execute_line(&line).is_ok());
1446
1447 let history = repl.editor.history();
1448 let load_in_history = (0..history.len()).any(|i| {
1449 history
1450 .get(i, rustyline::history::SearchDirection::Forward)
1451 .ok()
1452 .flatten()
1453 .map(|e| e.entry.starts_with(":load"))
1454 .unwrap_or(false)
1455 });
1456 assert!(
1457 !load_in_history,
1458 ":load line itself must not be written to history"
1459 );
1460 }
1461
1462 #[test]
1467 fn test_repl_line_is_never_chained_across_multiple_commands() {
1468 let mut registry = CommandRegistry::new();
1476 for name in ["first", "second"] {
1477 let cmd_def = CommandDefinition {
1478 name: name.to_string(),
1479 aliases: vec![],
1480 description: format!("Test command {}", name),
1481 required: false,
1482 arguments: vec![ArgumentDefinition {
1483 name: "value".to_string(),
1484 arg_type: ArgumentType::String,
1485 required: true,
1486 description: "Value".to_string(),
1487 validation: vec![],
1488 secure: false,
1489 }],
1490 options: vec![],
1491 implementation: format!("{}_handler", name),
1492 continue_on_failure: false,
1493 requires_success: false,
1494 };
1495 registry
1496 .register_sync(
1497 cmd_def,
1498 Box::new(TestHandler {
1499 name: name.to_string(),
1500 }),
1501 )
1502 .unwrap();
1503 }
1504
1505 let context = Box::new(TestContext::default());
1506 let mut repl =
1507 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1508
1509 let result = repl.execute_line("first 1 second");
1512
1513 assert!(result.is_err());
1514 match result.unwrap_err() {
1515 DynamicCliError::Parse(ParseError::TooManyArguments { command, .. }) => {
1516 assert_eq!(command, "first");
1517 }
1518 other => panic!("Expected TooManyArguments error, got: {:?}", other),
1519 }
1520
1521 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1524 assert!(ctx.executed_commands.is_empty());
1525 }
1526}