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,
282
283 prompt_suffix: String,
292
293 editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
295
296 history_path: Option<PathBuf>,
298
299 config: Option<Arc<CommandsConfig>>,
302
303 help_formatter: Option<Box<dyn HelpFormatter>>,
306
307 prompt_multiline: Option<String>,
316}
317
318impl ReplInterface {
319 pub fn new(
366 registry: CommandRegistry,
367 context: Box<dyn ExecutionContext>,
368 prompt: String,
369 config: Option<CommandsConfig>,
370 help_formatter: Option<Box<dyn HelpFormatter>>,
371 ) -> Result<Self> {
372 let registry = Arc::new(registry);
374
375 let prompt_suffix = config
380 .as_ref()
381 .map(|c| c.metadata.prompt_suffix.clone())
382 .unwrap_or_else(crate::config::schema::default_prompt_suffix);
383
384 let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
386
387 let rl_config = Config::builder()
389 .completion_type(CompletionType::List)
390 .build();
391
392 let helper = DcliHelper::new(Arc::clone(®istry), config.clone());
393
394 let mut editor = Editor::with_config(rl_config).map_err(|e| {
395 ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
396 })?;
397 editor.set_helper(Some(helper));
398
399 let history_path = Self::get_history_path(&prompt);
401
402 let mut repl = Self {
403 registry,
404 context,
405 prompt: format!("{}{}", prompt, prompt_suffix),
406 prompt_suffix,
407 editor,
408 history_path,
409 config,
410 help_formatter,
411 prompt_multiline: None,
412 };
413
414 repl.load_history();
415
416 Ok(repl)
417 }
418
419 pub fn with_prompt_multiline(mut self, prompt: String) -> Self {
454 self.prompt_multiline = Some(prompt);
455 self
456 }
457
458 fn effective_prompt_multiline(&self) -> String {
475 match &self.prompt_multiline {
476 Some(base) => format!("{}{}", base, self.prompt_suffix),
477 None => format!("...{}", self.prompt_suffix.trim_start()),
478 }
479 }
480
481 fn try_handle_help(&self, line: &str) -> Option<String> {
497 let config = self.config.as_deref()?;
498 let formatter = self.help_formatter.as_deref()?;
499
500 let trimmed = line.trim();
501
502 if trimmed == "--help" || trimmed == "-h" {
503 return Some(formatter.format_app(config));
504 }
505
506 if let Some(rest) = trimmed
507 .strip_prefix("--help ")
508 .or_else(|| trimmed.strip_prefix("-h "))
509 {
510 let cmd = rest.trim();
511 if !cmd.is_empty() {
512 return Some(formatter.format_command(config, cmd));
513 }
514 }
515
516 let parts: Vec<&str> = trimmed.split_whitespace().collect();
517 if parts.len() >= 2 {
518 let last = *parts.last().unwrap();
519 if last == "--help" || last == "-h" {
520 return Some(formatter.format_command(config, parts[0]));
521 }
522 }
523
524 None
525 }
526
527 fn try_handle_load(&mut self, line: &str) -> Option<Result<()>> {
552 let path = line.trim().strip_prefix(":load ").map(str::trim)?;
553
554 if path.is_empty() {
555 return Some(Err(DynamicCliError::Parse(ParseError::InvalidSyntax {
556 details: "`:load` requires a file path".to_string(),
557 hint: Some("Usage: :load <path/to/script.txt>".to_string()),
558 })));
559 }
560
561 Some(self.load_script(path))
562 }
563
564 fn load_script(&mut self, path: &str) -> Result<()> {
569 let content = std::fs::read_to_string(path).map_err(|e| {
570 DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
571 "failed to read script file {}: {}",
572 path,
573 e
574 )))
575 })?;
576
577 let mut attempted = 0usize;
578 let mut succeeded = 0usize;
579
580 for (idx, raw_line) in content.lines().enumerate() {
581 let line_number = idx + 1;
582 let script_line = raw_line.trim();
583
584 if script_line.is_empty() || script_line.starts_with('#') {
585 continue;
586 }
587
588 attempted += 1;
589
590 match self.execute_line(script_line) {
591 Ok(()) => succeeded += 1,
592 Err(e) => {
593 eprintln!(" :load {} — line {}:", path, line_number);
594 display_error(&e);
595 }
596 }
597 }
598
599 println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
600 Ok(())
601 }
602
603 fn has_secure_arg(
609 &self,
610 command_name: &str,
611 parsed_args: &std::collections::HashMap<String, String>,
612 ) -> bool {
613 let config = match &self.config {
614 Some(c) => c,
615 None => return false,
616 };
617
618 let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
619 Some(d) => d,
620 None => return false,
621 };
622
623 cmd_def
624 .arguments
625 .iter()
626 .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
627 }
628
629 fn get_history_path(app_name: &str) -> Option<PathBuf> {
637 dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
638 }
639
640 fn load_history(&mut self) {
642 if let Some(ref path) = self.history_path {
643 if let Some(parent) = path.parent() {
644 let _ = std::fs::create_dir_all(parent);
645 }
646 let _ = self.editor.load_history(path);
647 }
648 }
649
650 fn save_history(&mut self) {
652 if let Some(ref path) = self.history_path {
653 if let Err(e) = self.editor.save_history(path) {
654 eprintln!("Warning: Failed to save command history: {}", e);
655 }
656 }
657 }
658
659 pub fn run(mut self) -> Result<()> {
718 let mut continuation_buffer: Vec<String> = Vec::new();
719
720 loop {
721 let prompt = if continuation_buffer.is_empty() {
722 self.prompt.clone()
723 } else {
724 self.effective_prompt_multiline()
725 };
726
727 let readline = self.editor.readline(&prompt);
728
729 match readline {
730 Ok(raw_line) => {
731 let line = match Self::accumulate_line(&mut continuation_buffer, &raw_line) {
732 Some(line) => line,
733 None => continue,
736 };
737
738 if line.is_empty() {
739 continue;
740 }
741
742 if line == "exit" || line == "quit" {
743 println!("Goodbye!");
744 break;
745 }
746
747 match self.execute_line(&line) {
751 Ok(()) => {}
752 Err(e) => {
753 display_error(&e);
754 }
755 }
756 }
757
758 Err(ReadlineError::Interrupted) => {
759 println!("^C");
760 continuation_buffer.clear();
763 continue;
764 }
765
766 Err(ReadlineError::Eof) => {
767 println!("exit");
768 break;
769 }
770
771 Err(err) => {
772 eprintln!("Error reading input: {}", err);
773 break;
774 }
775 }
776 }
777
778 self.save_history();
779 Ok(())
780 }
781
782 fn accumulate_line(buffer: &mut Vec<String>, raw_line: &str) -> Option<String> {
802 let trimmed = raw_line.trim();
803
804 if let Some(fragment) = trimmed.strip_suffix('\\') {
805 buffer.push(fragment.trim_end().to_string());
806 return None;
807 }
808
809 if buffer.is_empty() {
810 return Some(trimmed.to_string());
811 }
812
813 buffer.push(trimmed.to_string());
814 Some(std::mem::take(buffer).join(" "))
815 }
816
817 fn execute_line(&mut self, line: &str) -> Result<()> {
826 if let Some(output) = self.try_handle_help(line) {
827 print!("{}", output);
828 return Ok(());
829 }
830
831 if let Some(result) = self.try_handle_load(line) {
832 return result;
833 }
834
835 let parser = ReplParser::new(&self.registry);
836 let parsed = parser.parse_line(line)?;
837
838 if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
841 let _ = self.editor.add_history_entry(line);
842 }
843
844 let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
857 if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
858 handler.execute(&mut *self.context, &parsed_args)?;
859 } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
860 futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
861 } else {
862 return Err(DynamicCliError::Execution(
863 ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
864 ));
865 }
866
867 Ok(())
868 }
869}
870
871impl Drop for ReplInterface {
872 fn drop(&mut self) {
873 self.save_history();
874 }
875}
876
877#[cfg(test)]
882mod tests {
883 use super::*;
884 use crate::config::schema::{
885 ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
886 };
887 use rustyline::history::History;
888 use std::collections::HashMap;
889
890 #[derive(Default)]
891 struct TestContext {
892 executed_commands: Vec<String>,
893 }
894
895 impl ExecutionContext for TestContext {
896 fn as_any(&self) -> &dyn std::any::Any {
897 self
898 }
899 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
900 self
901 }
902 }
903
904 struct TestHandler {
905 name: String,
906 }
907
908 impl crate::executor::CommandHandler for TestHandler {
909 fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
910 let ctx = crate::context::downcast_mut::<TestContext>(context)
911 .expect("Failed to downcast context");
912 ctx.executed_commands.push(self.name.clone());
913 Ok(())
914 }
915 }
916
917 fn create_test_registry() -> CommandRegistry {
918 let mut registry = CommandRegistry::new();
919 let cmd_def = CommandDefinition {
920 name: "test".to_string(),
921 aliases: vec!["t".to_string()],
922 description: "Test command".to_string(),
923 required: false,
924 arguments: vec![],
925 options: vec![],
926 implementation: "test_handler".to_string(),
927 continue_on_failure: false,
928 requires_success: false,
929 };
930 registry
931 .register_sync(
932 cmd_def,
933 Box::new(TestHandler {
934 name: "test".to_string(),
935 }),
936 )
937 .unwrap();
938 registry
939 }
940
941 fn make_help_config() -> CommandsConfig {
942 use crate::config::schema::{CommandsConfig, Metadata};
943 CommandsConfig {
944 metadata: Metadata {
945 version: "1.0.0".to_string(),
946 prompt: "testapp".to_string(),
947 prompt_suffix: " > ".to_string(),
948 },
949 commands: vec![CommandDefinition {
950 name: "hello".to_string(),
951 aliases: vec!["hi".to_string()],
952 description: "Say hello".to_string(),
953 required: false,
954 arguments: vec![],
955 options: vec![OptionDefinition {
956 name: "loud".to_string(),
957 short: Some("l".to_string()),
958 long: Some("loud".to_string()),
959 option_type: ArgumentType::Bool,
960 required: false,
961 default: Some("false".to_string()),
962 description: "Loud greeting".to_string(),
963 choices: vec![],
964 repeatable: false,
965 option_parameters: HashMap::new(),
966 }],
967 implementation: "hello_handler".to_string(),
968 continue_on_failure: false,
969 requires_success: false,
970 }],
971 global_options: vec![],
972 }
973 }
974
975 #[test]
978 fn test_repl_interface_creation() {
979 let registry = create_test_registry();
980 let context = Box::new(TestContext::default());
981 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
982 assert!(repl.is_ok());
983 }
984
985 #[test]
986 fn test_repl_interface_creation_with_config() {
987 let registry = create_test_registry();
988 let context = Box::new(TestContext::default());
989 let config = make_help_config();
990 let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
991 assert!(repl.is_ok());
992 }
993
994 #[test]
997 fn test_repl_execute_line() {
998 let registry = create_test_registry();
999 let context = Box::new(TestContext::default());
1000 let mut repl =
1001 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1002 let result = repl.execute_line("test");
1003 assert!(result.is_ok());
1004 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1005 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
1006 }
1007
1008 #[test]
1009 fn test_repl_execute_with_alias() {
1010 let registry = create_test_registry();
1011 let context = Box::new(TestContext::default());
1012 let mut repl =
1013 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1014 assert!(repl.execute_line("t").is_ok());
1015 }
1016
1017 #[test]
1018 fn test_repl_execute_unknown_command() {
1019 let registry = create_test_registry();
1020 let context = Box::new(TestContext::default());
1021 let mut repl =
1022 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1023 let result = repl.execute_line("unknown");
1024 assert!(result.is_err());
1025 match result.unwrap_err() {
1026 DynamicCliError::Parse(_) => {}
1027 other => panic!("Expected Parse error, got: {:?}", other),
1028 }
1029 }
1030
1031 #[test]
1032 fn test_repl_empty_line() {
1033 let registry = create_test_registry();
1034 let context = Box::new(TestContext::default());
1035 let mut repl =
1036 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1037 assert!(repl.execute_line("").is_err());
1038 }
1039
1040 #[test]
1041 fn test_repl_command_with_args() {
1042 let mut registry = CommandRegistry::new();
1043 let cmd_def = CommandDefinition {
1044 name: "greet".to_string(),
1045 aliases: vec![],
1046 description: "Greet someone".to_string(),
1047 required: false,
1048 arguments: vec![ArgumentDefinition {
1049 name: "name".to_string(),
1050 arg_type: ArgumentType::String,
1051 required: true,
1052 description: "Name".to_string(),
1053 validation: vec![],
1054 secure: false,
1055 }],
1056 options: vec![],
1057 implementation: "greet_handler".to_string(),
1058 continue_on_failure: false,
1059 requires_success: false,
1060 };
1061
1062 struct GreetHandler;
1063 impl crate::executor::CommandHandler for GreetHandler {
1064 fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
1065 assert_eq!(args.get_scalar("name"), Some("Alice"));
1066 Ok(())
1067 }
1068 }
1069
1070 registry
1071 .register_sync(cmd_def, Box::new(GreetHandler))
1072 .unwrap();
1073 let context = Box::new(TestContext::default());
1074 let mut repl =
1075 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1076 assert!(repl.execute_line("greet Alice").is_ok());
1077 }
1078
1079 #[test]
1082 fn test_repl_history_path() {
1083 let path = ReplInterface::get_history_path("myapp");
1084 if let Some(p) = path {
1085 let path_str = p.to_str().unwrap();
1086 assert!(path_str.contains("myapp"), "path should contain app name");
1087 assert!(
1088 path_str.ends_with("history"),
1089 "path should end with 'history', got: {}",
1090 path_str
1091 );
1092 }
1093 }
1094
1095 #[test]
1098 fn test_try_handle_help_without_formatter_returns_none() {
1099 let registry = create_test_registry();
1100 let context = Box::new(TestContext::default());
1101 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1102 assert!(repl.try_handle_help("--help").is_none());
1103 assert!(repl.try_handle_help("-h").is_none());
1104 }
1105
1106 #[test]
1107 fn test_try_handle_help_global() {
1108 use crate::help::DefaultHelpFormatter;
1109 colored::control::set_override(false);
1110 let registry = create_test_registry();
1111 let context = Box::new(TestContext::default());
1112 let config = make_help_config();
1113 let repl = ReplInterface::new(
1114 registry,
1115 context,
1116 "test".to_string(),
1117 Some(config),
1118 Some(Box::new(DefaultHelpFormatter::new())),
1119 )
1120 .unwrap();
1121 let out = repl.try_handle_help("--help");
1122 assert!(out.is_some());
1123 let out = out.unwrap();
1124 assert!(out.contains("testapp"));
1125 assert!(out.contains("hello"));
1126 }
1127
1128 #[test]
1129 fn test_try_handle_help_short_flag() {
1130 use crate::help::DefaultHelpFormatter;
1131 colored::control::set_override(false);
1132 let registry = create_test_registry();
1133 let context = Box::new(TestContext::default());
1134 let config = make_help_config();
1135 let repl = ReplInterface::new(
1136 registry,
1137 context,
1138 "test".to_string(),
1139 Some(config),
1140 Some(Box::new(DefaultHelpFormatter::new())),
1141 )
1142 .unwrap();
1143 let out = repl.try_handle_help("-h");
1144 assert!(out.is_some());
1145 assert!(out.unwrap().contains("testapp"));
1146 }
1147
1148 #[test]
1149 fn test_try_handle_help_with_command_prefix() {
1150 use crate::help::DefaultHelpFormatter;
1151 colored::control::set_override(false);
1152 let registry = create_test_registry();
1153 let context = Box::new(TestContext::default());
1154 let config = make_help_config();
1155 let repl = ReplInterface::new(
1156 registry,
1157 context,
1158 "test".to_string(),
1159 Some(config),
1160 Some(Box::new(DefaultHelpFormatter::new())),
1161 )
1162 .unwrap();
1163 let out = repl.try_handle_help("--help hello");
1164 assert!(out.is_some());
1165 assert!(out.unwrap().contains("hello"));
1166 let out2 = repl.try_handle_help("-h hello");
1167 assert!(out2.is_some());
1168 }
1169
1170 #[test]
1171 fn test_try_handle_help_command_suffix() {
1172 use crate::help::DefaultHelpFormatter;
1173 colored::control::set_override(false);
1174 let registry = create_test_registry();
1175 let context = Box::new(TestContext::default());
1176 let config = make_help_config();
1177 let repl = ReplInterface::new(
1178 registry,
1179 context,
1180 "test".to_string(),
1181 Some(config),
1182 Some(Box::new(DefaultHelpFormatter::new())),
1183 )
1184 .unwrap();
1185 let out = repl.try_handle_help("hello --help");
1186 assert!(out.is_some());
1187 assert!(out.unwrap().contains("hello"));
1188 let out2 = repl.try_handle_help("hello -h");
1189 assert!(out2.is_some());
1190 }
1191
1192 #[test]
1193 fn test_try_handle_help_alias() {
1194 use crate::help::DefaultHelpFormatter;
1195 colored::control::set_override(false);
1196 let registry = create_test_registry();
1197 let context = Box::new(TestContext::default());
1198 let config = make_help_config();
1199 let repl = ReplInterface::new(
1200 registry,
1201 context,
1202 "test".to_string(),
1203 Some(config),
1204 Some(Box::new(DefaultHelpFormatter::new())),
1205 )
1206 .unwrap();
1207 let out = repl.try_handle_help("--help hi");
1208 assert!(out.is_some());
1209 assert!(out.unwrap().contains("hello"));
1210 }
1211
1212 #[test]
1213 fn test_execute_line_help_intercepted() {
1214 use crate::help::DefaultHelpFormatter;
1215 colored::control::set_override(false);
1216 let registry = create_test_registry();
1217 let context = Box::new(TestContext::default());
1218 let config = make_help_config();
1219 let mut repl = ReplInterface::new(
1220 registry,
1221 context,
1222 "test".to_string(),
1223 Some(config),
1224 Some(Box::new(DefaultHelpFormatter::new())),
1225 )
1226 .unwrap();
1227 assert!(repl.execute_line("--help").is_ok());
1228 }
1229
1230 #[test]
1231 fn test_execute_line_normal_command_still_works_with_formatter() {
1232 use crate::help::DefaultHelpFormatter;
1233 let registry = create_test_registry();
1234 let context = Box::new(TestContext::default());
1235 let config = make_help_config();
1236 let mut repl = ReplInterface::new(
1237 registry,
1238 context,
1239 "test".to_string(),
1240 Some(config),
1241 Some(Box::new(DefaultHelpFormatter::new())),
1242 )
1243 .unwrap();
1244 assert!(repl.execute_line("test").is_ok());
1245 }
1246
1247 #[test]
1250 fn test_completer_commands_empty_input() {
1251 let registry = Arc::new(create_test_registry());
1252 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1253 let history = rustyline::history::DefaultHistory::new();
1254 let ctx = rustyline::Context::new(&history);
1255 let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1256 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1257 assert!(names.contains(&"test"));
1258 assert!(names.contains(&"t"));
1259 }
1260
1261 #[test]
1262 fn test_completer_commands_prefix_filter() {
1263 let registry = Arc::new(create_test_registry());
1264 let completer = DcliCompleter::new(Arc::clone(®istry), None);
1265 let history = rustyline::history::DefaultHistory::new();
1266 let ctx = rustyline::Context::new(&history);
1267 let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1268 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1269 assert!(names.contains(&"test"));
1270 assert!(!names.contains(&"t"));
1271 }
1272
1273 #[test]
1274 fn test_completer_flags_after_command() {
1275 let config = Arc::new(make_help_config());
1276 let mut registry = CommandRegistry::new();
1278 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1279 struct DummyHandler;
1280 impl crate::executor::CommandHandler for DummyHandler {
1281 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1282 Ok(())
1283 }
1284 }
1285 registry
1286 .register_sync(cmd_def, Box::new(DummyHandler))
1287 .unwrap();
1288 let registry = Arc::new(registry);
1289
1290 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1291 let history = rustyline::history::DefaultHistory::new();
1292 let ctx = rustyline::Context::new(&history);
1293
1294 let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1296 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1297 assert!(
1298 names.contains(&"--loud"),
1299 "expected --loud, got {:?}",
1300 names
1301 );
1302 assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1303 }
1304
1305 #[test]
1306 fn test_completer_flags_prefix_filter() {
1307 let config = Arc::new(make_help_config());
1308 let mut registry = CommandRegistry::new();
1309 let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1310 struct DummyHandler;
1311 impl crate::executor::CommandHandler for DummyHandler {
1312 fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1313 Ok(())
1314 }
1315 }
1316 registry
1317 .register_sync(cmd_def, Box::new(DummyHandler))
1318 .unwrap();
1319 let registry = Arc::new(registry);
1320
1321 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1322 let history = rustyline::history::DefaultHistory::new();
1323 let ctx = rustyline::Context::new(&history);
1324
1325 let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1327 let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1328 assert!(names.contains(&"--loud"));
1329 assert!(!names.contains(&"-l"));
1330 }
1331
1332 #[test]
1333 fn test_completer_no_flags_for_unknown_command() {
1334 let config = Arc::new(make_help_config());
1335 let registry = Arc::new(create_test_registry());
1336 let completer = DcliCompleter::new(Arc::clone(®istry), Some(Arc::clone(&config)));
1337 let history = rustyline::history::DefaultHistory::new();
1338 let ctx = rustyline::Context::new(&history);
1339 let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1341 assert!(candidates.is_empty());
1342 }
1343
1344 fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1348 use crate::config::schema::{CommandsConfig, Metadata};
1349
1350 let cmd_def = CommandDefinition {
1351 name: "login".to_string(),
1352 aliases: vec![],
1353 description: "Login command".to_string(),
1354 required: false,
1355 arguments: vec![
1356 ArgumentDefinition {
1357 name: "username".to_string(),
1358 arg_type: ArgumentType::String,
1359 required: true,
1360 description: "Username".to_string(),
1361 validation: vec![],
1362 secure: false,
1363 },
1364 ArgumentDefinition {
1365 name: "password".to_string(),
1366 arg_type: ArgumentType::String,
1367 required: true,
1368 description: "Password".to_string(),
1369 validation: vec![],
1370 secure: true,
1371 },
1372 ],
1373 options: vec![],
1374 implementation: "login_handler".to_string(),
1375 continue_on_failure: false,
1376 requires_success: false,
1377 };
1378
1379 struct LoginHandler;
1380 impl crate::executor::CommandHandler for LoginHandler {
1381 fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1382 Ok(())
1383 }
1384 }
1385
1386 let mut registry = CommandRegistry::new();
1387 registry
1388 .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1389 .unwrap();
1390
1391 let config = CommandsConfig {
1392 metadata: Metadata {
1393 version: "1.0.0".to_string(),
1394 prompt: "testapp".to_string(),
1395 prompt_suffix: " > ".to_string(),
1396 },
1397 commands: vec![cmd_def],
1398 global_options: vec![],
1399 };
1400
1401 (registry, config)
1402 }
1403
1404 #[test]
1405 fn test_has_secure_arg_returns_false_without_config() {
1406 let registry = create_test_registry();
1407 let context = Box::new(TestContext::default());
1408 let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1409
1410 let mut args = HashMap::new();
1411 args.insert("password".to_string(), "secret".to_string());
1412
1413 assert!(!repl.has_secure_arg("login", &args));
1414 }
1415
1416 #[test]
1417 fn test_has_secure_arg_returns_false_when_no_secure_field() {
1418 let registry = create_test_registry();
1419 let context = Box::new(TestContext::default());
1420 let config = make_help_config();
1421 let repl =
1422 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1423
1424 let mut args = HashMap::new();
1425 args.insert("loud".to_string(), "true".to_string());
1426
1427 assert!(!repl.has_secure_arg("hello", &args));
1428 }
1429
1430 #[test]
1431 fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1432 let (registry, config) = make_secure_registry_and_config();
1433 let context = Box::new(TestContext::default());
1434 let repl =
1435 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1436
1437 let mut args = HashMap::new();
1438 args.insert("username".to_string(), "alice".to_string());
1439 args.insert("password".to_string(), "secret".to_string());
1440
1441 assert!(repl.has_secure_arg("login", &args));
1442 }
1443
1444 #[test]
1445 fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1446 let (registry, config) = make_secure_registry_and_config();
1447 let context = Box::new(TestContext::default());
1448 let repl =
1449 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1450
1451 let mut args = HashMap::new();
1453 args.insert("username".to_string(), "alice".to_string());
1454
1455 assert!(!repl.has_secure_arg("login", &args));
1456 }
1457
1458 #[test]
1459 fn test_has_secure_arg_returns_false_for_unknown_command() {
1460 let (registry, config) = make_secure_registry_and_config();
1461 let context = Box::new(TestContext::default());
1462 let repl =
1463 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1464
1465 let mut args = HashMap::new();
1466 args.insert("password".to_string(), "secret".to_string());
1467
1468 assert!(!repl.has_secure_arg("nonexistent", &args));
1469 }
1470
1471 #[test]
1474 fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1475 let (registry, config) = make_secure_registry_and_config();
1476 let context = Box::new(TestContext::default());
1477 let mut repl =
1478 ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1479
1480 let result = repl.execute_line("login alice secret");
1481 assert!(result.is_ok());
1482
1483 let history = repl.editor.history();
1485 let in_history = (0..history.len()).any(|i| {
1486 history
1487 .get(i, rustyline::history::SearchDirection::Forward)
1488 .ok()
1489 .flatten()
1490 .map(|e| e.entry.as_ref() == "login alice secret")
1491 .unwrap_or(false)
1492 });
1493 assert!(
1494 !in_history,
1495 "secure command line must not be written to history"
1496 );
1497 }
1498
1499 #[test]
1500 fn test_execute_line_without_secure_arg_adds_to_history() {
1501 let registry = create_test_registry();
1502 let context = Box::new(TestContext::default());
1503 let mut repl =
1504 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1505
1506 let result = repl.execute_line("test");
1507 assert!(result.is_ok());
1508
1509 let history = repl.editor.history();
1511 let in_history = (0..history.len()).any(|i| {
1512 history
1513 .get(i, rustyline::history::SearchDirection::Forward)
1514 .ok()
1515 .flatten()
1516 .map(|e| e.entry.as_ref() == "test")
1517 .unwrap_or(false)
1518 });
1519 assert!(
1520 in_history,
1521 "non-secure command line must be written to history"
1522 );
1523 }
1524
1525 fn write_script(content: &str) -> tempfile::NamedTempFile {
1528 use std::io::Write;
1529 let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1530 file.write_all(content.as_bytes())
1531 .expect("failed to write temp script file");
1532 file
1533 }
1534
1535 #[test]
1536 fn test_load_executes_each_line_via_execute_line() {
1537 let registry = create_test_registry();
1538 let context = Box::new(TestContext::default());
1539 let mut repl =
1540 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1541
1542 let script = write_script("test\nt\n");
1543 let line = format!(":load {}", script.path().display());
1544
1545 assert!(repl.execute_line(&line).is_ok());
1546
1547 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1548 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1549 }
1550
1551 #[test]
1552 fn test_load_skips_blank_lines_and_comments() {
1553 let registry = create_test_registry();
1554 let context = Box::new(TestContext::default());
1555 let mut repl =
1556 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1557
1558 let script = write_script("# a comment\n\ntest\n \n# another\n");
1559 let line = format!(":load {}", script.path().display());
1560
1561 assert!(repl.execute_line(&line).is_ok());
1562
1563 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1564 assert_eq!(ctx.executed_commands, vec!["test"]);
1565 }
1566
1567 #[test]
1568 fn test_load_continues_past_a_failing_line() {
1569 let registry = create_test_registry();
1570 let context = Box::new(TestContext::default());
1571 let mut repl =
1572 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1573
1574 let script = write_script("test\nunknown_command\ntest\n");
1575 let line = format!(":load {}", script.path().display());
1576
1577 let result = repl.execute_line(&line);
1581 assert!(result.is_ok());
1582
1583 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1584 assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1585 }
1586
1587 #[test]
1588 fn test_load_missing_path_argument_is_an_error() {
1589 let registry = create_test_registry();
1590 let context = Box::new(TestContext::default());
1591 let mut repl =
1592 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1593
1594 let result = repl.execute_line(":load");
1595 assert!(result.is_err());
1596 }
1597
1598 #[test]
1599 fn test_load_missing_file_is_an_error() {
1600 let registry = create_test_registry();
1601 let context = Box::new(TestContext::default());
1602 let mut repl =
1603 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1604
1605 let result = repl.execute_line(":load /nonexistent/path/to/script.txt");
1606 assert!(result.is_err());
1607 }
1608
1609 #[test]
1610 fn test_load_line_itself_is_not_added_to_history() {
1611 let registry = create_test_registry();
1612 let context = Box::new(TestContext::default());
1613 let mut repl =
1614 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1615
1616 let script = write_script("test\n");
1617 let line = format!(":load {}", script.path().display());
1618 assert!(repl.execute_line(&line).is_ok());
1619
1620 let history = repl.editor.history();
1621 let load_in_history = (0..history.len()).any(|i| {
1622 history
1623 .get(i, rustyline::history::SearchDirection::Forward)
1624 .ok()
1625 .flatten()
1626 .map(|e| e.entry.starts_with(":load"))
1627 .unwrap_or(false)
1628 });
1629 assert!(
1630 !load_in_history,
1631 ":load line itself must not be written to history"
1632 );
1633 }
1634
1635 #[test]
1640 fn test_repl_line_is_never_chained_across_multiple_commands() {
1641 let mut registry = CommandRegistry::new();
1649 for name in ["first", "second"] {
1650 let cmd_def = CommandDefinition {
1651 name: name.to_string(),
1652 aliases: vec![],
1653 description: format!("Test command {}", name),
1654 required: false,
1655 arguments: vec![ArgumentDefinition {
1656 name: "value".to_string(),
1657 arg_type: ArgumentType::String,
1658 required: true,
1659 description: "Value".to_string(),
1660 validation: vec![],
1661 secure: false,
1662 }],
1663 options: vec![],
1664 implementation: format!("{}_handler", name),
1665 continue_on_failure: false,
1666 requires_success: false,
1667 };
1668 registry
1669 .register_sync(
1670 cmd_def,
1671 Box::new(TestHandler {
1672 name: name.to_string(),
1673 }),
1674 )
1675 .unwrap();
1676 }
1677
1678 let context = Box::new(TestContext::default());
1679 let mut repl =
1680 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1681
1682 let result = repl.execute_line("first 1 second");
1685
1686 assert!(result.is_err());
1687 match result.unwrap_err() {
1688 DynamicCliError::Parse(ParseError::TooManyArguments { command, .. }) => {
1689 assert_eq!(command, "first");
1690 }
1691 other => panic!("Expected TooManyArguments error, got: {:?}", other),
1692 }
1693
1694 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1697 assert!(ctx.executed_commands.is_empty());
1698 }
1699
1700 #[test]
1705 fn test_prompt_uses_default_suffix_without_config() {
1706 let registry = create_test_registry();
1707 let context = Box::new(TestContext::default());
1708 let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None).unwrap();
1709
1710 assert_eq!(repl.prompt, "myapp > ");
1713 }
1714
1715 #[test]
1716 fn test_prompt_honours_configured_suffix() {
1717 let registry = create_test_registry();
1720 let context = Box::new(TestContext::default());
1721 let mut config = make_help_config();
1722 config.metadata.prompt_suffix = " $ ".to_string();
1723
1724 let repl =
1725 ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
1726
1727 assert_eq!(repl.prompt, "rpn $ ");
1728 }
1729
1730 #[test]
1731 fn test_effective_prompt_multiline_default_derivation() {
1732 let registry = create_test_registry();
1735 let context = Box::new(TestContext::default());
1736 let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None).unwrap();
1737
1738 assert_eq!(repl.prompt, "rpn > ");
1739 assert_eq!(repl.effective_prompt_multiline(), "...> ");
1740 }
1741
1742 #[test]
1743 fn test_effective_prompt_multiline_uses_configured_suffix() {
1744 let registry = create_test_registry();
1749 let context = Box::new(TestContext::default());
1750 let mut config = make_help_config();
1751 config.metadata.prompt_suffix = " $ ".to_string();
1752
1753 let repl =
1754 ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
1755
1756 assert_eq!(repl.effective_prompt_multiline(), "...$ ");
1757 }
1758
1759 #[test]
1760 fn test_with_prompt_multiline_overrides_base_only() {
1761 let registry = create_test_registry();
1764 let context = Box::new(TestContext::default());
1765 let mut config = make_help_config();
1766 config.metadata.prompt_suffix = " $ ".to_string();
1767
1768 let repl = ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None)
1769 .unwrap()
1770 .with_prompt_multiline("_ _ _".to_string());
1771
1772 assert_eq!(repl.effective_prompt_multiline(), "_ _ _ $ ");
1773 }
1774
1775 #[test]
1780 fn test_accumulate_line_single_line_no_continuation() {
1781 let mut buffer: Vec<String> = Vec::new();
1782 let result = ReplInterface::accumulate_line(&mut buffer, "test");
1783
1784 assert_eq!(result, Some("test".to_string()));
1785 assert!(buffer.is_empty());
1786 }
1787
1788 #[test]
1789 fn test_accumulate_line_trailing_backslash_buffers_and_returns_none() {
1790 let mut buffer: Vec<String> = Vec::new();
1791 let result = ReplInterface::accumulate_line(&mut buffer, "push \\");
1792
1793 assert_eq!(result, None);
1794 assert_eq!(buffer, vec!["push".to_string()]);
1795 }
1796
1797 #[test]
1798 fn test_accumulate_line_multi_fragment_reconstruction() {
1799 let mut buffer: Vec<String> = Vec::new();
1802
1803 assert_eq!(
1804 ReplInterface::accumulate_line(&mut buffer, "config \\"),
1805 None
1806 );
1807 assert_eq!(
1808 ReplInterface::accumulate_line(&mut buffer, "--format model \\"),
1809 None
1810 );
1811 let result = ReplInterface::accumulate_line(&mut buffer, "source=model.yml");
1812
1813 assert_eq!(
1814 result,
1815 Some("config --format model source=model.yml".to_string())
1816 );
1817 assert!(buffer.is_empty());
1819 }
1820
1821 #[test]
1822 fn test_accumulate_line_strips_marker_and_surrounding_whitespace() {
1823 let mut buffer: Vec<String> = Vec::new();
1827 ReplInterface::accumulate_line(&mut buffer, "test \\");
1828 let result = ReplInterface::accumulate_line(&mut buffer, " arg");
1829
1830 assert_eq!(result, Some("test arg".to_string()));
1831 }
1832
1833 #[test]
1834 fn test_run_multiline_reconstruction_reaches_execute_line_once() {
1835 let registry = create_test_registry();
1840 let context = Box::new(TestContext::default());
1841 let mut repl =
1842 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1843
1844 let mut buffer: Vec<String> = Vec::new();
1845 assert_eq!(ReplInterface::accumulate_line(&mut buffer, "te \\"), None);
1846 let line = ReplInterface::accumulate_line(&mut buffer, "st").unwrap();
1847 assert_eq!(line, "te st");
1848
1849 let result = repl.execute_line(&line);
1852 assert!(result.is_err());
1853 }
1854
1855 #[test]
1856 fn test_load_line_ending_in_backslash_is_not_continued() {
1857 let registry = create_test_registry();
1862 let context = Box::new(TestContext::default());
1863 let mut repl =
1864 ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1865
1866 let script = write_script("test \\\ntest\n");
1867 let line = format!(":load {}", script.path().display());
1868
1869 assert!(repl.execute_line(&line).is_ok());
1873
1874 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1875 assert_eq!(ctx.executed_commands, vec!["test"]);
1877 }
1878}