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        let handler = self
592            .registry
593            .get_handler(&parsed.command_name)
594            .ok_or_else(|| {
595                DynamicCliError::Execution(ExecutionError::handler_not_found(
596                    &parsed.command_name,
597                    "unknown",
598                ))
599            })?;
600
601        handler.execute(&mut *self.context, &parsed.arguments)?;
602
603        Ok(())
604    }
605}
606
607impl Drop for ReplInterface {
608    fn drop(&mut self) {
609        self.save_history();
610    }
611}
612
613// ============================================================================
614// Tests
615// ============================================================================
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::config::schema::{
621        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
622    };
623    use rustyline::history::History;
624    use std::collections::HashMap;
625
626    #[derive(Default)]
627    struct TestContext {
628        executed_commands: Vec<String>,
629    }
630
631    impl ExecutionContext for TestContext {
632        fn as_any(&self) -> &dyn std::any::Any {
633            self
634        }
635        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
636            self
637        }
638    }
639
640    struct TestHandler {
641        name: String,
642    }
643
644    impl crate::executor::CommandHandler for TestHandler {
645        fn execute(
646            &self,
647            context: &mut dyn ExecutionContext,
648            _args: &HashMap<String, String>,
649        ) -> Result<()> {
650            let ctx = crate::context::downcast_mut::<TestContext>(context)
651                .expect("Failed to downcast context");
652            ctx.executed_commands.push(self.name.clone());
653            Ok(())
654        }
655    }
656
657    fn create_test_registry() -> CommandRegistry {
658        let mut registry = CommandRegistry::new();
659        let cmd_def = CommandDefinition {
660            name: "test".to_string(),
661            aliases: vec!["t".to_string()],
662            description: "Test command".to_string(),
663            required: false,
664            arguments: vec![],
665            options: vec![],
666            implementation: "test_handler".to_string(),
667        };
668        registry
669            .register(
670                cmd_def,
671                Box::new(TestHandler {
672                    name: "test".to_string(),
673                }),
674            )
675            .unwrap();
676        registry
677    }
678
679    fn make_help_config() -> CommandsConfig {
680        use crate::config::schema::{CommandsConfig, Metadata};
681        CommandsConfig {
682            metadata: Metadata {
683                version: "1.0.0".to_string(),
684                prompt: "testapp".to_string(),
685                prompt_suffix: " > ".to_string(),
686            },
687            commands: vec![CommandDefinition {
688                name: "hello".to_string(),
689                aliases: vec!["hi".to_string()],
690                description: "Say hello".to_string(),
691                required: false,
692                arguments: vec![],
693                options: vec![OptionDefinition {
694                    name: "loud".to_string(),
695                    short: Some("l".to_string()),
696                    long: Some("loud".to_string()),
697                    option_type: ArgumentType::Bool,
698                    required: false,
699                    default: Some("false".to_string()),
700                    description: "Loud greeting".to_string(),
701                    choices: vec![],
702                }],
703                implementation: "hello_handler".to_string(),
704            }],
705            global_options: vec![],
706        }
707    }
708
709    // ── Construction ──────────────────────────────────────────────────────────
710
711    #[test]
712    fn test_repl_interface_creation() {
713        let registry = create_test_registry();
714        let context = Box::new(TestContext::default());
715        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
716        assert!(repl.is_ok());
717    }
718
719    #[test]
720    fn test_repl_interface_creation_with_config() {
721        let registry = create_test_registry();
722        let context = Box::new(TestContext::default());
723        let config = make_help_config();
724        let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
725        assert!(repl.is_ok());
726    }
727
728    // ── execute_line ──────────────────────────────────────────────────────────
729
730    #[test]
731    fn test_repl_execute_line() {
732        let registry = create_test_registry();
733        let context = Box::new(TestContext::default());
734        let mut repl =
735            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
736        let result = repl.execute_line("test");
737        assert!(result.is_ok());
738        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
739        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
740    }
741
742    #[test]
743    fn test_repl_execute_with_alias() {
744        let registry = create_test_registry();
745        let context = Box::new(TestContext::default());
746        let mut repl =
747            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
748        assert!(repl.execute_line("t").is_ok());
749    }
750
751    #[test]
752    fn test_repl_execute_unknown_command() {
753        let registry = create_test_registry();
754        let context = Box::new(TestContext::default());
755        let mut repl =
756            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
757        let result = repl.execute_line("unknown");
758        assert!(result.is_err());
759        match result.unwrap_err() {
760            DynamicCliError::Parse(_) => {}
761            other => panic!("Expected Parse error, got: {:?}", other),
762        }
763    }
764
765    #[test]
766    fn test_repl_empty_line() {
767        let registry = create_test_registry();
768        let context = Box::new(TestContext::default());
769        let mut repl =
770            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
771        assert!(repl.execute_line("").is_err());
772    }
773
774    #[test]
775    fn test_repl_command_with_args() {
776        let mut registry = CommandRegistry::new();
777        let cmd_def = CommandDefinition {
778            name: "greet".to_string(),
779            aliases: vec![],
780            description: "Greet someone".to_string(),
781            required: false,
782            arguments: vec![ArgumentDefinition {
783                name: "name".to_string(),
784                arg_type: ArgumentType::String,
785                required: true,
786                description: "Name".to_string(),
787                validation: vec![],
788                secure: false,
789            }],
790            options: vec![],
791            implementation: "greet_handler".to_string(),
792        };
793
794        struct GreetHandler;
795        impl crate::executor::CommandHandler for GreetHandler {
796            fn execute(
797                &self,
798                _ctx: &mut dyn ExecutionContext,
799                args: &HashMap<String, String>,
800            ) -> Result<()> {
801                assert_eq!(args.get("name"), Some(&"Alice".to_string()));
802                Ok(())
803            }
804        }
805
806        registry.register(cmd_def, Box::new(GreetHandler)).unwrap();
807        let context = Box::new(TestContext::default());
808        let mut repl =
809            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
810        assert!(repl.execute_line("greet Alice").is_ok());
811    }
812
813    // ── History path ──────────────────────────────────────────────────────────
814
815    #[test]
816    fn test_repl_history_path() {
817        let path = ReplInterface::get_history_path("myapp");
818        if let Some(p) = path {
819            let path_str = p.to_str().unwrap();
820            assert!(path_str.contains("myapp"), "path should contain app name");
821            assert!(
822                path_str.ends_with("history"),
823                "path should end with 'history', got: {}",
824                path_str
825            );
826        }
827    }
828
829    // ── Help interception ─────────────────────────────────────────────────────
830
831    #[test]
832    fn test_try_handle_help_without_formatter_returns_none() {
833        let registry = create_test_registry();
834        let context = Box::new(TestContext::default());
835        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
836        assert!(repl.try_handle_help("--help").is_none());
837        assert!(repl.try_handle_help("-h").is_none());
838    }
839
840    #[test]
841    fn test_try_handle_help_global() {
842        use crate::help::DefaultHelpFormatter;
843        colored::control::set_override(false);
844        let registry = create_test_registry();
845        let context = Box::new(TestContext::default());
846        let config = make_help_config();
847        let repl = ReplInterface::new(
848            registry,
849            context,
850            "test".to_string(),
851            Some(config),
852            Some(Box::new(DefaultHelpFormatter::new())),
853        )
854        .unwrap();
855        let out = repl.try_handle_help("--help");
856        assert!(out.is_some());
857        let out = out.unwrap();
858        assert!(out.contains("testapp"));
859        assert!(out.contains("hello"));
860    }
861
862    #[test]
863    fn test_try_handle_help_short_flag() {
864        use crate::help::DefaultHelpFormatter;
865        colored::control::set_override(false);
866        let registry = create_test_registry();
867        let context = Box::new(TestContext::default());
868        let config = make_help_config();
869        let repl = ReplInterface::new(
870            registry,
871            context,
872            "test".to_string(),
873            Some(config),
874            Some(Box::new(DefaultHelpFormatter::new())),
875        )
876        .unwrap();
877        let out = repl.try_handle_help("-h");
878        assert!(out.is_some());
879        assert!(out.unwrap().contains("testapp"));
880    }
881
882    #[test]
883    fn test_try_handle_help_with_command_prefix() {
884        use crate::help::DefaultHelpFormatter;
885        colored::control::set_override(false);
886        let registry = create_test_registry();
887        let context = Box::new(TestContext::default());
888        let config = make_help_config();
889        let repl = ReplInterface::new(
890            registry,
891            context,
892            "test".to_string(),
893            Some(config),
894            Some(Box::new(DefaultHelpFormatter::new())),
895        )
896        .unwrap();
897        let out = repl.try_handle_help("--help hello");
898        assert!(out.is_some());
899        assert!(out.unwrap().contains("hello"));
900        let out2 = repl.try_handle_help("-h hello");
901        assert!(out2.is_some());
902    }
903
904    #[test]
905    fn test_try_handle_help_command_suffix() {
906        use crate::help::DefaultHelpFormatter;
907        colored::control::set_override(false);
908        let registry = create_test_registry();
909        let context = Box::new(TestContext::default());
910        let config = make_help_config();
911        let repl = ReplInterface::new(
912            registry,
913            context,
914            "test".to_string(),
915            Some(config),
916            Some(Box::new(DefaultHelpFormatter::new())),
917        )
918        .unwrap();
919        let out = repl.try_handle_help("hello --help");
920        assert!(out.is_some());
921        assert!(out.unwrap().contains("hello"));
922        let out2 = repl.try_handle_help("hello -h");
923        assert!(out2.is_some());
924    }
925
926    #[test]
927    fn test_try_handle_help_alias() {
928        use crate::help::DefaultHelpFormatter;
929        colored::control::set_override(false);
930        let registry = create_test_registry();
931        let context = Box::new(TestContext::default());
932        let config = make_help_config();
933        let repl = ReplInterface::new(
934            registry,
935            context,
936            "test".to_string(),
937            Some(config),
938            Some(Box::new(DefaultHelpFormatter::new())),
939        )
940        .unwrap();
941        let out = repl.try_handle_help("--help hi");
942        assert!(out.is_some());
943        assert!(out.unwrap().contains("hello"));
944    }
945
946    #[test]
947    fn test_execute_line_help_intercepted() {
948        use crate::help::DefaultHelpFormatter;
949        colored::control::set_override(false);
950        let registry = create_test_registry();
951        let context = Box::new(TestContext::default());
952        let config = make_help_config();
953        let mut repl = ReplInterface::new(
954            registry,
955            context,
956            "test".to_string(),
957            Some(config),
958            Some(Box::new(DefaultHelpFormatter::new())),
959        )
960        .unwrap();
961        assert!(repl.execute_line("--help").is_ok());
962    }
963
964    #[test]
965    fn test_execute_line_normal_command_still_works_with_formatter() {
966        use crate::help::DefaultHelpFormatter;
967        let registry = create_test_registry();
968        let context = Box::new(TestContext::default());
969        let config = make_help_config();
970        let mut repl = ReplInterface::new(
971            registry,
972            context,
973            "test".to_string(),
974            Some(config),
975            Some(Box::new(DefaultHelpFormatter::new())),
976        )
977        .unwrap();
978        assert!(repl.execute_line("test").is_ok());
979    }
980
981    // ── Tab completion ────────────────────────────────────────────────────────
982
983    #[test]
984    fn test_completer_commands_empty_input() {
985        let registry = Arc::new(create_test_registry());
986        let completer = DcliCompleter::new(Arc::clone(&registry), None);
987        let history = rustyline::history::DefaultHistory::new();
988        let ctx = rustyline::Context::new(&history);
989        let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
990        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
991        assert!(names.contains(&"test"));
992        assert!(names.contains(&"t"));
993    }
994
995    #[test]
996    fn test_completer_commands_prefix_filter() {
997        let registry = Arc::new(create_test_registry());
998        let completer = DcliCompleter::new(Arc::clone(&registry), None);
999        let history = rustyline::history::DefaultHistory::new();
1000        let ctx = rustyline::Context::new(&history);
1001        let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1002        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1003        assert!(names.contains(&"test"));
1004        assert!(!names.contains(&"t"));
1005    }
1006
1007    #[test]
1008    fn test_completer_flags_after_command() {
1009        let config = Arc::new(make_help_config());
1010        // Registry with "hello" command
1011        let mut registry = CommandRegistry::new();
1012        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1013        struct DummyHandler;
1014        impl crate::executor::CommandHandler for DummyHandler {
1015            fn execute(
1016                &self,
1017                _: &mut dyn ExecutionContext,
1018                _: &HashMap<String, String>,
1019            ) -> Result<()> {
1020                Ok(())
1021            }
1022        }
1023        registry.register(cmd_def, Box::new(DummyHandler)).unwrap();
1024        let registry = Arc::new(registry);
1025
1026        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1027        let history = rustyline::history::DefaultHistory::new();
1028        let ctx = rustyline::Context::new(&history);
1029
1030        // "hello " → should propose --loud and -l
1031        let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1032        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1033        assert!(
1034            names.contains(&"--loud"),
1035            "expected --loud, got {:?}",
1036            names
1037        );
1038        assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1039    }
1040
1041    #[test]
1042    fn test_completer_flags_prefix_filter() {
1043        let config = Arc::new(make_help_config());
1044        let mut registry = CommandRegistry::new();
1045        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1046        struct DummyHandler;
1047        impl crate::executor::CommandHandler for DummyHandler {
1048            fn execute(
1049                &self,
1050                _: &mut dyn ExecutionContext,
1051                _: &HashMap<String, String>,
1052            ) -> Result<()> {
1053                Ok(())
1054            }
1055        }
1056        registry.register(cmd_def, Box::new(DummyHandler)).unwrap();
1057        let registry = Arc::new(registry);
1058
1059        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1060        let history = rustyline::history::DefaultHistory::new();
1061        let ctx = rustyline::Context::new(&history);
1062
1063        // "hello --l" → only --loud
1064        let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1065        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1066        assert!(names.contains(&"--loud"));
1067        assert!(!names.contains(&"-l"));
1068    }
1069
1070    #[test]
1071    fn test_completer_no_flags_for_unknown_command() {
1072        let config = Arc::new(make_help_config());
1073        let registry = Arc::new(create_test_registry());
1074        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1075        let history = rustyline::history::DefaultHistory::new();
1076        let ctx = rustyline::Context::new(&history);
1077        // "unknown " → empty (command not in registry)
1078        let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1079        assert!(candidates.is_empty());
1080    }
1081
1082    // ── has_secure_arg ────────────────────────────────────────────────────────
1083
1084    /// Build a registry + config with one command that has a `secure` argument.
1085    fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1086        use crate::config::schema::{CommandsConfig, Metadata};
1087
1088        let cmd_def = CommandDefinition {
1089            name: "login".to_string(),
1090            aliases: vec![],
1091            description: "Login command".to_string(),
1092            required: false,
1093            arguments: vec![
1094                ArgumentDefinition {
1095                    name: "username".to_string(),
1096                    arg_type: ArgumentType::String,
1097                    required: true,
1098                    description: "Username".to_string(),
1099                    validation: vec![],
1100                    secure: false,
1101                },
1102                ArgumentDefinition {
1103                    name: "password".to_string(),
1104                    arg_type: ArgumentType::String,
1105                    required: true,
1106                    description: "Password".to_string(),
1107                    validation: vec![],
1108                    secure: true,
1109                },
1110            ],
1111            options: vec![],
1112            implementation: "login_handler".to_string(),
1113        };
1114
1115        struct LoginHandler;
1116        impl crate::executor::CommandHandler for LoginHandler {
1117            fn execute(
1118                &self,
1119                _ctx: &mut dyn ExecutionContext,
1120                _args: &HashMap<String, String>,
1121            ) -> Result<()> {
1122                Ok(())
1123            }
1124        }
1125
1126        let mut registry = CommandRegistry::new();
1127        registry
1128            .register(cmd_def.clone(), Box::new(LoginHandler))
1129            .unwrap();
1130
1131        let config = CommandsConfig {
1132            metadata: Metadata {
1133                version: "1.0.0".to_string(),
1134                prompt: "testapp".to_string(),
1135                prompt_suffix: " > ".to_string(),
1136            },
1137            commands: vec![cmd_def],
1138            global_options: vec![],
1139        };
1140
1141        (registry, config)
1142    }
1143
1144    #[test]
1145    fn test_has_secure_arg_returns_false_without_config() {
1146        let registry = create_test_registry();
1147        let context = Box::new(TestContext::default());
1148        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1149
1150        let mut args = HashMap::new();
1151        args.insert("password".to_string(), "secret".to_string());
1152
1153        assert!(!repl.has_secure_arg("login", &args));
1154    }
1155
1156    #[test]
1157    fn test_has_secure_arg_returns_false_when_no_secure_field() {
1158        let registry = create_test_registry();
1159        let context = Box::new(TestContext::default());
1160        let config = make_help_config();
1161        let repl =
1162            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1163
1164        let mut args = HashMap::new();
1165        args.insert("loud".to_string(), "true".to_string());
1166
1167        assert!(!repl.has_secure_arg("hello", &args));
1168    }
1169
1170    #[test]
1171    fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1172        let (registry, config) = make_secure_registry_and_config();
1173        let context = Box::new(TestContext::default());
1174        let repl =
1175            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1176
1177        let mut args = HashMap::new();
1178        args.insert("username".to_string(), "alice".to_string());
1179        args.insert("password".to_string(), "secret".to_string());
1180
1181        assert!(repl.has_secure_arg("login", &args));
1182    }
1183
1184    #[test]
1185    fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1186        let (registry, config) = make_secure_registry_and_config();
1187        let context = Box::new(TestContext::default());
1188        let repl =
1189            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1190
1191        // Only username provided — password (secure) absent from parsed args.
1192        let mut args = HashMap::new();
1193        args.insert("username".to_string(), "alice".to_string());
1194
1195        assert!(!repl.has_secure_arg("login", &args));
1196    }
1197
1198    #[test]
1199    fn test_has_secure_arg_returns_false_for_unknown_command() {
1200        let (registry, config) = make_secure_registry_and_config();
1201        let context = Box::new(TestContext::default());
1202        let repl =
1203            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1204
1205        let mut args = HashMap::new();
1206        args.insert("password".to_string(), "secret".to_string());
1207
1208        assert!(!repl.has_secure_arg("nonexistent", &args));
1209    }
1210
1211    // ── Secure argument history filtering ─────────────────────────────────────
1212
1213    #[test]
1214    fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1215        let (registry, config) = make_secure_registry_and_config();
1216        let context = Box::new(TestContext::default());
1217        let mut repl =
1218            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1219
1220        let result = repl.execute_line("login alice secret");
1221        assert!(result.is_ok());
1222
1223        // The line must NOT appear in the in-memory history.
1224        let history = repl.editor.history();
1225        let in_history = (0..history.len()).any(|i| {
1226            history
1227                .get(i, rustyline::history::SearchDirection::Forward)
1228                .ok()
1229                .flatten()
1230                .map(|e| e.entry.as_ref() == "login alice secret")
1231                .unwrap_or(false)
1232        });
1233        assert!(
1234            !in_history,
1235            "secure command line must not be written to history"
1236        );
1237    }
1238
1239    #[test]
1240    fn test_execute_line_without_secure_arg_adds_to_history() {
1241        let registry = create_test_registry();
1242        let context = Box::new(TestContext::default());
1243        let mut repl =
1244            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1245
1246        let result = repl.execute_line("test");
1247        assert!(result.is_ok());
1248
1249        // The line must appear in the in-memory history.
1250        let history = repl.editor.history();
1251        let in_history = (0..history.len()).any(|i| {
1252            history
1253                .get(i, rustyline::history::SearchDirection::Forward)
1254                .ok()
1255                .flatten()
1256                .map(|e| e.entry.as_ref() == "test")
1257                .unwrap_or(false)
1258        });
1259        assert!(
1260            in_history,
1261            "non-secure command line must be written to history"
1262        );
1263    }
1264}