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, ParseError, Result};
44use crate::help::HelpFormatter;
45use crate::parser::{ParsedArgs, 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    /// Intercept a `:load <path>` line before normal command parsing (#41
430    /// scope extension).
431    ///
432    /// Returns `None` when `line` doesn't start with `:load ` — normal
433    /// dispatch proceeds. Returns `Some(result)` when it does, whether
434    /// the load itself succeeds or fails.
435    ///
436    /// Unlike [`CliInterface::run_script`][crate::interface::CliInterface::run_script],
437    /// there is no error-policy parameter here: a failing line is
438    /// reported inline (via [`display_error`]) and the load always
439    /// continues to the next line, printing a final `succeeded/attempted`
440    /// summary. This matches how the REPL already surfaces errors for
441    /// interactively-typed lines — one at a time, without halting the
442    /// session — rather than the batch abort/continue choice that makes
443    /// sense for a one-shot script run.
444    ///
445    /// Each loaded line is dispatched via [`execute_line`][Self::execute_line]
446    /// itself — the same scalar-only path (DD-024 addendum) as any other
447    /// REPL-typed line, **not**
448    /// [`crate::interface::CliInterface::run_script`]'s typed/repeatable-options
449    /// path. A loaded script is not added to `rustyline` history, and a
450    /// script that `:load`s itself (directly or via another file) will
451    /// recurse until the file handle limit or stack is exhausted — no
452    /// cycle detection is implemented.
453    fn try_handle_load(&mut self, line: &str) -> Option<Result<()>> {
454        let path = line.trim().strip_prefix(":load ").map(str::trim)?;
455
456        if path.is_empty() {
457            return Some(Err(DynamicCliError::Parse(ParseError::InvalidSyntax {
458                details: "`:load` requires a file path".to_string(),
459                hint: Some("Usage: :load <path/to/script.txt>".to_string()),
460            })));
461        }
462
463        Some(self.load_script(path))
464    }
465
466    /// Read `path` and dispatch each non-blank, non-comment (`#`-prefixed)
467    /// line through [`execute_line`][Self::execute_line], continuing past
468    /// any failure. See [`try_handle_load`][Self::try_handle_load] for the
469    /// full behaviour.
470    fn load_script(&mut self, path: &str) -> Result<()> {
471        let content = std::fs::read_to_string(path).map_err(|e| {
472            DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
473                "failed to read script file {}: {}",
474                path,
475                e
476            )))
477        })?;
478
479        let mut attempted = 0usize;
480        let mut succeeded = 0usize;
481
482        for (idx, raw_line) in content.lines().enumerate() {
483            let line_number = idx + 1;
484            let script_line = raw_line.trim();
485
486            if script_line.is_empty() || script_line.starts_with('#') {
487                continue;
488            }
489
490            attempted += 1;
491
492            match self.execute_line(script_line) {
493                Ok(()) => succeeded += 1,
494                Err(e) => {
495                    eprintln!("  :load {} — line {}:", path, line_number);
496                    display_error(&e);
497                }
498            }
499        }
500
501        println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
502        Ok(())
503    }
504
505    /// Check whether a parsed command involves at least one secure argument.
506    ///
507    /// Looks up the command definition in `self.config` (if available) and
508    /// returns `true` when any argument name present in `parsed_args` is
509    /// marked `secure: true` in the YAML schema.
510    fn has_secure_arg(
511        &self,
512        command_name: &str,
513        parsed_args: &std::collections::HashMap<String, String>,
514    ) -> bool {
515        let config = match &self.config {
516            Some(c) => c,
517            None => return false,
518        };
519
520        let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
521            Some(d) => d,
522            None => return false,
523        };
524
525        cmd_def
526            .arguments
527            .iter()
528            .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
529    }
530
531    /// Get the history file path for this application.
532    ///
533    /// Each application gets its own isolated history file under the
534    /// XDG data directory:
535    ///
536    /// - Linux/macOS: `~/.local/share/<app_name>/history`
537    /// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
538    fn get_history_path(app_name: &str) -> Option<PathBuf> {
539        dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
540    }
541
542    /// Load command history from file.
543    fn load_history(&mut self) {
544        if let Some(ref path) = self.history_path {
545            if let Some(parent) = path.parent() {
546                let _ = std::fs::create_dir_all(parent);
547            }
548            let _ = self.editor.load_history(path);
549        }
550    }
551
552    /// Save command history to file.
553    fn save_history(&mut self) {
554        if let Some(ref path) = self.history_path {
555            if let Err(e) = self.editor.save_history(path) {
556                eprintln!("Warning: Failed to save command history: {}", e);
557            }
558        }
559    }
560
561    /// Run the REPL loop.
562    ///
563    /// Enters an interactive loop that:
564    /// 1. Displays the prompt
565    /// 2. Reads user input (with tab completion)
566    /// 3. Parses and executes the command
567    /// 4. Displays results or errors
568    /// 5. Repeats until the user exits
569    ///
570    /// # Returns
571    ///
572    /// - `Ok(())` when the user exits normally (via `exit` or `quit`)
573    /// - `Err(_)` on critical errors (I/O failures, etc.)
574    ///
575    /// # Example
576    ///
577    /// ```no_run
578    /// use dynamic_cli::interface::ReplInterface;
579    /// use dynamic_cli::prelude::*;
580    ///
581    /// # #[derive(Default)]
582    /// # struct MyContext;
583    /// # impl ExecutionContext for MyContext {
584    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
585    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
586    /// # }
587    /// # fn main() -> dynamic_cli::Result<()> {
588    /// let registry = CommandRegistry::new();
589    /// let context = Box::new(MyContext::default());
590    ///
591    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
592    /// repl.run()?;
593    /// # Ok(())
594    /// # }
595    /// ```
596    pub fn run(mut self) -> Result<()> {
597        loop {
598            let readline = self.editor.readline(&self.prompt);
599
600            match readline {
601                Ok(line) => {
602                    let line = line.trim();
603                    if line.is_empty() {
604                        continue;
605                    }
606
607                    if line == "exit" || line == "quit" {
608                        println!("Goodbye!");
609                        break;
610                    }
611
612                    // Parse and execute command.
613                    // History is written inside execute_line(), after successful
614                    // parsing and only when no secure argument is present.
615                    match self.execute_line(line) {
616                        Ok(()) => {}
617                        Err(e) => {
618                            display_error(&e);
619                        }
620                    }
621                }
622
623                Err(ReadlineError::Interrupted) => {
624                    println!("^C");
625                    continue;
626                }
627
628                Err(ReadlineError::Eof) => {
629                    println!("exit");
630                    break;
631                }
632
633                Err(err) => {
634                    eprintln!("Error reading input: {}", err);
635                    break;
636                }
637            }
638        }
639
640        self.save_history();
641        Ok(())
642    }
643
644    /// Execute a single line of input.
645    ///
646    /// Parses the line and executes the corresponding command.
647    /// `--help` and `-h` requests are intercepted before dispatch.
648    ///
649    /// History is written here — after successful parsing — so that:
650    /// - Failed or invalid commands are never persisted.
651    /// - Lines containing a `secure: true` argument are silently omitted.
652    fn execute_line(&mut self, line: &str) -> Result<()> {
653        if let Some(output) = self.try_handle_help(line) {
654            print!("{}", output);
655            return Ok(());
656        }
657
658        if let Some(result) = self.try_handle_load(line) {
659            return result;
660        }
661
662        let parser = ReplParser::new(&self.registry);
663        let parsed = parser.parse_line(line)?;
664
665        // Write to history only on successful parse and when no secure
666        // argument is present in the parsed command.
667        if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
668            let _ = self.editor.add_history_entry(line);
669        }
670
671        // Sync tried first (unchanged behaviour), then async via `block_on`
672        // (DD-022). Safe here because the REPL loop is strictly sequential
673        // — one command finishes (readline blocks regardless) before the
674        // next line is even read, so there is no other async task waiting
675        // that `block_on` could starve.
676        //
677        // Wrapped via `from_scalars`: `ReplParser::parse_line` still
678        // produces a plain `HashMap<String, String>` (DD-024 addendum —
679        // repeatable options have no interactive REPL-typing use case, see
680        // `DESIGN_DECISIONS.md`). Every handler receives `&ParsedArgs`
681        // regardless of dispatch path (#39); the REPL path just never
682        // populates `ParsedValue::Repeated` entries.
683        let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
684        if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
685            handler.execute(&mut *self.context, &parsed_args)?;
686        } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
687            futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
688        } else {
689            return Err(DynamicCliError::Execution(
690                ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
691            ));
692        }
693
694        Ok(())
695    }
696}
697
698impl Drop for ReplInterface {
699    fn drop(&mut self) {
700        self.save_history();
701    }
702}
703
704// ============================================================================
705// Tests
706// ============================================================================
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::config::schema::{
712        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
713    };
714    use rustyline::history::History;
715    use std::collections::HashMap;
716
717    #[derive(Default)]
718    struct TestContext {
719        executed_commands: Vec<String>,
720    }
721
722    impl ExecutionContext for TestContext {
723        fn as_any(&self) -> &dyn std::any::Any {
724            self
725        }
726        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
727            self
728        }
729    }
730
731    struct TestHandler {
732        name: String,
733    }
734
735    impl crate::executor::CommandHandler for TestHandler {
736        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
737            let ctx = crate::context::downcast_mut::<TestContext>(context)
738                .expect("Failed to downcast context");
739            ctx.executed_commands.push(self.name.clone());
740            Ok(())
741        }
742    }
743
744    fn create_test_registry() -> CommandRegistry {
745        let mut registry = CommandRegistry::new();
746        let cmd_def = CommandDefinition {
747            name: "test".to_string(),
748            aliases: vec!["t".to_string()],
749            description: "Test command".to_string(),
750            required: false,
751            arguments: vec![],
752            options: vec![],
753            implementation: "test_handler".to_string(),
754        };
755        registry
756            .register_sync(
757                cmd_def,
758                Box::new(TestHandler {
759                    name: "test".to_string(),
760                }),
761            )
762            .unwrap();
763        registry
764    }
765
766    fn make_help_config() -> CommandsConfig {
767        use crate::config::schema::{CommandsConfig, Metadata};
768        CommandsConfig {
769            metadata: Metadata {
770                version: "1.0.0".to_string(),
771                prompt: "testapp".to_string(),
772                prompt_suffix: " > ".to_string(),
773            },
774            commands: vec![CommandDefinition {
775                name: "hello".to_string(),
776                aliases: vec!["hi".to_string()],
777                description: "Say hello".to_string(),
778                required: false,
779                arguments: vec![],
780                options: vec![OptionDefinition {
781                    name: "loud".to_string(),
782                    short: Some("l".to_string()),
783                    long: Some("loud".to_string()),
784                    option_type: ArgumentType::Bool,
785                    required: false,
786                    default: Some("false".to_string()),
787                    description: "Loud greeting".to_string(),
788                    choices: vec![],
789                    repeatable: false,
790                    option_parameters: HashMap::new(),
791                }],
792                implementation: "hello_handler".to_string(),
793            }],
794            global_options: vec![],
795        }
796    }
797
798    // ── Construction ──────────────────────────────────────────────────────────
799
800    #[test]
801    fn test_repl_interface_creation() {
802        let registry = create_test_registry();
803        let context = Box::new(TestContext::default());
804        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
805        assert!(repl.is_ok());
806    }
807
808    #[test]
809    fn test_repl_interface_creation_with_config() {
810        let registry = create_test_registry();
811        let context = Box::new(TestContext::default());
812        let config = make_help_config();
813        let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
814        assert!(repl.is_ok());
815    }
816
817    // ── execute_line ──────────────────────────────────────────────────────────
818
819    #[test]
820    fn test_repl_execute_line() {
821        let registry = create_test_registry();
822        let context = Box::new(TestContext::default());
823        let mut repl =
824            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
825        let result = repl.execute_line("test");
826        assert!(result.is_ok());
827        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
828        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
829    }
830
831    #[test]
832    fn test_repl_execute_with_alias() {
833        let registry = create_test_registry();
834        let context = Box::new(TestContext::default());
835        let mut repl =
836            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
837        assert!(repl.execute_line("t").is_ok());
838    }
839
840    #[test]
841    fn test_repl_execute_unknown_command() {
842        let registry = create_test_registry();
843        let context = Box::new(TestContext::default());
844        let mut repl =
845            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
846        let result = repl.execute_line("unknown");
847        assert!(result.is_err());
848        match result.unwrap_err() {
849            DynamicCliError::Parse(_) => {}
850            other => panic!("Expected Parse error, got: {:?}", other),
851        }
852    }
853
854    #[test]
855    fn test_repl_empty_line() {
856        let registry = create_test_registry();
857        let context = Box::new(TestContext::default());
858        let mut repl =
859            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
860        assert!(repl.execute_line("").is_err());
861    }
862
863    #[test]
864    fn test_repl_command_with_args() {
865        let mut registry = CommandRegistry::new();
866        let cmd_def = CommandDefinition {
867            name: "greet".to_string(),
868            aliases: vec![],
869            description: "Greet someone".to_string(),
870            required: false,
871            arguments: vec![ArgumentDefinition {
872                name: "name".to_string(),
873                arg_type: ArgumentType::String,
874                required: true,
875                description: "Name".to_string(),
876                validation: vec![],
877                secure: false,
878            }],
879            options: vec![],
880            implementation: "greet_handler".to_string(),
881        };
882
883        struct GreetHandler;
884        impl crate::executor::CommandHandler for GreetHandler {
885            fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
886                assert_eq!(args.get_scalar("name"), Some("Alice"));
887                Ok(())
888            }
889        }
890
891        registry
892            .register_sync(cmd_def, Box::new(GreetHandler))
893            .unwrap();
894        let context = Box::new(TestContext::default());
895        let mut repl =
896            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
897        assert!(repl.execute_line("greet Alice").is_ok());
898    }
899
900    // ── History path ──────────────────────────────────────────────────────────
901
902    #[test]
903    fn test_repl_history_path() {
904        let path = ReplInterface::get_history_path("myapp");
905        if let Some(p) = path {
906            let path_str = p.to_str().unwrap();
907            assert!(path_str.contains("myapp"), "path should contain app name");
908            assert!(
909                path_str.ends_with("history"),
910                "path should end with 'history', got: {}",
911                path_str
912            );
913        }
914    }
915
916    // ── Help interception ─────────────────────────────────────────────────────
917
918    #[test]
919    fn test_try_handle_help_without_formatter_returns_none() {
920        let registry = create_test_registry();
921        let context = Box::new(TestContext::default());
922        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
923        assert!(repl.try_handle_help("--help").is_none());
924        assert!(repl.try_handle_help("-h").is_none());
925    }
926
927    #[test]
928    fn test_try_handle_help_global() {
929        use crate::help::DefaultHelpFormatter;
930        colored::control::set_override(false);
931        let registry = create_test_registry();
932        let context = Box::new(TestContext::default());
933        let config = make_help_config();
934        let repl = ReplInterface::new(
935            registry,
936            context,
937            "test".to_string(),
938            Some(config),
939            Some(Box::new(DefaultHelpFormatter::new())),
940        )
941        .unwrap();
942        let out = repl.try_handle_help("--help");
943        assert!(out.is_some());
944        let out = out.unwrap();
945        assert!(out.contains("testapp"));
946        assert!(out.contains("hello"));
947    }
948
949    #[test]
950    fn test_try_handle_help_short_flag() {
951        use crate::help::DefaultHelpFormatter;
952        colored::control::set_override(false);
953        let registry = create_test_registry();
954        let context = Box::new(TestContext::default());
955        let config = make_help_config();
956        let repl = ReplInterface::new(
957            registry,
958            context,
959            "test".to_string(),
960            Some(config),
961            Some(Box::new(DefaultHelpFormatter::new())),
962        )
963        .unwrap();
964        let out = repl.try_handle_help("-h");
965        assert!(out.is_some());
966        assert!(out.unwrap().contains("testapp"));
967    }
968
969    #[test]
970    fn test_try_handle_help_with_command_prefix() {
971        use crate::help::DefaultHelpFormatter;
972        colored::control::set_override(false);
973        let registry = create_test_registry();
974        let context = Box::new(TestContext::default());
975        let config = make_help_config();
976        let repl = ReplInterface::new(
977            registry,
978            context,
979            "test".to_string(),
980            Some(config),
981            Some(Box::new(DefaultHelpFormatter::new())),
982        )
983        .unwrap();
984        let out = repl.try_handle_help("--help hello");
985        assert!(out.is_some());
986        assert!(out.unwrap().contains("hello"));
987        let out2 = repl.try_handle_help("-h hello");
988        assert!(out2.is_some());
989    }
990
991    #[test]
992    fn test_try_handle_help_command_suffix() {
993        use crate::help::DefaultHelpFormatter;
994        colored::control::set_override(false);
995        let registry = create_test_registry();
996        let context = Box::new(TestContext::default());
997        let config = make_help_config();
998        let repl = ReplInterface::new(
999            registry,
1000            context,
1001            "test".to_string(),
1002            Some(config),
1003            Some(Box::new(DefaultHelpFormatter::new())),
1004        )
1005        .unwrap();
1006        let out = repl.try_handle_help("hello --help");
1007        assert!(out.is_some());
1008        assert!(out.unwrap().contains("hello"));
1009        let out2 = repl.try_handle_help("hello -h");
1010        assert!(out2.is_some());
1011    }
1012
1013    #[test]
1014    fn test_try_handle_help_alias() {
1015        use crate::help::DefaultHelpFormatter;
1016        colored::control::set_override(false);
1017        let registry = create_test_registry();
1018        let context = Box::new(TestContext::default());
1019        let config = make_help_config();
1020        let repl = ReplInterface::new(
1021            registry,
1022            context,
1023            "test".to_string(),
1024            Some(config),
1025            Some(Box::new(DefaultHelpFormatter::new())),
1026        )
1027        .unwrap();
1028        let out = repl.try_handle_help("--help hi");
1029        assert!(out.is_some());
1030        assert!(out.unwrap().contains("hello"));
1031    }
1032
1033    #[test]
1034    fn test_execute_line_help_intercepted() {
1035        use crate::help::DefaultHelpFormatter;
1036        colored::control::set_override(false);
1037        let registry = create_test_registry();
1038        let context = Box::new(TestContext::default());
1039        let config = make_help_config();
1040        let mut repl = ReplInterface::new(
1041            registry,
1042            context,
1043            "test".to_string(),
1044            Some(config),
1045            Some(Box::new(DefaultHelpFormatter::new())),
1046        )
1047        .unwrap();
1048        assert!(repl.execute_line("--help").is_ok());
1049    }
1050
1051    #[test]
1052    fn test_execute_line_normal_command_still_works_with_formatter() {
1053        use crate::help::DefaultHelpFormatter;
1054        let registry = create_test_registry();
1055        let context = Box::new(TestContext::default());
1056        let config = make_help_config();
1057        let mut repl = ReplInterface::new(
1058            registry,
1059            context,
1060            "test".to_string(),
1061            Some(config),
1062            Some(Box::new(DefaultHelpFormatter::new())),
1063        )
1064        .unwrap();
1065        assert!(repl.execute_line("test").is_ok());
1066    }
1067
1068    // ── Tab completion ────────────────────────────────────────────────────────
1069
1070    #[test]
1071    fn test_completer_commands_empty_input() {
1072        let registry = Arc::new(create_test_registry());
1073        let completer = DcliCompleter::new(Arc::clone(&registry), None);
1074        let history = rustyline::history::DefaultHistory::new();
1075        let ctx = rustyline::Context::new(&history);
1076        let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1077        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1078        assert!(names.contains(&"test"));
1079        assert!(names.contains(&"t"));
1080    }
1081
1082    #[test]
1083    fn test_completer_commands_prefix_filter() {
1084        let registry = Arc::new(create_test_registry());
1085        let completer = DcliCompleter::new(Arc::clone(&registry), None);
1086        let history = rustyline::history::DefaultHistory::new();
1087        let ctx = rustyline::Context::new(&history);
1088        let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1089        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1090        assert!(names.contains(&"test"));
1091        assert!(!names.contains(&"t"));
1092    }
1093
1094    #[test]
1095    fn test_completer_flags_after_command() {
1096        let config = Arc::new(make_help_config());
1097        // Registry with "hello" command
1098        let mut registry = CommandRegistry::new();
1099        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1100        struct DummyHandler;
1101        impl crate::executor::CommandHandler for DummyHandler {
1102            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1103                Ok(())
1104            }
1105        }
1106        registry
1107            .register_sync(cmd_def, Box::new(DummyHandler))
1108            .unwrap();
1109        let registry = Arc::new(registry);
1110
1111        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1112        let history = rustyline::history::DefaultHistory::new();
1113        let ctx = rustyline::Context::new(&history);
1114
1115        // "hello " → should propose --loud and -l
1116        let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1117        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1118        assert!(
1119            names.contains(&"--loud"),
1120            "expected --loud, got {:?}",
1121            names
1122        );
1123        assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1124    }
1125
1126    #[test]
1127    fn test_completer_flags_prefix_filter() {
1128        let config = Arc::new(make_help_config());
1129        let mut registry = CommandRegistry::new();
1130        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1131        struct DummyHandler;
1132        impl crate::executor::CommandHandler for DummyHandler {
1133            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1134                Ok(())
1135            }
1136        }
1137        registry
1138            .register_sync(cmd_def, Box::new(DummyHandler))
1139            .unwrap();
1140        let registry = Arc::new(registry);
1141
1142        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1143        let history = rustyline::history::DefaultHistory::new();
1144        let ctx = rustyline::Context::new(&history);
1145
1146        // "hello --l" → only --loud
1147        let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1148        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1149        assert!(names.contains(&"--loud"));
1150        assert!(!names.contains(&"-l"));
1151    }
1152
1153    #[test]
1154    fn test_completer_no_flags_for_unknown_command() {
1155        let config = Arc::new(make_help_config());
1156        let registry = Arc::new(create_test_registry());
1157        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1158        let history = rustyline::history::DefaultHistory::new();
1159        let ctx = rustyline::Context::new(&history);
1160        // "unknown " → empty (command not in registry)
1161        let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1162        assert!(candidates.is_empty());
1163    }
1164
1165    // ── has_secure_arg ────────────────────────────────────────────────────────
1166
1167    /// Build a registry + config with one command that has a `secure` argument.
1168    fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1169        use crate::config::schema::{CommandsConfig, Metadata};
1170
1171        let cmd_def = CommandDefinition {
1172            name: "login".to_string(),
1173            aliases: vec![],
1174            description: "Login command".to_string(),
1175            required: false,
1176            arguments: vec![
1177                ArgumentDefinition {
1178                    name: "username".to_string(),
1179                    arg_type: ArgumentType::String,
1180                    required: true,
1181                    description: "Username".to_string(),
1182                    validation: vec![],
1183                    secure: false,
1184                },
1185                ArgumentDefinition {
1186                    name: "password".to_string(),
1187                    arg_type: ArgumentType::String,
1188                    required: true,
1189                    description: "Password".to_string(),
1190                    validation: vec![],
1191                    secure: true,
1192                },
1193            ],
1194            options: vec![],
1195            implementation: "login_handler".to_string(),
1196        };
1197
1198        struct LoginHandler;
1199        impl crate::executor::CommandHandler for LoginHandler {
1200            fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1201                Ok(())
1202            }
1203        }
1204
1205        let mut registry = CommandRegistry::new();
1206        registry
1207            .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1208            .unwrap();
1209
1210        let config = CommandsConfig {
1211            metadata: Metadata {
1212                version: "1.0.0".to_string(),
1213                prompt: "testapp".to_string(),
1214                prompt_suffix: " > ".to_string(),
1215            },
1216            commands: vec![cmd_def],
1217            global_options: vec![],
1218        };
1219
1220        (registry, config)
1221    }
1222
1223    #[test]
1224    fn test_has_secure_arg_returns_false_without_config() {
1225        let registry = create_test_registry();
1226        let context = Box::new(TestContext::default());
1227        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1228
1229        let mut args = HashMap::new();
1230        args.insert("password".to_string(), "secret".to_string());
1231
1232        assert!(!repl.has_secure_arg("login", &args));
1233    }
1234
1235    #[test]
1236    fn test_has_secure_arg_returns_false_when_no_secure_field() {
1237        let registry = create_test_registry();
1238        let context = Box::new(TestContext::default());
1239        let config = make_help_config();
1240        let repl =
1241            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1242
1243        let mut args = HashMap::new();
1244        args.insert("loud".to_string(), "true".to_string());
1245
1246        assert!(!repl.has_secure_arg("hello", &args));
1247    }
1248
1249    #[test]
1250    fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1251        let (registry, config) = make_secure_registry_and_config();
1252        let context = Box::new(TestContext::default());
1253        let repl =
1254            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1255
1256        let mut args = HashMap::new();
1257        args.insert("username".to_string(), "alice".to_string());
1258        args.insert("password".to_string(), "secret".to_string());
1259
1260        assert!(repl.has_secure_arg("login", &args));
1261    }
1262
1263    #[test]
1264    fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1265        let (registry, config) = make_secure_registry_and_config();
1266        let context = Box::new(TestContext::default());
1267        let repl =
1268            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1269
1270        // Only username provided — password (secure) absent from parsed args.
1271        let mut args = HashMap::new();
1272        args.insert("username".to_string(), "alice".to_string());
1273
1274        assert!(!repl.has_secure_arg("login", &args));
1275    }
1276
1277    #[test]
1278    fn test_has_secure_arg_returns_false_for_unknown_command() {
1279        let (registry, config) = make_secure_registry_and_config();
1280        let context = Box::new(TestContext::default());
1281        let repl =
1282            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1283
1284        let mut args = HashMap::new();
1285        args.insert("password".to_string(), "secret".to_string());
1286
1287        assert!(!repl.has_secure_arg("nonexistent", &args));
1288    }
1289
1290    // ── Secure argument history filtering ─────────────────────────────────────
1291
1292    #[test]
1293    fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1294        let (registry, config) = make_secure_registry_and_config();
1295        let context = Box::new(TestContext::default());
1296        let mut repl =
1297            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1298
1299        let result = repl.execute_line("login alice secret");
1300        assert!(result.is_ok());
1301
1302        // The line must NOT appear in the in-memory history.
1303        let history = repl.editor.history();
1304        let in_history = (0..history.len()).any(|i| {
1305            history
1306                .get(i, rustyline::history::SearchDirection::Forward)
1307                .ok()
1308                .flatten()
1309                .map(|e| e.entry.as_ref() == "login alice secret")
1310                .unwrap_or(false)
1311        });
1312        assert!(
1313            !in_history,
1314            "secure command line must not be written to history"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_execute_line_without_secure_arg_adds_to_history() {
1320        let registry = create_test_registry();
1321        let context = Box::new(TestContext::default());
1322        let mut repl =
1323            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1324
1325        let result = repl.execute_line("test");
1326        assert!(result.is_ok());
1327
1328        // The line must appear in the in-memory history.
1329        let history = repl.editor.history();
1330        let in_history = (0..history.len()).any(|i| {
1331            history
1332                .get(i, rustyline::history::SearchDirection::Forward)
1333                .ok()
1334                .flatten()
1335                .map(|e| e.entry.as_ref() == "test")
1336                .unwrap_or(false)
1337        });
1338        assert!(
1339            in_history,
1340            "non-secure command line must be written to history"
1341        );
1342    }
1343
1344    // ── :load (#41 scope extension) ─────────────────────────────────────────
1345
1346    fn write_script(content: &str) -> tempfile::NamedTempFile {
1347        use std::io::Write;
1348        let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1349        file.write_all(content.as_bytes())
1350            .expect("failed to write temp script file");
1351        file
1352    }
1353
1354    #[test]
1355    fn test_load_executes_each_line_via_execute_line() {
1356        let registry = create_test_registry();
1357        let context = Box::new(TestContext::default());
1358        let mut repl =
1359            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1360
1361        let script = write_script("test\nt\n");
1362        let line = format!(":load {}", script.path().display());
1363
1364        assert!(repl.execute_line(&line).is_ok());
1365
1366        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1367        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1368    }
1369
1370    #[test]
1371    fn test_load_skips_blank_lines_and_comments() {
1372        let registry = create_test_registry();
1373        let context = Box::new(TestContext::default());
1374        let mut repl =
1375            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1376
1377        let script = write_script("# a comment\n\ntest\n   \n# another\n");
1378        let line = format!(":load {}", script.path().display());
1379
1380        assert!(repl.execute_line(&line).is_ok());
1381
1382        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1383        assert_eq!(ctx.executed_commands, vec!["test"]);
1384    }
1385
1386    #[test]
1387    fn test_load_continues_past_a_failing_line() {
1388        let registry = create_test_registry();
1389        let context = Box::new(TestContext::default());
1390        let mut repl =
1391            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1392
1393        let script = write_script("test\nunknown_command\ntest\n");
1394        let line = format!(":load {}", script.path().display());
1395
1396        // Unlike CliInterface::run_script(Abort), :load never returns Err
1397        // just because a line inside it failed — the failure is displayed
1398        // inline and the load proceeds to the next line.
1399        let result = repl.execute_line(&line);
1400        assert!(result.is_ok());
1401
1402        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1403        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1404    }
1405
1406    #[test]
1407    fn test_load_missing_path_argument_is_an_error() {
1408        let registry = create_test_registry();
1409        let context = Box::new(TestContext::default());
1410        let mut repl =
1411            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1412
1413        let result = repl.execute_line(":load");
1414        assert!(result.is_err());
1415    }
1416
1417    #[test]
1418    fn test_load_missing_file_is_an_error() {
1419        let registry = create_test_registry();
1420        let context = Box::new(TestContext::default());
1421        let mut repl =
1422            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1423
1424        let result = repl.execute_line(":load /nonexistent/path/to/script.txt");
1425        assert!(result.is_err());
1426    }
1427
1428    #[test]
1429    fn test_load_line_itself_is_not_added_to_history() {
1430        let registry = create_test_registry();
1431        let context = Box::new(TestContext::default());
1432        let mut repl =
1433            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1434
1435        let script = write_script("test\n");
1436        let line = format!(":load {}", script.path().display());
1437        assert!(repl.execute_line(&line).is_ok());
1438
1439        let history = repl.editor.history();
1440        let load_in_history = (0..history.len()).any(|i| {
1441            history
1442                .get(i, rustyline::history::SearchDirection::Forward)
1443                .ok()
1444                .flatten()
1445                .map(|e| e.entry.starts_with(":load"))
1446                .unwrap_or(false)
1447        });
1448        assert!(
1449            !load_in_history,
1450            ":load line itself must not be written to history"
1451        );
1452    }
1453}