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 };
755 registry
756 .register_sync(
757 cmd_def,
758 Box::new(TestHandler {
759 name: "test".to_string(),
760 }),
761 )
762 .unwrap();
763 registry
764 }
765
766 fn make_help_config() -> CommandsConfig {
767 use crate::config::schema::{CommandsConfig, Metadata};
768 CommandsConfig {
769 metadata: Metadata {
770 version: "1.0.0".to_string(),
771 prompt: "testapp".to_string(),
772 prompt_suffix: " > ".to_string(),
773 },
774 commands: vec![CommandDefinition {
775 name: "hello".to_string(),
776 aliases: vec!["hi".to_string()],
777 description: "Say hello".to_string(),
778 required: false,
779 arguments: vec![],
780 options: vec![OptionDefinition {
781 name: "loud".to_string(),
782 short: Some("l".to_string()),
783 long: Some("loud".to_string()),
784 option_type: ArgumentType::Bool,
785 required: false,
786 default: Some("false".to_string()),
787 description: "Loud greeting".to_string(),
788 choices: vec![],
789 repeatable: false,
790 option_parameters: HashMap::new(),
791 }],
792 implementation: "hello_handler".to_string(),
793 }],
794 global_options: vec![],
795 }
796 }
797
798 #[test]
801 fn test_repl_interface_creation() {
802 let registry = create_test_registry();
803 let context = Box::new(TestContext::default());
804 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
805 assert!(repl.is_ok());
806 }
807
808 #[test]
809 fn test_repl_interface_creation_with_config() {
810 let registry = create_test_registry();
811 let context = Box::new(TestContext::default());
812 let config = make_help_config();
813 let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
814 assert!(repl.is_ok());
815 }
816
817 #[test]
820 fn test_repl_execute_line() {
821 let registry = create_test_registry();
822 let context = Box::new(TestContext::default());
823 let mut repl =
824 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
825 let result = repl.execute_line("test");
826 assert!(result.is_ok());
827 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
828 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
829 }
830
831 #[test]
832 fn test_repl_execute_with_alias() {
833 let registry = create_test_registry();
834 let context = Box::new(TestContext::default());
835 let mut repl =
836 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
837 assert!(repl.execute_line("t").is_ok());
838 }
839
840 #[test]
841 fn test_repl_execute_unknown_command() {
842 let registry = create_test_registry();
843 let context = Box::new(TestContext::default());
844 let mut repl =
845 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
846 let result = repl.execute_line("unknown");
847 assert!(result.is_err());
848 match result.unwrap_err() {
849 DynamicCliError::Parse(_) => {}
850 other => panic!("Expected Parse error, got: {:?}", other),
851 }
852 }
853
854 #[test]
855 fn test_repl_empty_line() {
856 let registry = create_test_registry();
857 let context = Box::new(TestContext::default());
858 let mut repl =
859 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
860 assert!(repl.execute_line("").is_err());
861 }
862
863 #[test]
864 fn test_repl_command_with_args() {
865 let mut registry = CommandRegistry::new();
866 let cmd_def = CommandDefinition {
867 name: "greet".to_string(),
868 aliases: vec![],
869 description: "Greet someone".to_string(),
870 required: false,
871 arguments: vec![ArgumentDefinition {
872 name: "name".to_string(),
873 arg_type: ArgumentType::String,
874 required: true,
875 description: "Name".to_string(),
876 validation: vec![],
877 secure: false,
878 }],
879 options: vec![],
880 implementation: "greet_handler".to_string(),
881 };
882
883 struct GreetHandler;
884 impl crate::executor::CommandHandler for GreetHandler {
885 fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
886 assert_eq!(args.get_scalar("name"), Some("Alice"));
887 Ok(())
888 }
889 }
890
891 registry
892 .register_sync(cmd_def, Box::new(GreetHandler))
893 .unwrap();
894 let context = Box::new(TestContext::default());
895 let mut repl =
896 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
897 assert!(repl.execute_line("greet Alice").is_ok());
898 }
899
900 #[test]
903 fn test_repl_history_path() {
904 let path = ReplInterface::get_history_path("myapp");
905 if let Some(p) = path {
906 let path_str = p.to_str().unwrap();
907 assert!(path_str.contains("myapp"), "path should contain app name");
908 assert!(
909 path_str.ends_with("history"),
910 "path should end with 'history', got: {}",
911 path_str
912 );
913 }
914 }
915
916 #[test]
919 fn test_try_handle_help_without_formatter_returns_none() {
920 let registry = create_test_registry();
921 let context = Box::new(TestContext::default());
922 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
923 assert!(repl.try_handle_help("--help").is_none());
924 assert!(repl.try_handle_help("-h").is_none());
925 }
926
927 #[test]
928 fn test_try_handle_help_global() {
929 use crate::help::DefaultHelpFormatter;
930 colored::control::set_override(false);
931 let registry = create_test_registry();
932 let context = Box::new(TestContext::default());
933 let config = make_help_config();
934 let repl = ReplInterface::new(
935 registry,
936 context,
937 "test".to_string(),
938 Some(config),
939 Some(Box::new(DefaultHelpFormatter::new())),
940 )
941 .unwrap();
942 let out = repl.try_handle_help("--help");
943 assert!(out.is_some());
944 let out = out.unwrap();
945 assert!(out.contains("testapp"));
946 assert!(out.contains("hello"));
947 }
948
949 #[test]
950 fn test_try_handle_help_short_flag() {
951 use crate::help::DefaultHelpFormatter;
952 colored::control::set_override(false);
953 let registry = create_test_registry();
954 let context = Box::new(TestContext::default());
955 let config = make_help_config();
956 let repl = ReplInterface::new(
957 registry,
958 context,
959 "test".to_string(),
960 Some(config),
961 Some(Box::new(DefaultHelpFormatter::new())),
962 )
963 .unwrap();
964 let out = repl.try_handle_help("-h");
965 assert!(out.is_some());
966 assert!(out.unwrap().contains("testapp"));
967 }
968
969 #[test]
970 fn test_try_handle_help_with_command_prefix() {
971 use crate::help::DefaultHelpFormatter;
972 colored::control::set_override(false);
973 let registry = create_test_registry();
974 let context = Box::new(TestContext::default());
975 let config = make_help_config();
976 let repl = ReplInterface::new(
977 registry,
978 context,
979 "test".to_string(),
980 Some(config),
981 Some(Box::new(DefaultHelpFormatter::new())),
982 )
983 .unwrap();
984 let out = repl.try_handle_help("--help hello");
985 assert!(out.is_some());
986 assert!(out.unwrap().contains("hello"));
987 let out2 = repl.try_handle_help("-h hello");
988 assert!(out2.is_some());
989 }
990
991 #[test]
992 fn test_try_handle_help_command_suffix() {
993 use crate::help::DefaultHelpFormatter;
994 colored::control::set_override(false);
995 let registry = create_test_registry();
996 let context = Box::new(TestContext::default());
997 let config = make_help_config();
998 let repl = ReplInterface::new(
999 registry,
1000 context,
1001 "test".to_string(),
1002 Some(config),
1003 Some(Box::new(DefaultHelpFormatter::new())),
1004 )
1005 .unwrap();
1006 let out = repl.try_handle_help("hello --help");
1007 assert!(out.is_some());
1008 assert!(out.unwrap().contains("hello"));
1009 let out2 = repl.try_handle_help("hello -h");
1010 assert!(out2.is_some());
1011 }
1012
1013 #[test]
1014 fn test_try_handle_help_alias() {
1015 use crate::help::DefaultHelpFormatter;
1016 colored::control::set_override(false);
1017 let registry = create_test_registry();
1018 let context = Box::new(TestContext::default());
1019 let config = make_help_config();
1020 let repl = ReplInterface::new(
1021 registry,
1022 context,
1023 "test".to_string(),
1024 Some(config),
1025 Some(Box::new(DefaultHelpFormatter::new())),
1026 )
1027 .unwrap();
1028 let out = repl.try_handle_help("--help hi");
1029 assert!(out.is_some());
1030 assert!(out.unwrap().contains("hello"));
1031 }
1032
1033 #[test]
1034 fn test_execute_line_help_intercepted() {
1035 use crate::help::DefaultHelpFormatter;
1036 colored::control::set_override(false);
1037 let registry = create_test_registry();
1038 let context = Box::new(TestContext::default());
1039 let config = make_help_config();
1040 let mut repl = ReplInterface::new(
1041 registry,
1042 context,
1043 "test".to_string(),
1044 Some(config),
1045 Some(Box::new(DefaultHelpFormatter::new())),
1046 )
1047 .unwrap();
1048 assert!(repl.execute_line("--help").is_ok());
1049 }
1050
1051 #[test]
1052 fn test_execute_line_normal_command_still_works_with_formatter() {
1053 use crate::help::DefaultHelpFormatter;
1054 let registry = create_test_registry();
1055 let context = Box::new(TestContext::default());
1056 let config = make_help_config();
1057 let mut repl = ReplInterface::new(
1058 registry,
1059 context,
1060 "test".to_string(),
1061 Some(config),
1062 Some(Box::new(DefaultHelpFormatter::new())),
1063 )
1064 .unwrap();
1065 assert!(repl.execute_line("test").is_ok());
1066 }
1067
1068 #[test]
1071 fn test_completer_commands_empty_input() {
1072 let registry = Arc::new(create_test_registry());
1073 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1074 let history = rustyline::history::DefaultHistory::new();
1075 let ctx = rustyline::Context::new(&history);
1076 let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1077 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1078 assert!(names.contains(&"test"));
1079 assert!(names.contains(&"t"));
1080 }
1081
1082 #[test]
1083 fn test_completer_commands_prefix_filter() {
1084 let registry = Arc::new(create_test_registry());
1085 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1086 let history = rustyline::history::DefaultHistory::new();
1087 let ctx = rustyline::Context::new(&history);
1088 let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1089 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1090 assert!(names.contains(&"test"));
1091 assert!(!names.contains(&"t"));
1092 }
1093
1094 #[test]
1095 fn test_completer_flags_after_command() {
1096 let config = Arc::new(make_help_config());
1097 let mut registry = CommandRegistry::new();
1099 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1100 struct DummyHandler;
1101 impl crate::executor::CommandHandler for DummyHandler {
1102 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1103 Ok(())
1104 }
1105 }
1106 registry
1107 .register_sync(cmd_def, Box::new(DummyHandler))
1108 .unwrap();
1109 let registry = Arc::new(registry);
1110
1111 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1112 let history = rustyline::history::DefaultHistory::new();
1113 let ctx = rustyline::Context::new(&history);
1114
1115 let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1117 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1118 assert!(
1119 names.contains(&"--loud"),
1120 "expected --loud, got {:?}",
1121 names
1122 );
1123 assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1124 }
1125
1126 #[test]
1127 fn test_completer_flags_prefix_filter() {
1128 let config = Arc::new(make_help_config());
1129 let mut registry = CommandRegistry::new();
1130 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1131 struct DummyHandler;
1132 impl crate::executor::CommandHandler for DummyHandler {
1133 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1134 Ok(())
1135 }
1136 }
1137 registry
1138 .register_sync(cmd_def, Box::new(DummyHandler))
1139 .unwrap();
1140 let registry = Arc::new(registry);
1141
1142 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1143 let history = rustyline::history::DefaultHistory::new();
1144 let ctx = rustyline::Context::new(&history);
1145
1146 let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1148 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1149 assert!(names.contains(&"--loud"));
1150 assert!(!names.contains(&"-l"));
1151 }
1152
1153 #[test]
1154 fn test_completer_no_flags_for_unknown_command() {
1155 let config = Arc::new(make_help_config());
1156 let registry = Arc::new(create_test_registry());
1157 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1158 let history = rustyline::history::DefaultHistory::new();
1159 let ctx = rustyline::Context::new(&history);
1160 let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1162 assert!(candidates.is_empty());
1163 }
1164
1165 fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1169 use crate::config::schema::{CommandsConfig, Metadata};
1170
1171 let cmd_def = CommandDefinition {
1172 name: "login".to_string(),
1173 aliases: vec![],
1174 description: "Login command".to_string(),
1175 required: false,
1176 arguments: vec![
1177 ArgumentDefinition {
1178 name: "username".to_string(),
1179 arg_type: ArgumentType::String,
1180 required: true,
1181 description: "Username".to_string(),
1182 validation: vec![],
1183 secure: false,
1184 },
1185 ArgumentDefinition {
1186 name: "password".to_string(),
1187 arg_type: ArgumentType::String,
1188 required: true,
1189 description: "Password".to_string(),
1190 validation: vec![],
1191 secure: true,
1192 },
1193 ],
1194 options: vec![],
1195 implementation: "login_handler".to_string(),
1196 };
1197
1198 struct LoginHandler;
1199 impl crate::executor::CommandHandler for LoginHandler {
1200 fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1201 Ok(())
1202 }
1203 }
1204
1205 let mut registry = CommandRegistry::new();
1206 registry
1207 .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1208 .unwrap();
1209
1210 let config = CommandsConfig {
1211 metadata: Metadata {
1212 version: "1.0.0".to_string(),
1213 prompt: "testapp".to_string(),
1214 prompt_suffix: " > ".to_string(),
1215 },
1216 commands: vec![cmd_def],
1217 global_options: vec![],
1218 };
1219
1220 (registry, config)
1221 }
1222
1223 #[test]
1224 fn test_has_secure_arg_returns_false_without_config() {
1225 let registry = create_test_registry();
1226 let context = Box::new(TestContext::default());
1227 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1228
1229 let mut args = HashMap::new();
1230 args.insert("password".to_string(), "secret".to_string());
1231
1232 assert!(!repl.has_secure_arg("login", &args));
1233 }
1234
1235 #[test]
1236 fn test_has_secure_arg_returns_false_when_no_secure_field() {
1237 let registry = create_test_registry();
1238 let context = Box::new(TestContext::default());
1239 let config = make_help_config();
1240 let repl =
1241 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1242
1243 let mut args = HashMap::new();
1244 args.insert("loud".to_string(), "true".to_string());
1245
1246 assert!(!repl.has_secure_arg("hello", &args));
1247 }
1248
1249 #[test]
1250 fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1251 let (registry, config) = make_secure_registry_and_config();
1252 let context = Box::new(TestContext::default());
1253 let repl =
1254 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1255
1256 let mut args = HashMap::new();
1257 args.insert("username".to_string(), "alice".to_string());
1258 args.insert("password".to_string(), "secret".to_string());
1259
1260 assert!(repl.has_secure_arg("login", &args));
1261 }
1262
1263 #[test]
1264 fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1265 let (registry, config) = make_secure_registry_and_config();
1266 let context = Box::new(TestContext::default());
1267 let repl =
1268 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1269
1270 let mut args = HashMap::new();
1272 args.insert("username".to_string(), "alice".to_string());
1273
1274 assert!(!repl.has_secure_arg("login", &args));
1275 }
1276
1277 #[test]
1278 fn test_has_secure_arg_returns_false_for_unknown_command() {
1279 let (registry, config) = make_secure_registry_and_config();
1280 let context = Box::new(TestContext::default());
1281 let repl =
1282 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1283
1284 let mut args = HashMap::new();
1285 args.insert("password".to_string(), "secret".to_string());
1286
1287 assert!(!repl.has_secure_arg("nonexistent", &args));
1288 }
1289
1290 #[test]
1293 fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1294 let (registry, config) = make_secure_registry_and_config();
1295 let context = Box::new(TestContext::default());
1296 let mut repl =
1297 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1298
1299 let result = repl.execute_line("login alice secret");
1300 assert!(result.is_ok());
1301
1302 let history = repl.editor.history();
1304 let in_history = (0..history.len()).any(|i| {
1305 history
1306 .get(i, rustyline::history::SearchDirection::Forward)
1307 .ok()
1308 .flatten()
1309 .map(|e| e.entry.as_ref() == "login alice secret")
1310 .unwrap_or(false)
1311 });
1312 assert!(
1313 !in_history,
1314 "secure command line must not be written to history"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_execute_line_without_secure_arg_adds_to_history() {
1320 let registry = create_test_registry();
1321 let context = Box::new(TestContext::default());
1322 let mut repl =
1323 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1324
1325 let result = repl.execute_line("test");
1326 assert!(result.is_ok());
1327
1328 let history = repl.editor.history();
1330 let in_history = (0..history.len()).any(|i| {
1331 history
1332 .get(i, rustyline::history::SearchDirection::Forward)
1333 .ok()
1334 .flatten()
1335 .map(|e| e.entry.as_ref() == "test")
1336 .unwrap_or(false)
1337 });
1338 assert!(
1339 in_history,
1340 "non-secure command line must be written to history"
1341 );
1342 }
1343
1344 fn write_script(content: &str) -> tempfile::NamedTempFile {
1347 use std::io::Write;
1348 let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1349 file.write_all(content.as_bytes())
1350 .expect("failed to write temp script file");
1351 file
1352 }
1353
1354 #[test]
1355 fn test_load_executes_each_line_via_execute_line() {
1356 let registry = create_test_registry();
1357 let context = Box::new(TestContext::default());
1358 let mut repl =
1359 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1360
1361 let script = write_script("test\nt\n");
1362 let line = format!(":load {}", script.path().display());
1363
1364 assert!(repl.execute_line(&line).is_ok());
1365
1366 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1367 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1368 }
1369
1370 #[test]
1371 fn test_load_skips_blank_lines_and_comments() {
1372 let registry = create_test_registry();
1373 let context = Box::new(TestContext::default());
1374 let mut repl =
1375 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1376
1377 let script = write_script("# a comment\n\ntest\n \n# another\n");
1378 let line = format!(":load {}", script.path().display());
1379
1380 assert!(repl.execute_line(&line).is_ok());
1381
1382 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1383 assert_eq!(ctx.executed_commands, vec!["test"]);
1384 }
1385
1386 #[test]
1387 fn test_load_continues_past_a_failing_line() {
1388 let registry = create_test_registry();
1389 let context = Box::new(TestContext::default());
1390 let mut repl =
1391 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1392
1393 let script = write_script("test\nunknown_command\ntest\n");
1394 let line = format!(":load {}", script.path().display());
1395
1396 let result = repl.execute_line(&line);
1400 assert!(result.is_ok());
1401
1402 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1403 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1404 }
1405
1406 #[test]
1407 fn test_load_missing_path_argument_is_an_error() {
1408 let registry = create_test_registry();
1409 let context = Box::new(TestContext::default());
1410 let mut repl =
1411 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1412
1413 let result = repl.execute_line(":load");
1414 assert!(result.is_err());
1415 }
1416
1417 #[test]
1418 fn test_load_missing_file_is_an_error() {
1419 let registry = create_test_registry();
1420 let context = Box::new(TestContext::default());
1421 let mut repl =
1422 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1423
1424 let result = repl.execute_line(":load /nonexistent/path/to/script.txt");
1425 assert!(result.is_err());
1426 }
1427
1428 #[test]
1429 fn test_load_line_itself_is_not_added_to_history() {
1430 let registry = create_test_registry();
1431 let context = Box::new(TestContext::default());
1432 let mut repl =
1433 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1434
1435 let script = write_script("test\n");
1436 let line = format!(":load {}", script.path().display());
1437 assert!(repl.execute_line(&line).is_ok());
1438
1439 let history = repl.editor.history();
1440 let load_in_history = (0..history.len()).any(|i| {
1441 history
1442 .get(i, rustyline::history::SearchDirection::Forward)
1443 .ok()
1444 .flatten()
1445 .map(|e| e.entry.starts_with(":load"))
1446 .unwrap_or(false)
1447 });
1448 assert!(
1449 !load_in_history,
1450 ":load line itself must not be written to history"
1451 );
1452 }
1453}