Skip to main content

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::config::schema::CommandsConfig;
32use crate::context::ExecutionContext;
33use crate::error::{display_error, DynamicCliError, ExecutionError, Result};
34use crate::help::HelpFormatter;
35use crate::parser::ReplParser;
36use crate::registry::CommandRegistry;
37use rustyline::error::ReadlineError;
38use rustyline::DefaultEditor;
39use std::path::PathBuf;
40
41/// REPL (Read-Eval-Print Loop) interface
42///
43/// Provides an interactive command-line interface with:
44/// - Line editing and history
45/// - Persistent command history
46/// - Graceful error handling
47/// - Special commands (exit, quit, help)
48///
49/// # Architecture
50///
51/// ```text
52/// User input → rustyline → ReplParser → CommandExecutor → Handler
53///                                             ↓
54///                                       ExecutionContext
55/// ```
56///
57/// # Special Commands
58///
59/// The REPL recognizes these built-in commands:
60/// - `exit`, `quit` - Exit the REPL
61/// - `help` - Show available commands (if registered)
62///
63/// # History
64///
65/// Command history is stored in the user's config directory:
66/// - Linux: `~/.config/<app_name>/history.txt`
67/// - macOS: `~/Library/Application Support/<app_name>/history.txt`
68/// - Windows: `%APPDATA%\<app_name>\history.txt`
69pub struct ReplInterface {
70    /// Command registry
71    registry: CommandRegistry,
72
73    /// Execution context
74    context: Box<dyn ExecutionContext>,
75
76    /// Prompt string (e.g., "myapp > ")
77    prompt: String,
78
79    /// Rustyline editor for input
80    editor: DefaultEditor,
81
82    /// History file path
83    history_path: Option<PathBuf>,
84
85    /// Application configuration — used by the help formatter.
86    /// `None` when no formatter has been supplied.
87    config: Option<CommandsConfig>,
88
89    /// Help formatter — renders `--help` output.
90    /// `None` when the application was built without a formatter.
91    help_formatter: Option<Box<dyn HelpFormatter>>,
92}
93
94impl ReplInterface {
95    /// Create a new REPL interface
96    ///
97    /// # Arguments
98    ///
99    /// * `registry` - Command registry with all registered commands
100    /// * `context` - Execution context
101    /// * `prompt` - Prompt prefix (e.g., "myapp" will display as "myapp > ")
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if rustyline initialization fails (rare).
106    ///
107    /// # Example
108    ///
109    /// ```no_run
110    /// use dynamic_cli::interface::ReplInterface;
111    /// use dynamic_cli::prelude::*;
112    ///
113    /// # #[derive(Default)]
114    /// # struct MyContext;
115    /// # impl ExecutionContext for MyContext {
116    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
117    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
118    /// # }
119    /// # fn main() -> dynamic_cli::Result<()> {
120    /// let registry = CommandRegistry::new();
121    /// let context = Box::new(MyContext::default());
122    ///
123    /// let repl = ReplInterface::new(registry, context, "myapp".to_string())?;
124    /// # Ok(())
125    /// # }
126    /// ```
127    pub fn new(
128        registry: CommandRegistry,
129        context: Box<dyn ExecutionContext>,
130        prompt: String,
131    ) -> Result<Self> {
132        // Create rustyline editor
133        let editor = DefaultEditor::new().map_err(|e| {
134            ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
135        })?;
136
137        // Determine history file path
138        let history_path = Self::get_history_path(&prompt);
139
140        let mut repl = Self {
141            registry,
142            context,
143            prompt: format!("{} > ", prompt),
144            editor,
145            history_path,
146            config: None,
147            help_formatter: None,
148        };
149
150        // Load history if available
151        repl.load_history();
152
153        Ok(repl)
154    }
155
156    /// Attach a help formatter and configuration to this REPL.
157    ///
158    /// When supplied, the REPL will intercept `--help` and `--help <command>`
159    /// (and their `-h` short forms, as well as `<command> --help`) before
160    /// dispatch and print formatted help instead of executing a command.
161    ///
162    /// This method is called automatically by [`CliBuilder`] when a formatter
163    /// has been registered. It can also be called directly when constructing
164    /// a `ReplInterface` manually.
165    ///
166    /// # Arguments
167    ///
168    /// * `config`    - The loaded application configuration (commands, metadata)
169    /// * `formatter` - A boxed [`HelpFormatter`] implementation
170    ///
171    /// # Example
172    ///
173    /// ```no_run
174    /// use dynamic_cli::interface::ReplInterface;
175    /// use dynamic_cli::help::DefaultHelpFormatter;
176    /// use dynamic_cli::prelude::*;
177    ///
178    /// # #[derive(Default)]
179    /// # struct MyContext;
180    /// # impl ExecutionContext for MyContext {
181    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
182    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
183    /// # }
184    /// # fn main() -> dynamic_cli::Result<()> {
185    /// # let config = dynamic_cli::config::loader::load_config("commands.yaml")?;
186    /// let registry = CommandRegistry::new();
187    /// let context = Box::new(MyContext::default());
188    ///
189    /// let repl = ReplInterface::new(registry, context, "myapp".to_string())?
190    ///     .with_help(config, Box::new(DefaultHelpFormatter::new()));
191    /// # Ok(())
192    /// # }
193    /// ```
194    pub fn with_help(mut self, config: CommandsConfig, formatter: Box<dyn HelpFormatter>) -> Self {
195        self.config = Some(config);
196        self.help_formatter = Some(formatter);
197        self
198    }
199
200    /// Try to handle a `--help` / `-h` request.
201    ///
202    /// Returns `Some(output)` when the line is a help request and a formatter
203    /// is available, `None` otherwise (normal command processing continues).
204    ///
205    /// Recognized patterns (case-sensitive):
206    ///
207    /// | Input              | Output                    |
208    /// |--------------------|---------------------------|
209    /// | `--help`           | Application-level help    |
210    /// | `-h`               | Application-level help    |
211    /// | `--help <command>` | Per-command help          |
212    /// | `-h <command>`     | Per-command help          |
213    /// | `<command> --help` | Per-command help          |
214    /// | `<command> -h`     | Per-command help          |
215    fn try_handle_help(&self, line: &str) -> Option<String> {
216        let (config, formatter) = match (&self.config, &self.help_formatter) {
217            (Some(c), Some(f)) => (c, f.as_ref()),
218            _ => return None,
219        };
220
221        let trimmed = line.trim();
222
223        // "--help" or "-h" alone → application-level help
224        if trimmed == "--help" || trimmed == "-h" {
225            return Some(formatter.format_app(config));
226        }
227
228        // "--help <command>" or "-h <command>" → per-command help
229        if let Some(rest) = trimmed
230            .strip_prefix("--help ")
231            .or_else(|| trimmed.strip_prefix("-h "))
232        {
233            let cmd = rest.trim();
234            if !cmd.is_empty() {
235                return Some(formatter.format_command(config, cmd));
236            }
237        }
238
239        // "<command> --help" or "<command> -h" → per-command help
240        let parts: Vec<&str> = trimmed.split_whitespace().collect();
241        if parts.len() >= 2 {
242            let last = *parts.last().unwrap();
243            if last == "--help" || last == "-h" {
244                return Some(formatter.format_command(config, parts[0]));
245            }
246        }
247
248        None
249    }
250
251    /// Get the history file path
252    ///
253    /// Uses the user's config directory to store command history.
254    fn get_history_path(app_name: &str) -> Option<PathBuf> {
255        dirs::config_dir().map(|config_dir| {
256            let app_dir = config_dir.join(app_name);
257            app_dir.join("history.txt")
258        })
259    }
260
261    /// Load command history from file
262    fn load_history(&mut self) {
263        if let Some(ref path) = self.history_path {
264            // Create parent directory if it doesn't exist
265            if let Some(parent) = path.parent() {
266                let _ = std::fs::create_dir_all(parent);
267            }
268
269            // Load history (ignore errors if file doesn't exist yet)
270            let _ = self.editor.load_history(path);
271        }
272    }
273
274    /// Save command history to file
275    fn save_history(&mut self) {
276        if let Some(ref path) = self.history_path {
277            if let Err(e) = self.editor.save_history(path) {
278                eprintln!("Warning: Failed to save command history: {}", e);
279            }
280        }
281    }
282
283    /// Run the REPL loop
284    ///
285    /// Enters an interactive loop that:
286    /// 1. Displays the prompt
287    /// 2. Reads user input
288    /// 3. Parses and executes the command
289    /// 4. Displays results or errors
290    /// 5. Repeats until user exits
291    ///
292    /// # Returns
293    ///
294    /// - `Ok(())` when user exits normally (via `exit` or `quit`)
295    /// - `Err(_)` on critical errors (I/O failures, etc.)
296    ///
297    /// # Example
298    ///
299    /// ```no_run
300    /// use dynamic_cli::interface::ReplInterface;
301    /// use dynamic_cli::prelude::*;
302    ///
303    /// # #[derive(Default)]
304    /// # struct MyContext;
305    /// # impl ExecutionContext for MyContext {
306    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
307    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
308    /// # }
309    /// # fn main() -> dynamic_cli::Result<()> {
310    /// let registry = CommandRegistry::new();
311    /// let context = Box::new(MyContext::default());
312    ///
313    /// let repl = ReplInterface::new(registry, context, "myapp".to_string())?;
314    /// repl.run()?; // Starts the REPL loop
315    /// # Ok(())
316    /// # }
317    /// ```
318    pub fn run(mut self) -> Result<()> {
319        loop {
320            // Read line from user
321            let readline = self.editor.readline(&self.prompt);
322
323            match readline {
324                Ok(line) => {
325                    // Skip empty lines
326                    let line = line.trim();
327                    if line.is_empty() {
328                        continue;
329                    }
330
331                    // Add to history
332                    let _ = self.editor.add_history_entry(line);
333
334                    // Check for built-in exit commands
335                    if line == "exit" || line == "quit" {
336                        println!("Goodbye!");
337                        break;
338                    }
339
340                    // Parse and execute command
341                    match self.execute_line(line) {
342                        Ok(()) => {
343                            // Command executed successfully
344                        }
345                        Err(e) => {
346                            // Display error but continue REPL
347                            display_error(&e);
348                        }
349                    }
350                }
351
352                Err(ReadlineError::Interrupted) => {
353                    // Ctrl-C pressed
354                    println!("^C");
355                    continue;
356                }
357
358                Err(ReadlineError::Eof) => {
359                    // Ctrl-D pressed
360                    println!("exit");
361                    break;
362                }
363
364                Err(err) => {
365                    // Other readline errors (rare)
366                    eprintln!("Error reading input: {}", err);
367                    break;
368                }
369            }
370        }
371
372        // Save history before exiting
373        self.save_history();
374
375        Ok(())
376    }
377
378    /// Execute a single line of input
379    ///
380    /// Parses the line and executes the corresponding command.
381    /// `--help` and `-h` requests are intercepted before dispatch
382    /// and handled by the configured [`HelpFormatter`] if one is present.
383    fn execute_line(&mut self, line: &str) -> Result<()> {
384        // Intercept --help / -h before the parser so the registry
385        // is never consulted for help requests.
386        if let Some(output) = self.try_handle_help(line) {
387            print!("{}", output);
388            return Ok(());
389        }
390
391        // Create parser (borrows registry immutably)
392        let parser = ReplParser::new(&self.registry);
393
394        // Parse command (parser is dropped after this, releasing the borrow)
395        let parsed = parser.parse_line(line)?;
396
397        // Now we can borrow registry again to get the handler
398        let handler = self
399            .registry
400            .get_handler(&parsed.command_name)
401            .ok_or_else(|| {
402                DynamicCliError::Execution(ExecutionError::handler_not_found(
403                    &parsed.command_name,
404                    "unknown",
405                ))
406            })?;
407
408        // Execute (handler references registry, context is borrowed mutably)
409        handler.execute(&mut *self.context, &parsed.arguments)?;
410
411        Ok(())
412    }
413}
414
415// Implement Drop to ensure history is saved even if run() is not called
416impl Drop for ReplInterface {
417    fn drop(&mut self) {
418        self.save_history();
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
426    use std::collections::HashMap;
427
428    // Test context
429    #[derive(Default)]
430    struct TestContext {
431        executed_commands: Vec<String>,
432    }
433
434    impl ExecutionContext for TestContext {
435        fn as_any(&self) -> &dyn std::any::Any {
436            self
437        }
438
439        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
440            self
441        }
442    }
443
444    // Test handler
445    struct TestHandler {
446        name: String,
447    }
448
449    impl crate::executor::CommandHandler for TestHandler {
450        fn execute(
451            &self,
452            context: &mut dyn ExecutionContext,
453            _args: &HashMap<String, String>,
454        ) -> Result<()> {
455            let ctx = crate::context::downcast_mut::<TestContext>(context)
456                .expect("Failed to downcast context");
457            ctx.executed_commands.push(self.name.clone());
458            Ok(())
459        }
460    }
461
462    fn create_test_registry() -> CommandRegistry {
463        let mut registry = CommandRegistry::new();
464
465        let cmd_def = CommandDefinition {
466            name: "test".to_string(),
467            aliases: vec!["t".to_string()],
468            description: "Test command".to_string(),
469            required: false,
470            arguments: vec![],
471            options: vec![],
472            implementation: "test_handler".to_string(),
473        };
474
475        let handler = Box::new(TestHandler {
476            name: "test".to_string(),
477        });
478
479        registry.register(cmd_def, handler).unwrap();
480
481        registry
482    }
483
484    #[test]
485    fn test_repl_interface_creation() {
486        let registry = create_test_registry();
487        let context = Box::new(TestContext::default());
488
489        let repl = ReplInterface::new(registry, context, "test".to_string());
490        assert!(repl.is_ok());
491    }
492
493    #[test]
494    fn test_repl_execute_line() {
495        let registry = create_test_registry();
496        let context = Box::new(TestContext::default());
497
498        let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
499
500        let result = repl.execute_line("test");
501        assert!(result.is_ok());
502
503        // Verify command was executed
504        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
505        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
506    }
507
508    #[test]
509    fn test_repl_execute_with_alias() {
510        let registry = create_test_registry();
511        let context = Box::new(TestContext::default());
512
513        let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
514
515        let result = repl.execute_line("t");
516        assert!(result.is_ok());
517    }
518
519    #[test]
520    fn test_repl_execute_unknown_command() {
521        let registry = create_test_registry();
522        let context = Box::new(TestContext::default());
523
524        let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
525
526        let result = repl.execute_line("unknown");
527        assert!(result.is_err());
528
529        match result.unwrap_err() {
530            DynamicCliError::Parse(_) => {}
531            other => panic!("Expected Parse error, got: {:?}", other),
532        }
533    }
534
535    #[test]
536    fn test_repl_history_path() {
537        let path = ReplInterface::get_history_path("myapp");
538
539        // Path should exist (unless we're in a very restricted environment)
540        if let Some(p) = path {
541            assert!(p.to_str().unwrap().contains("myapp"));
542            assert!(p.to_str().unwrap().contains("history.txt"));
543        }
544    }
545
546    #[test]
547    fn test_repl_command_with_args() {
548        let mut registry = CommandRegistry::new();
549
550        let cmd_def = CommandDefinition {
551            name: "greet".to_string(),
552            aliases: vec![],
553            description: "Greet someone".to_string(),
554            required: false,
555            arguments: vec![ArgumentDefinition {
556                name: "name".to_string(),
557                arg_type: ArgumentType::String,
558                required: true,
559                description: "Name".to_string(),
560                validation: vec![],
561            }],
562            options: vec![],
563            implementation: "greet_handler".to_string(),
564        };
565
566        struct GreetHandler;
567        impl crate::executor::CommandHandler for GreetHandler {
568            fn execute(
569                &self,
570                _context: &mut dyn ExecutionContext,
571                args: &HashMap<String, String>,
572            ) -> Result<()> {
573                assert_eq!(args.get("name"), Some(&"Alice".to_string()));
574                Ok(())
575            }
576        }
577
578        registry.register(cmd_def, Box::new(GreetHandler)).unwrap();
579
580        let context = Box::new(TestContext::default());
581        let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
582
583        let result = repl.execute_line("greet Alice");
584        assert!(result.is_ok());
585    }
586
587    #[test]
588    fn test_repl_empty_line() {
589        let registry = create_test_registry();
590        let context = Box::new(TestContext::default());
591
592        let mut repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
593
594        // Empty line should return an error from parser
595        let result = repl.execute_line("");
596        assert!(result.is_err());
597    }
598
599    // -------------------------------------------------------------------------
600    // with_help / try_handle_help
601    // -------------------------------------------------------------------------
602
603    /// Minimal config for help tests.
604    fn make_help_config() -> crate::config::schema::CommandsConfig {
605        use crate::config::schema::{CommandDefinition, CommandsConfig, Metadata};
606        CommandsConfig {
607            metadata: Metadata {
608                version: "1.0.0".to_string(),
609                prompt: "testapp".to_string(),
610                prompt_suffix: " > ".to_string(),
611            },
612            commands: vec![CommandDefinition {
613                name: "hello".to_string(),
614                aliases: vec!["hi".to_string()],
615                description: "Say hello".to_string(),
616                required: false,
617                arguments: vec![],
618                options: vec![],
619                implementation: "hello_handler".to_string(),
620            }],
621            global_options: vec![],
622        }
623    }
624
625    #[test]
626    fn test_try_handle_help_without_formatter_returns_none() {
627        // No formatter attached → try_handle_help must be a no-op.
628        let registry = create_test_registry();
629        let context = Box::new(TestContext::default());
630        let repl = ReplInterface::new(registry, context, "test".to_string()).unwrap();
631
632        assert!(repl.try_handle_help("--help").is_none());
633        assert!(repl.try_handle_help("-h").is_none());
634    }
635
636    #[test]
637    fn test_try_handle_help_global() {
638        use crate::help::DefaultHelpFormatter;
639        colored::control::set_override(false);
640
641        let registry = create_test_registry();
642        let context = Box::new(TestContext::default());
643        let config = make_help_config();
644
645        let repl = ReplInterface::new(registry, context, "test".to_string())
646            .unwrap()
647            .with_help(config, Box::new(DefaultHelpFormatter::new()));
648
649        let out = repl.try_handle_help("--help");
650        assert!(out.is_some());
651        let out = out.unwrap();
652        assert!(out.contains("testapp"), "should contain app prompt");
653        assert!(out.contains("hello"), "should list commands");
654    }
655
656    #[test]
657    fn test_try_handle_help_short_flag() {
658        use crate::help::DefaultHelpFormatter;
659        colored::control::set_override(false);
660
661        let registry = create_test_registry();
662        let context = Box::new(TestContext::default());
663        let config = make_help_config();
664
665        let repl = ReplInterface::new(registry, context, "test".to_string())
666            .unwrap()
667            .with_help(config, Box::new(DefaultHelpFormatter::new()));
668
669        // -h alone → same as --help
670        let out = repl.try_handle_help("-h");
671        assert!(out.is_some());
672        assert!(out.unwrap().contains("testapp"));
673    }
674
675    #[test]
676    fn test_try_handle_help_with_command_prefix() {
677        use crate::help::DefaultHelpFormatter;
678        colored::control::set_override(false);
679
680        let registry = create_test_registry();
681        let context = Box::new(TestContext::default());
682        let config = make_help_config();
683
684        let repl = ReplInterface::new(registry, context, "test".to_string())
685            .unwrap()
686            .with_help(config, Box::new(DefaultHelpFormatter::new()));
687
688        // "--help hello" → per-command help
689        let out = repl.try_handle_help("--help hello");
690        assert!(out.is_some());
691        assert!(out.unwrap().contains("hello"));
692
693        // "-h hello" → per-command help
694        let out2 = repl.try_handle_help("-h hello");
695        assert!(out2.is_some());
696    }
697
698    #[test]
699    fn test_try_handle_help_command_suffix() {
700        use crate::help::DefaultHelpFormatter;
701        colored::control::set_override(false);
702
703        let registry = create_test_registry();
704        let context = Box::new(TestContext::default());
705        let config = make_help_config();
706
707        let repl = ReplInterface::new(registry, context, "test".to_string())
708            .unwrap()
709            .with_help(config, Box::new(DefaultHelpFormatter::new()));
710
711        // "hello --help" → per-command help
712        let out = repl.try_handle_help("hello --help");
713        assert!(out.is_some());
714        assert!(out.unwrap().contains("hello"));
715
716        // "hello -h" → per-command help
717        let out2 = repl.try_handle_help("hello -h");
718        assert!(out2.is_some());
719    }
720
721    #[test]
722    fn test_try_handle_help_alias() {
723        use crate::help::DefaultHelpFormatter;
724        colored::control::set_override(false);
725
726        let registry = create_test_registry();
727        let context = Box::new(TestContext::default());
728        let config = make_help_config();
729
730        let repl = ReplInterface::new(registry, context, "test".to_string())
731            .unwrap()
732            .with_help(config, Box::new(DefaultHelpFormatter::new()));
733
734        // "hi" is an alias for "hello"
735        let out = repl.try_handle_help("--help hi");
736        assert!(out.is_some());
737        // The formatter resolves aliases, output should mention hello
738        assert!(out.unwrap().contains("hello"));
739    }
740
741    #[test]
742    fn test_execute_line_help_intercepted() {
743        use crate::help::DefaultHelpFormatter;
744        colored::control::set_override(false);
745
746        let registry = create_test_registry();
747        let context = Box::new(TestContext::default());
748        let config = make_help_config();
749
750        let mut repl = ReplInterface::new(registry, context, "test".to_string())
751            .unwrap()
752            .with_help(config, Box::new(DefaultHelpFormatter::new()));
753
754        // "--help" must succeed (print help, not dispatch)
755        let result = repl.execute_line("--help");
756        assert!(result.is_ok(), "help request must not return an error");
757    }
758
759    #[test]
760    fn test_execute_line_normal_command_still_works_with_formatter() {
761        use crate::help::DefaultHelpFormatter;
762
763        let registry = create_test_registry();
764        let context = Box::new(TestContext::default());
765        let config = make_help_config();
766
767        let mut repl = ReplInterface::new(registry, context, "test".to_string())
768            .unwrap()
769            .with_help(config, Box::new(DefaultHelpFormatter::new()));
770
771        // Normal commands must still execute normally
772        let result = repl.execute_line("test");
773        assert!(result.is_ok());
774    }
775}