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//! - Per-application command history (persistent across sessions)
6//! - Tab completion at three levels: commands, sub-commands, argument flags
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(), None, None)?;
26//! repl.run()?;
27//! # Ok(())
28//! # }
29//! ```
30
31use 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, Result};
44use crate::help::HelpFormatter;
45use crate::parser::ReplParser;
46use crate::registry::CommandRegistry;
47
48// ============================================================================
49// DcliCompleter
50// ============================================================================
51
52/// Tab-completion engine for the REPL.
53///
54/// Completes at three depth levels driven by the YAML configuration:
55///
56/// | Input                    | Candidates                              |
57/// |--------------------------|------------------------------------------|
58/// | `<Tab>`                  | all command names + aliases              |
59/// | `he<Tab>`                | command names/aliases starting with `he` |
60/// | `hello <Tab>`            | long and short option flags of `hello`   |
61/// | `hello --<Tab>`          | long flags of `hello`                    |
62/// | `hello -<Tab>`           | short flags of `hello`                   |
63///
64/// Positional argument values are not completed (open-ended strings).
65///
66/// The completer holds `Arc` references so it shares the same data as
67/// `ReplInterface` without duplication or unsafe aliasing.
68struct DcliCompleter {
69    /// Shared registry — single source of truth for command names and aliases.
70    registry: Arc<CommandRegistry>,
71
72    /// Shared configuration — source of truth for option flags.
73    /// `None` when the REPL was constructed without a config.
74    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    /// Collect all flag completions for a given canonical command name.
83    ///
84    /// Returns both long forms (`--flag`) and short forms (`-f`) for every
85    /// option defined on the command.
86    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        // Work only on the portion of the line up to the cursor.
120        let line = &line[..pos];
121        let tokens: Vec<&str> = line.split_whitespace().collect();
122
123        // ── Level 1: no token yet, or first token still being typed ──────────
124        // Complete command names and aliases.
125        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        // ── Level 2: first token is a complete command, completing flags ──────
153        // Resolve the command name (handles aliases).
154        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        // The word being completed (may be empty if cursor follows a space).
161        let current_word = if line.ends_with(' ') {
162            ""
163        } else {
164            tokens.last().copied().unwrap_or("")
165        };
166
167        // Only offer flag completions when the current word looks like a flag
168        // or when the user pressed Tab on an empty position after the command.
169        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
191// ============================================================================
192// DcliHelper — rustyline Helper glue
193// ============================================================================
194
195/// Rustyline `Helper` implementation that wires `DcliCompleter` into the
196/// editor. The remaining traits (`Hinter`, `Highlighter`, `Validator`) use
197/// their no-op default implementations.
198struct 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
225// No-op implementations required by the Helper supertrait bound.
226impl Hinter for DcliHelper {
227    type Hint = String;
228}
229
230impl Highlighter for DcliHelper {}
231
232impl Validator for DcliHelper {}
233
234// ============================================================================
235// ReplInterface
236// ============================================================================
237
238/// REPL (Read-Eval-Print Loop) interface
239///
240/// Provides an interactive command-line interface with:
241/// - Line editing and history
242/// - Per-application persistent command history
243/// - Tab completion (commands, aliases, option flags)
244/// - Graceful error handling
245/// - Special commands (exit, quit, --help)
246///
247/// # Architecture
248///
249/// ```text
250/// User input → rustyline (DcliHelper) → ReplParser → CommandExecutor → Handler
251///                    ↓                                      ↓
252///             Tab completion                         ExecutionContext
253///          (commands + flags)
254/// ```
255///
256/// # Special Commands
257///
258/// The REPL recognizes these built-in commands:
259/// - `exit`, `quit` — Exit the REPL
260/// - `--help`, `-h` — Show application-level help (if a formatter is attached)
261/// - `<cmd> --help`, `--help <cmd>` — Show per-command help
262///
263/// # History
264///
265/// Command history is stored per application under the XDG data directory:
266/// - Linux/macOS: `~/.local/share/<app_name>/history`
267/// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
268///
269/// Lines containing a `secure: true` argument are never written to history.
270/// Lines that fail to parse are discarded silently.
271pub struct ReplInterface {
272    /// Shared command registry — single source of truth for names, aliases,
273    /// definitions, and handlers.
274    registry: Arc<CommandRegistry>,
275
276    /// Execution context passed to every command handler.
277    context: Box<dyn ExecutionContext>,
278
279    /// Prompt string (e.g., "myapp > ").
280    prompt: String,
281
282    /// Rustyline editor with tab-completion support.
283    editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
284
285    /// History file path.
286    history_path: Option<PathBuf>,
287
288    /// Application configuration — shared with the completer and used by the
289    /// help formatter. `None` when no config was supplied at construction.
290    config: Option<Arc<CommandsConfig>>,
291
292    /// Help formatter — renders `--help` output.
293    /// `None` when the application was built without a formatter.
294    help_formatter: Option<Box<dyn HelpFormatter>>,
295}
296
297impl ReplInterface {
298    /// Create a new REPL interface.
299    ///
300    /// All configuration is supplied at construction time so that the
301    /// tab-completion engine and the help formatter share the same data
302    /// without duplication.
303    ///
304    /// # Arguments
305    ///
306    /// * `registry`       — Command registry with all registered commands.
307    /// * `context`        — Execution context passed to handlers.
308    /// * `prompt`         — Prompt prefix (e.g., `"myapp"` displays as `"myapp > "`).
309    /// * `config`         — Application configuration for completion and help.
310    ///   Pass `None` to disable both features.
311    /// * `help_formatter` — Help formatter implementation.
312    ///   Pass `None` to use [`DefaultHelpFormatter`] lazily,
313    ///   or supply a custom implementation.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if rustyline initialisation fails (rare).
318    ///
319    /// # Example
320    ///
321    /// ```no_run
322    /// use dynamic_cli::interface::ReplInterface;
323    /// use dynamic_cli::prelude::*;
324    ///
325    /// # #[derive(Default)]
326    /// # struct MyContext;
327    /// # impl ExecutionContext for MyContext {
328    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
329    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
330    /// # }
331    /// # fn main() -> dynamic_cli::Result<()> {
332    /// let registry = CommandRegistry::new();
333    /// let context = Box::new(MyContext::default());
334    ///
335    /// // Without completion or help:
336    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
337    /// # Ok(())
338    /// # }
339    /// ```
340    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        // Wrap registry in Arc — shared with the completer.
348        let registry = Arc::new(registry);
349
350        // Wrap config in Arc if present — shared with the completer.
351        let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
352
353        // Build the rustyline editor with Tab completion enabled.
354        let rl_config = Config::builder()
355            .completion_type(CompletionType::List)
356            .build();
357
358        let helper = DcliHelper::new(Arc::clone(&registry), 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        // Determine history file path using the prompt as the app name.
366        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    /// Try to handle a `--help` / `-h` request.
384    ///
385    /// Returns `Some(output)` when the line is a help request and a formatter
386    /// is available, `None` otherwise (normal command processing continues).
387    ///
388    /// Recognized patterns (case-sensitive):
389    ///
390    /// | Input              | Output                    |
391    /// |--------------------|---------------------------|
392    /// | `--help`           | Application-level help    |
393    /// | `-h`               | Application-level help    |
394    /// | `--help <command>` | Per-command help          |
395    /// | `-h <command>`     | Per-command help          |
396    /// | `<command> --help` | Per-command help          |
397    /// | `<command> -h`     | Per-command help          |
398    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    /// Check whether a parsed command involves at least one secure argument.
430    ///
431    /// Looks up the command definition in `self.config` (if available) and
432    /// returns `true` when any argument name present in `parsed_args` is
433    /// marked `secure: true` in the YAML schema.
434    fn has_secure_arg(
435        &self,
436        command_name: &str,
437        parsed_args: &std::collections::HashMap<String, String>,
438    ) -> bool {
439        let config = match &self.config {
440            Some(c) => c,
441            None => return false,
442        };
443
444        let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
445            Some(d) => d,
446            None => return false,
447        };
448
449        cmd_def
450            .arguments
451            .iter()
452            .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
453    }
454
455    /// Get the history file path for this application.
456    ///
457    /// Each application gets its own isolated history file under the
458    /// XDG data directory:
459    ///
460    /// - Linux/macOS: `~/.local/share/<app_name>/history`
461    /// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
462    fn get_history_path(app_name: &str) -> Option<PathBuf> {
463        dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
464    }
465
466    /// Load command history from file.
467    fn load_history(&mut self) {
468        if let Some(ref path) = self.history_path {
469            if let Some(parent) = path.parent() {
470                let _ = std::fs::create_dir_all(parent);
471            }
472            let _ = self.editor.load_history(path);
473        }
474    }
475
476    /// Save command history to file.
477    fn save_history(&mut self) {
478        if let Some(ref path) = self.history_path {
479            if let Err(e) = self.editor.save_history(path) {
480                eprintln!("Warning: Failed to save command history: {}", e);
481            }
482        }
483    }
484
485    /// Run the REPL loop.
486    ///
487    /// Enters an interactive loop that:
488    /// 1. Displays the prompt
489    /// 2. Reads user input (with tab completion)
490    /// 3. Parses and executes the command
491    /// 4. Displays results or errors
492    /// 5. Repeats until the user exits
493    ///
494    /// # Returns
495    ///
496    /// - `Ok(())` when the user exits normally (via `exit` or `quit`)
497    /// - `Err(_)` on critical errors (I/O failures, etc.)
498    ///
499    /// # Example
500    ///
501    /// ```no_run
502    /// use dynamic_cli::interface::ReplInterface;
503    /// use dynamic_cli::prelude::*;
504    ///
505    /// # #[derive(Default)]
506    /// # struct MyContext;
507    /// # impl ExecutionContext for MyContext {
508    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
509    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
510    /// # }
511    /// # fn main() -> dynamic_cli::Result<()> {
512    /// let registry = CommandRegistry::new();
513    /// let context = Box::new(MyContext::default());
514    ///
515    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
516    /// repl.run()?;
517    /// # Ok(())
518    /// # }
519    /// ```
520    pub fn run(mut self) -> Result<()> {
521        loop {
522            let readline = self.editor.readline(&self.prompt);
523
524            match readline {
525                Ok(line) => {
526                    let line = line.trim();
527                    if line.is_empty() {
528                        continue;
529                    }
530
531                    if line == "exit" || line == "quit" {
532                        println!("Goodbye!");
533                        break;
534                    }
535
536                    // Parse and execute command.
537                    // History is written inside execute_line(), after successful
538                    // parsing and only when no secure argument is present.
539                    match self.execute_line(line) {
540                        Ok(()) => {}
541                        Err(e) => {
542                            display_error(&e);
543                        }
544                    }
545                }
546
547                Err(ReadlineError::Interrupted) => {
548                    println!("^C");
549                    continue;
550                }
551
552                Err(ReadlineError::Eof) => {
553                    println!("exit");
554                    break;
555                }
556
557                Err(err) => {
558                    eprintln!("Error reading input: {}", err);
559                    break;
560                }
561            }
562        }
563
564        self.save_history();
565        Ok(())
566    }
567
568    /// Execute a single line of input.
569    ///
570    /// Parses the line and executes the corresponding command.
571    /// `--help` and `-h` requests are intercepted before dispatch.
572    ///
573    /// History is written here — after successful parsing — so that:
574    /// - Failed or invalid commands are never persisted.
575    /// - Lines containing a `secure: true` argument are silently omitted.
576    fn execute_line(&mut self, line: &str) -> Result<()> {
577        if let Some(output) = self.try_handle_help(line) {
578            print!("{}", output);
579            return Ok(());
580        }
581
582        let parser = ReplParser::new(&self.registry);
583        let parsed = parser.parse_line(line)?;
584
585        // Write to history only on successful parse and when no secure
586        // argument is present in the parsed command.
587        if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
588            let _ = self.editor.add_history_entry(line);
589        }
590
591        // Sync tried first (unchanged behaviour), then async via `block_on`
592        // (DD-022). Safe here because the REPL loop is strictly sequential
593        // — one command finishes (readline blocks regardless) before the
594        // next line is even read, so there is no other async task waiting
595        // that `block_on` could starve.
596        if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
597            handler.execute(&mut *self.context, &parsed.arguments)?;
598        } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
599            futures::executor::block_on(handler.execute(&mut *self.context, &parsed.arguments))?;
600        } else {
601            return Err(DynamicCliError::Execution(
602                ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
603            ));
604        }
605
606        Ok(())
607    }
608}
609
610impl Drop for ReplInterface {
611    fn drop(&mut self) {
612        self.save_history();
613    }
614}
615
616// ============================================================================
617// Tests
618// ============================================================================
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::config::schema::{
624        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
625    };
626    use rustyline::history::History;
627    use std::collections::HashMap;
628
629    #[derive(Default)]
630    struct TestContext {
631        executed_commands: Vec<String>,
632    }
633
634    impl ExecutionContext for TestContext {
635        fn as_any(&self) -> &dyn std::any::Any {
636            self
637        }
638        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
639            self
640        }
641    }
642
643    struct TestHandler {
644        name: String,
645    }
646
647    impl crate::executor::CommandHandler for TestHandler {
648        fn execute(
649            &self,
650            context: &mut dyn ExecutionContext,
651            _args: &HashMap<String, String>,
652        ) -> Result<()> {
653            let ctx = crate::context::downcast_mut::<TestContext>(context)
654                .expect("Failed to downcast context");
655            ctx.executed_commands.push(self.name.clone());
656            Ok(())
657        }
658    }
659
660    fn create_test_registry() -> CommandRegistry {
661        let mut registry = CommandRegistry::new();
662        let cmd_def = CommandDefinition {
663            name: "test".to_string(),
664            aliases: vec!["t".to_string()],
665            description: "Test command".to_string(),
666            required: false,
667            arguments: vec![],
668            options: vec![],
669            implementation: "test_handler".to_string(),
670        };
671        registry
672            .register_sync(
673                cmd_def,
674                Box::new(TestHandler {
675                    name: "test".to_string(),
676                }),
677            )
678            .unwrap();
679        registry
680    }
681
682    fn make_help_config() -> CommandsConfig {
683        use crate::config::schema::{CommandsConfig, Metadata};
684        CommandsConfig {
685            metadata: Metadata {
686                version: "1.0.0".to_string(),
687                prompt: "testapp".to_string(),
688                prompt_suffix: " > ".to_string(),
689            },
690            commands: vec![CommandDefinition {
691                name: "hello".to_string(),
692                aliases: vec!["hi".to_string()],
693                description: "Say hello".to_string(),
694                required: false,
695                arguments: vec![],
696                options: vec![OptionDefinition {
697                    name: "loud".to_string(),
698                    short: Some("l".to_string()),
699                    long: Some("loud".to_string()),
700                    option_type: ArgumentType::Bool,
701                    required: false,
702                    default: Some("false".to_string()),
703                    description: "Loud greeting".to_string(),
704                    choices: vec![],
705                }],
706                implementation: "hello_handler".to_string(),
707            }],
708            global_options: vec![],
709        }
710    }
711
712    // ── Construction ──────────────────────────────────────────────────────────
713
714    #[test]
715    fn test_repl_interface_creation() {
716        let registry = create_test_registry();
717        let context = Box::new(TestContext::default());
718        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
719        assert!(repl.is_ok());
720    }
721
722    #[test]
723    fn test_repl_interface_creation_with_config() {
724        let registry = create_test_registry();
725        let context = Box::new(TestContext::default());
726        let config = make_help_config();
727        let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
728        assert!(repl.is_ok());
729    }
730
731    // ── execute_line ──────────────────────────────────────────────────────────
732
733    #[test]
734    fn test_repl_execute_line() {
735        let registry = create_test_registry();
736        let context = Box::new(TestContext::default());
737        let mut repl =
738            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
739        let result = repl.execute_line("test");
740        assert!(result.is_ok());
741        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
742        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
743    }
744
745    #[test]
746    fn test_repl_execute_with_alias() {
747        let registry = create_test_registry();
748        let context = Box::new(TestContext::default());
749        let mut repl =
750            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
751        assert!(repl.execute_line("t").is_ok());
752    }
753
754    #[test]
755    fn test_repl_execute_unknown_command() {
756        let registry = create_test_registry();
757        let context = Box::new(TestContext::default());
758        let mut repl =
759            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
760        let result = repl.execute_line("unknown");
761        assert!(result.is_err());
762        match result.unwrap_err() {
763            DynamicCliError::Parse(_) => {}
764            other => panic!("Expected Parse error, got: {:?}", other),
765        }
766    }
767
768    #[test]
769    fn test_repl_empty_line() {
770        let registry = create_test_registry();
771        let context = Box::new(TestContext::default());
772        let mut repl =
773            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
774        assert!(repl.execute_line("").is_err());
775    }
776
777    #[test]
778    fn test_repl_command_with_args() {
779        let mut registry = CommandRegistry::new();
780        let cmd_def = CommandDefinition {
781            name: "greet".to_string(),
782            aliases: vec![],
783            description: "Greet someone".to_string(),
784            required: false,
785            arguments: vec![ArgumentDefinition {
786                name: "name".to_string(),
787                arg_type: ArgumentType::String,
788                required: true,
789                description: "Name".to_string(),
790                validation: vec![],
791                secure: false,
792            }],
793            options: vec![],
794            implementation: "greet_handler".to_string(),
795        };
796
797        struct GreetHandler;
798        impl crate::executor::CommandHandler for GreetHandler {
799            fn execute(
800                &self,
801                _ctx: &mut dyn ExecutionContext,
802                args: &HashMap<String, String>,
803            ) -> Result<()> {
804                assert_eq!(args.get("name"), Some(&"Alice".to_string()));
805                Ok(())
806            }
807        }
808
809        registry
810            .register_sync(cmd_def, Box::new(GreetHandler))
811            .unwrap();
812        let context = Box::new(TestContext::default());
813        let mut repl =
814            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
815        assert!(repl.execute_line("greet Alice").is_ok());
816    }
817
818    // ── History path ──────────────────────────────────────────────────────────
819
820    #[test]
821    fn test_repl_history_path() {
822        let path = ReplInterface::get_history_path("myapp");
823        if let Some(p) = path {
824            let path_str = p.to_str().unwrap();
825            assert!(path_str.contains("myapp"), "path should contain app name");
826            assert!(
827                path_str.ends_with("history"),
828                "path should end with 'history', got: {}",
829                path_str
830            );
831        }
832    }
833
834    // ── Help interception ─────────────────────────────────────────────────────
835
836    #[test]
837    fn test_try_handle_help_without_formatter_returns_none() {
838        let registry = create_test_registry();
839        let context = Box::new(TestContext::default());
840        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
841        assert!(repl.try_handle_help("--help").is_none());
842        assert!(repl.try_handle_help("-h").is_none());
843    }
844
845    #[test]
846    fn test_try_handle_help_global() {
847        use crate::help::DefaultHelpFormatter;
848        colored::control::set_override(false);
849        let registry = create_test_registry();
850        let context = Box::new(TestContext::default());
851        let config = make_help_config();
852        let repl = ReplInterface::new(
853            registry,
854            context,
855            "test".to_string(),
856            Some(config),
857            Some(Box::new(DefaultHelpFormatter::new())),
858        )
859        .unwrap();
860        let out = repl.try_handle_help("--help");
861        assert!(out.is_some());
862        let out = out.unwrap();
863        assert!(out.contains("testapp"));
864        assert!(out.contains("hello"));
865    }
866
867    #[test]
868    fn test_try_handle_help_short_flag() {
869        use crate::help::DefaultHelpFormatter;
870        colored::control::set_override(false);
871        let registry = create_test_registry();
872        let context = Box::new(TestContext::default());
873        let config = make_help_config();
874        let repl = ReplInterface::new(
875            registry,
876            context,
877            "test".to_string(),
878            Some(config),
879            Some(Box::new(DefaultHelpFormatter::new())),
880        )
881        .unwrap();
882        let out = repl.try_handle_help("-h");
883        assert!(out.is_some());
884        assert!(out.unwrap().contains("testapp"));
885    }
886
887    #[test]
888    fn test_try_handle_help_with_command_prefix() {
889        use crate::help::DefaultHelpFormatter;
890        colored::control::set_override(false);
891        let registry = create_test_registry();
892        let context = Box::new(TestContext::default());
893        let config = make_help_config();
894        let repl = ReplInterface::new(
895            registry,
896            context,
897            "test".to_string(),
898            Some(config),
899            Some(Box::new(DefaultHelpFormatter::new())),
900        )
901        .unwrap();
902        let out = repl.try_handle_help("--help hello");
903        assert!(out.is_some());
904        assert!(out.unwrap().contains("hello"));
905        let out2 = repl.try_handle_help("-h hello");
906        assert!(out2.is_some());
907    }
908
909    #[test]
910    fn test_try_handle_help_command_suffix() {
911        use crate::help::DefaultHelpFormatter;
912        colored::control::set_override(false);
913        let registry = create_test_registry();
914        let context = Box::new(TestContext::default());
915        let config = make_help_config();
916        let repl = ReplInterface::new(
917            registry,
918            context,
919            "test".to_string(),
920            Some(config),
921            Some(Box::new(DefaultHelpFormatter::new())),
922        )
923        .unwrap();
924        let out = repl.try_handle_help("hello --help");
925        assert!(out.is_some());
926        assert!(out.unwrap().contains("hello"));
927        let out2 = repl.try_handle_help("hello -h");
928        assert!(out2.is_some());
929    }
930
931    #[test]
932    fn test_try_handle_help_alias() {
933        use crate::help::DefaultHelpFormatter;
934        colored::control::set_override(false);
935        let registry = create_test_registry();
936        let context = Box::new(TestContext::default());
937        let config = make_help_config();
938        let repl = ReplInterface::new(
939            registry,
940            context,
941            "test".to_string(),
942            Some(config),
943            Some(Box::new(DefaultHelpFormatter::new())),
944        )
945        .unwrap();
946        let out = repl.try_handle_help("--help hi");
947        assert!(out.is_some());
948        assert!(out.unwrap().contains("hello"));
949    }
950
951    #[test]
952    fn test_execute_line_help_intercepted() {
953        use crate::help::DefaultHelpFormatter;
954        colored::control::set_override(false);
955        let registry = create_test_registry();
956        let context = Box::new(TestContext::default());
957        let config = make_help_config();
958        let mut repl = ReplInterface::new(
959            registry,
960            context,
961            "test".to_string(),
962            Some(config),
963            Some(Box::new(DefaultHelpFormatter::new())),
964        )
965        .unwrap();
966        assert!(repl.execute_line("--help").is_ok());
967    }
968
969    #[test]
970    fn test_execute_line_normal_command_still_works_with_formatter() {
971        use crate::help::DefaultHelpFormatter;
972        let registry = create_test_registry();
973        let context = Box::new(TestContext::default());
974        let config = make_help_config();
975        let mut repl = ReplInterface::new(
976            registry,
977            context,
978            "test".to_string(),
979            Some(config),
980            Some(Box::new(DefaultHelpFormatter::new())),
981        )
982        .unwrap();
983        assert!(repl.execute_line("test").is_ok());
984    }
985
986    // ── Tab completion ────────────────────────────────────────────────────────
987
988    #[test]
989    fn test_completer_commands_empty_input() {
990        let registry = Arc::new(create_test_registry());
991        let completer = DcliCompleter::new(Arc::clone(&registry), None);
992        let history = rustyline::history::DefaultHistory::new();
993        let ctx = rustyline::Context::new(&history);
994        let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
995        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
996        assert!(names.contains(&"test"));
997        assert!(names.contains(&"t"));
998    }
999
1000    #[test]
1001    fn test_completer_commands_prefix_filter() {
1002        let registry = Arc::new(create_test_registry());
1003        let completer = DcliCompleter::new(Arc::clone(&registry), None);
1004        let history = rustyline::history::DefaultHistory::new();
1005        let ctx = rustyline::Context::new(&history);
1006        let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1007        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1008        assert!(names.contains(&"test"));
1009        assert!(!names.contains(&"t"));
1010    }
1011
1012    #[test]
1013    fn test_completer_flags_after_command() {
1014        let config = Arc::new(make_help_config());
1015        // Registry with "hello" command
1016        let mut registry = CommandRegistry::new();
1017        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1018        struct DummyHandler;
1019        impl crate::executor::CommandHandler for DummyHandler {
1020            fn execute(
1021                &self,
1022                _: &mut dyn ExecutionContext,
1023                _: &HashMap<String, String>,
1024            ) -> Result<()> {
1025                Ok(())
1026            }
1027        }
1028        registry
1029            .register_sync(cmd_def, Box::new(DummyHandler))
1030            .unwrap();
1031        let registry = Arc::new(registry);
1032
1033        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1034        let history = rustyline::history::DefaultHistory::new();
1035        let ctx = rustyline::Context::new(&history);
1036
1037        // "hello " → should propose --loud and -l
1038        let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1039        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1040        assert!(
1041            names.contains(&"--loud"),
1042            "expected --loud, got {:?}",
1043            names
1044        );
1045        assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1046    }
1047
1048    #[test]
1049    fn test_completer_flags_prefix_filter() {
1050        let config = Arc::new(make_help_config());
1051        let mut registry = CommandRegistry::new();
1052        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1053        struct DummyHandler;
1054        impl crate::executor::CommandHandler for DummyHandler {
1055            fn execute(
1056                &self,
1057                _: &mut dyn ExecutionContext,
1058                _: &HashMap<String, String>,
1059            ) -> Result<()> {
1060                Ok(())
1061            }
1062        }
1063        registry
1064            .register_sync(cmd_def, Box::new(DummyHandler))
1065            .unwrap();
1066        let registry = Arc::new(registry);
1067
1068        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1069        let history = rustyline::history::DefaultHistory::new();
1070        let ctx = rustyline::Context::new(&history);
1071
1072        // "hello --l" → only --loud
1073        let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1074        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1075        assert!(names.contains(&"--loud"));
1076        assert!(!names.contains(&"-l"));
1077    }
1078
1079    #[test]
1080    fn test_completer_no_flags_for_unknown_command() {
1081        let config = Arc::new(make_help_config());
1082        let registry = Arc::new(create_test_registry());
1083        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1084        let history = rustyline::history::DefaultHistory::new();
1085        let ctx = rustyline::Context::new(&history);
1086        // "unknown " → empty (command not in registry)
1087        let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1088        assert!(candidates.is_empty());
1089    }
1090
1091    // ── has_secure_arg ────────────────────────────────────────────────────────
1092
1093    /// Build a registry + config with one command that has a `secure` argument.
1094    fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1095        use crate::config::schema::{CommandsConfig, Metadata};
1096
1097        let cmd_def = CommandDefinition {
1098            name: "login".to_string(),
1099            aliases: vec![],
1100            description: "Login command".to_string(),
1101            required: false,
1102            arguments: vec![
1103                ArgumentDefinition {
1104                    name: "username".to_string(),
1105                    arg_type: ArgumentType::String,
1106                    required: true,
1107                    description: "Username".to_string(),
1108                    validation: vec![],
1109                    secure: false,
1110                },
1111                ArgumentDefinition {
1112                    name: "password".to_string(),
1113                    arg_type: ArgumentType::String,
1114                    required: true,
1115                    description: "Password".to_string(),
1116                    validation: vec![],
1117                    secure: true,
1118                },
1119            ],
1120            options: vec![],
1121            implementation: "login_handler".to_string(),
1122        };
1123
1124        struct LoginHandler;
1125        impl crate::executor::CommandHandler for LoginHandler {
1126            fn execute(
1127                &self,
1128                _ctx: &mut dyn ExecutionContext,
1129                _args: &HashMap<String, String>,
1130            ) -> Result<()> {
1131                Ok(())
1132            }
1133        }
1134
1135        let mut registry = CommandRegistry::new();
1136        registry
1137            .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1138            .unwrap();
1139
1140        let config = CommandsConfig {
1141            metadata: Metadata {
1142                version: "1.0.0".to_string(),
1143                prompt: "testapp".to_string(),
1144                prompt_suffix: " > ".to_string(),
1145            },
1146            commands: vec![cmd_def],
1147            global_options: vec![],
1148        };
1149
1150        (registry, config)
1151    }
1152
1153    #[test]
1154    fn test_has_secure_arg_returns_false_without_config() {
1155        let registry = create_test_registry();
1156        let context = Box::new(TestContext::default());
1157        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1158
1159        let mut args = HashMap::new();
1160        args.insert("password".to_string(), "secret".to_string());
1161
1162        assert!(!repl.has_secure_arg("login", &args));
1163    }
1164
1165    #[test]
1166    fn test_has_secure_arg_returns_false_when_no_secure_field() {
1167        let registry = create_test_registry();
1168        let context = Box::new(TestContext::default());
1169        let config = make_help_config();
1170        let repl =
1171            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1172
1173        let mut args = HashMap::new();
1174        args.insert("loud".to_string(), "true".to_string());
1175
1176        assert!(!repl.has_secure_arg("hello", &args));
1177    }
1178
1179    #[test]
1180    fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1181        let (registry, config) = make_secure_registry_and_config();
1182        let context = Box::new(TestContext::default());
1183        let repl =
1184            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1185
1186        let mut args = HashMap::new();
1187        args.insert("username".to_string(), "alice".to_string());
1188        args.insert("password".to_string(), "secret".to_string());
1189
1190        assert!(repl.has_secure_arg("login", &args));
1191    }
1192
1193    #[test]
1194    fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1195        let (registry, config) = make_secure_registry_and_config();
1196        let context = Box::new(TestContext::default());
1197        let repl =
1198            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1199
1200        // Only username provided — password (secure) absent from parsed args.
1201        let mut args = HashMap::new();
1202        args.insert("username".to_string(), "alice".to_string());
1203
1204        assert!(!repl.has_secure_arg("login", &args));
1205    }
1206
1207    #[test]
1208    fn test_has_secure_arg_returns_false_for_unknown_command() {
1209        let (registry, config) = make_secure_registry_and_config();
1210        let context = Box::new(TestContext::default());
1211        let repl =
1212            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1213
1214        let mut args = HashMap::new();
1215        args.insert("password".to_string(), "secret".to_string());
1216
1217        assert!(!repl.has_secure_arg("nonexistent", &args));
1218    }
1219
1220    // ── Secure argument history filtering ─────────────────────────────────────
1221
1222    #[test]
1223    fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1224        let (registry, config) = make_secure_registry_and_config();
1225        let context = Box::new(TestContext::default());
1226        let mut repl =
1227            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1228
1229        let result = repl.execute_line("login alice secret");
1230        assert!(result.is_ok());
1231
1232        // The line must NOT appear in the in-memory history.
1233        let history = repl.editor.history();
1234        let in_history = (0..history.len()).any(|i| {
1235            history
1236                .get(i, rustyline::history::SearchDirection::Forward)
1237                .ok()
1238                .flatten()
1239                .map(|e| e.entry.as_ref() == "login alice secret")
1240                .unwrap_or(false)
1241        });
1242        assert!(
1243            !in_history,
1244            "secure command line must not be written to history"
1245        );
1246    }
1247
1248    #[test]
1249    fn test_execute_line_without_secure_arg_adds_to_history() {
1250        let registry = create_test_registry();
1251        let context = Box::new(TestContext::default());
1252        let mut repl =
1253            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1254
1255        let result = repl.execute_line("test");
1256        assert!(result.is_ok());
1257
1258        // The line must appear in the in-memory history.
1259        let history = repl.editor.history();
1260        let in_history = (0..history.len()).any(|i| {
1261            history
1262                .get(i, rustyline::history::SearchDirection::Forward)
1263                .ok()
1264                .flatten()
1265                .map(|e| e.entry.as_ref() == "test")
1266                .unwrap_or(false)
1267        });
1268        assert!(
1269            in_history,
1270            "non-secure command line must be written to history"
1271        );
1272    }
1273}