dynamic_cli/interface/repl.rs
1//! REPL (Read-Eval-Print Loop) implementation
2//!
3//! This module provides an interactive REPL interface with:
4//! - Line editing (arrow keys, history navigation)
5//! - Command history (persistent across sessions)
6//! - Tab completion (future enhancement)
7//! - Colored prompts and error display
8//!
9//! # Example
10//!
11//! ```no_run
12//! use dynamic_cli::interface::ReplInterface;
13//! use dynamic_cli::prelude::*;
14//!
15//! # #[derive(Default)]
16//! # struct MyContext;
17//! # impl ExecutionContext for MyContext {
18//! # fn as_any(&self) -> &dyn std::any::Any { self }
19//! # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
20//! # }
21//! # fn main() -> dynamic_cli::Result<()> {
22//! let registry = CommandRegistry::new();
23//! let context = Box::new(MyContext::default());
24//!
25//! let repl = ReplInterface::new(registry, context, "myapp".to_string())?;
26//! repl.run()?;
27//! # Ok(())
28//! # }
29//! ```
30
31use crate::context::ExecutionContext;
32use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
33use crate::parser::ReplParser;
34use crate::registry::CommandRegistry;
35use rustyline::error::ReadlineError;
36use rustyline::DefaultEditor;
37use std::path::PathBuf;
38
39/// REPL (Read-Eval-Print Loop) interface
40///
41/// Provides an interactive command-line interface with:
42/// - Line editing and history
43/// - Persistent command history
44/// - Graceful error handling
45/// - Special commands (exit, quit, help)
46///
47/// # Architecture
48///
49/// ```text
50/// User input → rustyline → ReplParser → CommandExecutor → Handler
51/// ↓
52/// ExecutionContext
53/// ```
54///
55/// # Special Commands
56///
57/// The REPL recognizes these built-in commands:
58/// - `exit`, `quit` - Exit the REPL
59/// - `help` - Show available commands (if registered)
60///
61/// # History
62///
63/// Command history is stored in the user's config directory:
64/// - Linux: `~/.config/<app_name>/history.txt`
65/// - macOS: `~/Library/Application Support/<app_name>/history.txt`
66/// - Windows: `%APPDATA%\<app_name>\history.txt`
67pub struct ReplInterface {
68 /// Command registry
69 registry: CommandRegistry,
70
71 /// Execution context
72 context: Box<dyn ExecutionContext>,
73
74 /// Prompt string (e.g., "myapp > ")
75 prompt: String,
76
77 /// Rustyline editor for input
78 editor: DefaultEditor,
79
80 /// History file path
81 history_path: Option<PathBuf>,
82}
83
84impl ReplInterface {
85 /// Create a new REPL interface
86 ///
87 /// # Arguments
88 ///
89 /// * `registry` - Command registry with all registered commands
90 /// * `context` - Execution context
91 /// * `prompt` - Prompt prefix (e.g., "myapp" will display as "myapp > ")
92 ///
93 /// # Errors
94 ///
95 /// Returns an error if rustyline initialization fails (rare).
96 ///
97 /// # Example
98 ///
99 /// ```no_run
100 /// use dynamic_cli::interface::ReplInterface;
101 /// use dynamic_cli::prelude::*;
102 ///
103 /// # #[derive(Default)]
104 /// # struct MyContext;
105 /// # impl ExecutionContext for MyContext {
106 /// # fn as_any(&self) -> &dyn std::any::Any { self }
107 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
108 /// # }
109 /// # fn main() -> dynamic_cli::Result<()> {
110 /// let registry = CommandRegistry::new();
111 /// let context = Box::new(MyContext::default());
112 ///
113 /// let repl = ReplInterface::new(registry, context, "myapp".to_string())?;
114 /// # Ok(())
115 /// # }
116 /// ```
117 pub fn new(
118 registry: CommandRegistry,
119 context: Box<dyn ExecutionContext>,
120 prompt: String,
121 ) -> Result<Self> {
122 // Create rustyline editor
123 let editor = DefaultEditor::new().map_err(|e| {
124 ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
125 })?;
126
127 // Determine history file path
128 let history_path = Self::get_history_path(&prompt);
129
130 let mut repl = Self {
131 registry,
132 context,
133 prompt: format!("{} > ", prompt),
134 editor,
135 history_path,
136 };
137
138 // Load history if available
139 repl.load_history();
140
141 Ok(repl)
142 }
143
144 /// Get the history file path
145 ///
146 /// Uses the user's config directory to store command history.
147 fn get_history_path(app_name: &str) -> Option<PathBuf> {
148 dirs::config_dir().map(|config_dir| {
149 let app_dir = config_dir.join(app_name);
150 app_dir.join("history.txt")
151 })
152 }
153
154 /// Load command history from file
155 fn load_history(&mut self) {
156 if let Some(ref path) = self.history_path {
157 // Create parent directory if it doesn't exist
158 if let Some(parent) = path.parent() {
159 let _ = std::fs::create_dir_all(parent);
160 }
161
162 // Load history (ignore errors if file doesn't exist yet)
163 let _ = self.editor.load_history(path);
164 }
165 }
166
167 /// Save command history to file
168 fn save_history(&mut self) {
169 if let Some(ref path) = self.history_path {
170 if let Err(e) = self.editor.save_history(path) {
171 eprintln!("Warning: Failed to save command history: {}", e);
172 }
173 }
174 }
175
176 /// Run the REPL loop
177 ///
178 /// Enters an interactive loop that:
179 /// 1. Displays the prompt
180 /// 2. Reads user input
181 /// 3. Parses and executes the command
182 /// 4. Displays results or errors
183 /// 5. Repeats until user exits
184 ///
185 /// # Returns
186 ///
187 /// - `Ok(())` when user exits normally (via `exit` or `quit`)
188 /// - `Err(_)` on critical errors (I/O failures, etc.)
189 ///
190 /// # Example
191 ///
192 /// ```no_run
193 /// use dynamic_cli::interface::ReplInterface;
194 /// use dynamic_cli::prelude::*;
195 ///
196 /// # #[derive(Default)]
197 /// # struct MyContext;
198 /// # impl ExecutionContext for MyContext {
199 /// # fn as_any(&self) -> &dyn std::any::Any { self }
200 /// # fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
201 /// # }
202 /// # fn main() -> dynamic_cli::Result<()> {
203 /// let registry = CommandRegistry::new();
204 /// let context = Box::new(MyContext::default());
205 ///
206 /// let repl = ReplInterface::new(registry, context, "myapp".to_string())?;
207 /// repl.run()?; // Starts the REPL loop
208 /// # Ok(())
209 /// # }
210 /// ```
211 pub fn run(mut self) -> Result<()> {
212 loop {
213 // Read line from user
214 let readline = self.editor.readline(&self.prompt);
215
216 match readline {
217 Ok(line) => {
218 // Skip empty lines
219 let line = line.trim();
220 if line.is_empty() {
221 continue;
222 }
223
224 // Add to history
225 let _ = self.editor.add_history_entry(line);
226
227 // Check for built-in exit commands
228 if line == "exit" || line == "quit" {
229 println!("Goodbye!");
230 break;
231 }
232
233 // Parse and execute command
234 match self.execute_line(line) {
235 Ok(()) => {
236 // Command executed successfully
237 }
238 Err(e) => {
239 // Display error but continue REPL
240 display_error(&e);
241 }
242 }
243 }
244
245 Err(ReadlineError::Interrupted) => {
246 // Ctrl-C pressed
247 println!("^C");
248 continue;
249 }
250
251 Err(ReadlineError::Eof) => {
252 // Ctrl-D pressed
253 println!("exit");
254 break;
255 }
256
257 Err(err) => {
258 // Other readline errors (rare)
259 eprintln!("Error reading input: {}", err);
260 break;
261 }
262 }
263 }
264
265 // Save history before exiting
266 self.save_history();
267
268 Ok(())
269 }
270
271 /// Execute a single line of input
272 ///
273 /// Parses the line and executes the corresponding command.
274 fn execute_line(&mut self, line: &str) -> Result<()> {
275 // Create parser (borrows registry immutably)
276 let parser = ReplParser::new(&self.registry);
277
278 // Parse command (parser is dropped after this, releasing the borrow)
279 let parsed = parser.parse_line(line)?;
280
281 // Now we can borrow registry again to get the handler
282 let handler = self
283 .registry
284 .get_handler(&parsed.command_name)
285 .ok_or_else(|| {
286 DynamicCliError::Execution(ExecutionError::HandlerNotFound {
287 command: parsed.command_name.clone(),
288 implementation: "unknown".to_string(),
289 })
290 })?;
291
292 // Execute (handler references registry, context is borrowed mutably)
293 handler.execute(&mut *self.context, &parsed.arguments)?;
294
295 Ok(())
296 }
297}
298
299// Implement Drop to ensure history is saved even if run() is not called
300impl Drop for ReplInterface {
301 fn drop(&mut self) {
302 self.save_history();
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
310 use std::collections::HashMap;
311
312 // Test context
313 #[derive(Default)]
314 struct TestContext {
315 executed_commands: Vec<String>,
316 }
317
318 impl ExecutionContext for TestContext {
319 fn as_any(&self) -> &dyn std::any::Any {
320 self
321 }
322
323 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
324 self
325 }
326 }
327
328 // Test handler
329 struct TestHandler {
330 name: String,
331 }
332
333 impl crate::executor::CommandHandler for TestHandler {
334 fn execute(
335 &self,
336 context: &mut dyn ExecutionContext,
337 _args: &HashMap<String, String>,
338 ) -> Result<()> {
339 let ctx = crate::context::downcast_mut::<TestContext>(context)
340 .expect("Failed to downcast context");
341 ctx.executed_commands.push(self.name.clone());
342 Ok(())
343 }
344 }
345
346 fn create_test_registry() -> CommandRegistry {
347 let mut registry = CommandRegistry::new();
348
349 let cmd_def = CommandDefinition {
350 name: "test".to_string(),
351 aliases: vec!["t".to_string()],
352 description: "Test command".to_string(),
353 required: false,
354 arguments: vec![],
355 options: vec![],
356 implementation: "test_handler".to_string(),
357 };
358
359 let handler = Box::new(TestHandler {
360 name: "test".to_string(),
361 });
362
363 registry.register(cmd_def, handler).unwrap();
364
365 registry
366 }
367
368 #[test]
369 fn test_repl_interface_creation() {
370 let registry = create_test_registry();
371 let context = Box::new(TestContext::default());
372
373 let repl = ReplInterface::new(registry, context, "test".to_string());
374 assert!(repl.is_ok());
375 }
376
377 #[test]
378 fn test_repl_execute_line() {
379 let registry = create_test_registry();
380 let context = Box::new(TestContext::default());
381
382 let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
383
384 let result = repl.execute_line("test");
385 assert!(result.is_ok());
386
387 // Verify command was executed
388 let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
389 assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
390 }
391
392 #[test]
393 fn test_repl_execute_with_alias() {
394 let registry = create_test_registry();
395 let context = Box::new(TestContext::default());
396
397 let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
398
399 let result = repl.execute_line("t");
400 assert!(result.is_ok());
401 }
402
403 #[test]
404 fn test_repl_execute_unknown_command() {
405 let registry = create_test_registry();
406 let context = Box::new(TestContext::default());
407
408 let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
409
410 let result = repl.execute_line("unknown");
411 assert!(result.is_err());
412
413 match result.unwrap_err() {
414 DynamicCliError::Parse(_) => {}
415 other => panic!("Expected Parse error, got: {:?}", other),
416 }
417 }
418
419 #[test]
420 fn test_repl_history_path() {
421 let path = ReplInterface::get_history_path("myapp");
422
423 // Path should exist (unless we're in a very restricted environment)
424 if let Some(p) = path {
425 assert!(p.to_str().unwrap().contains("myapp"));
426 assert!(p.to_str().unwrap().contains("history.txt"));
427 }
428 }
429
430 #[test]
431 fn test_repl_command_with_args() {
432 let mut registry = CommandRegistry::new();
433
434 let cmd_def = CommandDefinition {
435 name: "greet".to_string(),
436 aliases: vec![],
437 description: "Greet someone".to_string(),
438 required: false,
439 arguments: vec![ArgumentDefinition {
440 name: "name".to_string(),
441 arg_type: ArgumentType::String,
442 required: true,
443 description: "Name".to_string(),
444 validation: vec![],
445 }],
446 options: vec![],
447 implementation: "greet_handler".to_string(),
448 };
449
450 struct GreetHandler;
451 impl crate::executor::CommandHandler for GreetHandler {
452 fn execute(
453 &self,
454 _context: &mut dyn ExecutionContext,
455 args: &HashMap<String, String>,
456 ) -> Result<()> {
457 assert_eq!(args.get("name"), Some(&"Alice".to_string()));
458 Ok(())
459 }
460 }
461
462 registry.register(cmd_def, Box::new(GreetHandler)).unwrap();
463
464 let context = Box::new(TestContext::default());
465 let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
466
467 let result = repl.execute_line("greet Alice");
468 assert!(result.is_ok());
469 }
470
471 #[test]
472 fn test_repl_empty_line() {
473 let registry = create_test_registry();
474 let context = Box::new(TestContext::default());
475
476 let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
477
478 // Empty line should return an error from parser
479 let result = repl.execute_line("");
480 assert!(result.is_err());
481 }
482}