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    /// Fully rendered prompt string (e.g., "myapp > "), built at construction
280    /// time from the `prompt` argument and `prompt_suffix` below.
281    prompt: String,
282
283    /// Suffix appended after the app-name segment to build both `prompt`
284    /// and the default multi-line continuation prompt (DD-027, #67).
285    ///
286    /// Sourced from `config.metadata.prompt_suffix` when a config is
287    /// supplied, falling back to [`crate::config::schema::default_prompt_suffix`] otherwise —
288    /// stored separately (rather than re-parsed out of `prompt`) so the
289    /// multi-line default stays correct regardless of what the configured
290    /// suffix actually is.
291    prompt_suffix: String,
292
293    /// Rustyline editor with tab-completion support.
294    editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
295
296    /// History file path.
297    history_path: Option<PathBuf>,
298
299    /// Application configuration — shared with the completer and used by the
300    /// help formatter. `None` when no config was supplied at construction.
301    config: Option<Arc<CommandsConfig>>,
302
303    /// Help formatter — renders `--help` output.
304    /// `None` when the application was built without a formatter.
305    help_formatter: Option<Box<dyn HelpFormatter>>,
306
307    /// Base (app-name-equivalent) segment of the continuation prompt shown
308    /// while a `\`-continued command is being accumulated (DD-027, #67).
309    ///
310    /// `None` (the default) means the base falls back to `"..."`. Either
311    /// way, `prompt_suffix` is always appended — this field overrides only
312    /// the segment that replaces the app name, exactly like `prompt`
313    /// (the constructor argument) only ever supplied the app-name segment
314    /// of the main prompt.
315    prompt_multiline: Option<String>,
316}
317
318impl ReplInterface {
319    /// Create a new REPL interface.
320    ///
321    /// All configuration is supplied at construction time so that the
322    /// tab-completion engine and the help formatter share the same data
323    /// without duplication.
324    ///
325    /// # Arguments
326    ///
327    /// * `registry`       — Command registry with all registered commands.
328    /// * `context`        — Execution context passed to handlers.
329    /// * `prompt`         — Prompt prefix (e.g., `"myapp"`). The displayed
330    ///   prompt is `prompt` followed by `config.metadata.prompt_suffix`
331    ///   (e.g., `"myapp > "`) when `config` is supplied, or by
332    ///   [`crate::config::schema::default_prompt_suffix`] (`"myapp > "`) otherwise — the suffix is
333    ///   never hardcoded independently of the config.
334    /// * `config`         — Application configuration for completion, help,
335    ///   and the prompt suffix. Pass `None` to disable all three.
336    /// * `help_formatter` — Help formatter implementation.
337    ///   Pass `None` to use [`DefaultHelpFormatter`] lazily,
338    ///   or supply a custom implementation.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if rustyline initialisation fails (rare).
343    ///
344    /// # Example
345    ///
346    /// ```no_run
347    /// use dynamic_cli::interface::ReplInterface;
348    /// use dynamic_cli::prelude::*;
349    ///
350    /// # #[derive(Default)]
351    /// # struct MyContext;
352    /// # impl ExecutionContext for MyContext {
353    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
354    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
355    /// # }
356    /// # fn main() -> dynamic_cli::Result<()> {
357    /// let registry = CommandRegistry::new();
358    /// let context = Box::new(MyContext::default());
359    ///
360    /// // Without completion or help:
361    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
362    /// # Ok(())
363    /// # }
364    /// ```
365    pub fn new(
366        registry: CommandRegistry,
367        context: Box<dyn ExecutionContext>,
368        prompt: String,
369        config: Option<CommandsConfig>,
370        help_formatter: Option<Box<dyn HelpFormatter>>,
371    ) -> Result<Self> {
372        // Wrap registry in Arc — shared with the completer.
373        let registry = Arc::new(registry);
374
375        // Determine the prompt suffix *before* config is moved into the Arc
376        // below — sourced from the live config when present (config-first,
377        // principle 7), falling back to the same default the schema itself
378        // uses when no config was supplied.
379        let prompt_suffix = config
380            .as_ref()
381            .map(|c| c.metadata.prompt_suffix.clone())
382            .unwrap_or_else(crate::config::schema::default_prompt_suffix);
383
384        // Wrap config in Arc if present — shared with the completer.
385        let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
386
387        // Build the rustyline editor with Tab completion enabled.
388        let rl_config = Config::builder()
389            .completion_type(CompletionType::List)
390            .build();
391
392        let helper = DcliHelper::new(Arc::clone(&registry), config.clone());
393
394        let mut editor = Editor::with_config(rl_config).map_err(|e| {
395            ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
396        })?;
397        editor.set_helper(Some(helper));
398
399        // Determine history file path using the prompt as the app name.
400        let history_path = Self::get_history_path(&prompt);
401
402        let mut repl = Self {
403            registry,
404            context,
405            prompt: format!("{}{}", prompt, prompt_suffix),
406            prompt_suffix,
407            editor,
408            history_path,
409            config,
410            help_formatter,
411            prompt_multiline: None,
412        };
413
414        repl.load_history();
415
416        Ok(repl)
417    }
418
419    /// Override the base segment of the continuation prompt shown while a
420    /// `\`-continued command is being accumulated (DD-027, #67).
421    ///
422    /// `prompt` here plays the same role as the app-name segment passed to
423    /// [`ReplInterface::new`] for the main prompt: only the base is
424    /// supplied, `prompt_suffix` (from the config, or the default) is
425    /// always appended after it by [`effective_prompt_multiline`][Self::effective_prompt_multiline].
426    /// When this setter is never called, the base defaults to `"..."`.
427    ///
428    /// Purely additive — consuming `self` and returning `Self` like every
429    /// other fluent setter in this crate; no existing `ReplInterface::new()`
430    /// call site changes.
431    ///
432    /// # Example
433    ///
434    /// ```no_run
435    /// use dynamic_cli::interface::ReplInterface;
436    /// use dynamic_cli::prelude::*;
437    ///
438    /// # #[derive(Default)]
439    /// # struct MyContext;
440    /// # impl ExecutionContext for MyContext {
441    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
442    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
443    /// # }
444    /// # fn main() -> dynamic_cli::Result<()> {
445    /// let registry = CommandRegistry::new();
446    /// let context = Box::new(MyContext::default());
447    ///
448    /// let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None)?
449    ///     .with_prompt_multiline("_ _ _".to_string());
450    /// # Ok(())
451    /// # }
452    /// ```
453    pub fn with_prompt_multiline(mut self, prompt: String) -> Self {
454        self.prompt_multiline = Some(prompt);
455        self
456    }
457
458    /// The prompt actually displayed while accumulating a `\`-continued
459    /// command (DD-027, #67).
460    ///
461    /// With an override set via
462    /// [`with_prompt_multiline`][Self::with_prompt_multiline], the base is
463    /// used as-is, followed by `prompt_suffix` unchanged (e.g. base
464    /// `"_ _ _"` + suffix `" $ "` → `"_ _ _ $ "`).
465    ///
466    /// Without an override, the default base `"..."` is followed by
467    /// `prompt_suffix` **with its leading whitespace stripped** — matching
468    /// DD-027's literal example (`"rpn > "` → `"...> "`): `"..."` takes the
469    /// app name's *and* its immediately following space's place, rather
470    /// than merely the app name's, so it reads as one continuous mark
471    /// against the separator glyph instead of leaving a stray gap
472    /// (`"... > "`). This asymmetry is deliberate — an explicit override is
473    /// the caller's own spacing choice and is never second-guessed.
474    fn effective_prompt_multiline(&self) -> String {
475        match &self.prompt_multiline {
476            Some(base) => format!("{}{}", base, self.prompt_suffix),
477            None => format!("...{}", self.prompt_suffix.trim_start()),
478        }
479    }
480
481    /// Try to handle a `--help` / `-h` request.
482    ///
483    /// Returns `Some(output)` when the line is a help request and a formatter
484    /// is available, `None` otherwise (normal command processing continues).
485    ///
486    /// Recognized patterns (case-sensitive):
487    ///
488    /// | Input              | Output                    |
489    /// |--------------------|---------------------------|
490    /// | `--help`           | Application-level help    |
491    /// | `-h`               | Application-level help    |
492    /// | `--help <command>` | Per-command help          |
493    /// | `-h <command>`     | Per-command help          |
494    /// | `<command> --help` | Per-command help          |
495    /// | `<command> -h`     | Per-command help          |
496    fn try_handle_help(&self, line: &str) -> Option<String> {
497        let config = self.config.as_deref()?;
498        let formatter = self.help_formatter.as_deref()?;
499
500        let trimmed = line.trim();
501
502        if trimmed == "--help" || trimmed == "-h" {
503            return Some(formatter.format_app(config));
504        }
505
506        if let Some(rest) = trimmed
507            .strip_prefix("--help ")
508            .or_else(|| trimmed.strip_prefix("-h "))
509        {
510            let cmd = rest.trim();
511            if !cmd.is_empty() {
512                return Some(formatter.format_command(config, cmd));
513            }
514        }
515
516        let parts: Vec<&str> = trimmed.split_whitespace().collect();
517        if parts.len() >= 2 {
518            let last = *parts.last().unwrap();
519            if last == "--help" || last == "-h" {
520                return Some(formatter.format_command(config, parts[0]));
521            }
522        }
523
524        None
525    }
526
527    /// Intercept a `:load <path>` line before normal command parsing (#41
528    /// scope extension).
529    ///
530    /// Returns `None` when `line` doesn't start with `:load ` — normal
531    /// dispatch proceeds. Returns `Some(result)` when it does, whether
532    /// the load itself succeeds or fails.
533    ///
534    /// Unlike [`CliInterface::run_script`][crate::interface::CliInterface::run_script],
535    /// there is no error-policy parameter here: a failing line is
536    /// reported inline (via [`display_error`]) and the load always
537    /// continues to the next line, printing a final `succeeded/attempted`
538    /// summary. This matches how the REPL already surfaces errors for
539    /// interactively-typed lines — one at a time, without halting the
540    /// session — rather than the batch abort/continue choice that makes
541    /// sense for a one-shot script run.
542    ///
543    /// Each loaded line is dispatched via [`execute_line`][Self::execute_line]
544    /// itself — the same scalar-only path (DD-024 addendum) as any other
545    /// REPL-typed line, **not**
546    /// [`crate::interface::CliInterface::run_script`]'s typed/repeatable-options
547    /// path. A loaded script is not added to `rustyline` history, and a
548    /// script that `:load`s itself (directly or via another file) will
549    /// recurse until the file handle limit or stack is exhausted — no
550    /// cycle detection is implemented.
551    fn try_handle_load(&mut self, line: &str) -> Option<Result<()>> {
552        let path = line.trim().strip_prefix(":load ").map(str::trim)?;
553
554        if path.is_empty() {
555            return Some(Err(DynamicCliError::Parse(ParseError::InvalidSyntax {
556                details: "`:load` requires a file path".to_string(),
557                hint: Some("Usage: :load <path/to/script.txt>".to_string()),
558            })));
559        }
560
561        Some(self.load_script(path))
562    }
563
564    /// Read `path` and dispatch each non-blank, non-comment (`#`-prefixed)
565    /// line through [`execute_line`][Self::execute_line], continuing past
566    /// any failure. See [`try_handle_load`][Self::try_handle_load] for the
567    /// full behaviour.
568    fn load_script(&mut self, path: &str) -> Result<()> {
569        let content = std::fs::read_to_string(path).map_err(|e| {
570            DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
571                "failed to read script file {}: {}",
572                path,
573                e
574            )))
575        })?;
576
577        let mut attempted = 0usize;
578        let mut succeeded = 0usize;
579
580        for (idx, raw_line) in content.lines().enumerate() {
581            let line_number = idx + 1;
582            let script_line = raw_line.trim();
583
584            if script_line.is_empty() || script_line.starts_with('#') {
585                continue;
586            }
587
588            attempted += 1;
589
590            match self.execute_line(script_line) {
591                Ok(()) => succeeded += 1,
592                Err(e) => {
593                    eprintln!("  :load {} — line {}:", path, line_number);
594                    display_error(&e);
595                }
596            }
597        }
598
599        println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
600        Ok(())
601    }
602
603    /// Check whether a parsed command involves at least one secure argument.
604    ///
605    /// Looks up the command definition in `self.config` (if available) and
606    /// returns `true` when any argument name present in `parsed_args` is
607    /// marked `secure: true` in the YAML schema.
608    fn has_secure_arg(
609        &self,
610        command_name: &str,
611        parsed_args: &std::collections::HashMap<String, String>,
612    ) -> bool {
613        let config = match &self.config {
614            Some(c) => c,
615            None => return false,
616        };
617
618        let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
619            Some(d) => d,
620            None => return false,
621        };
622
623        cmd_def
624            .arguments
625            .iter()
626            .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
627    }
628
629    /// Get the history file path for this application.
630    ///
631    /// Each application gets its own isolated history file under the
632    /// XDG data directory:
633    ///
634    /// - Linux/macOS: `~/.local/share/<app_name>/history`
635    /// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
636    fn get_history_path(app_name: &str) -> Option<PathBuf> {
637        dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
638    }
639
640    /// Load command history from file.
641    fn load_history(&mut self) {
642        if let Some(ref path) = self.history_path {
643            if let Some(parent) = path.parent() {
644                let _ = std::fs::create_dir_all(parent);
645            }
646            let _ = self.editor.load_history(path);
647        }
648    }
649
650    /// Save command history to file.
651    fn save_history(&mut self) {
652        if let Some(ref path) = self.history_path {
653            if let Err(e) = self.editor.save_history(path) {
654                eprintln!("Warning: Failed to save command history: {}", e);
655            }
656        }
657    }
658
659    /// Run the REPL loop.
660    ///
661    /// Enters an interactive loop that:
662    /// 1. Displays the prompt (or the continuation prompt while
663    ///    accumulating a `\`-continued command, DD-027/#69)
664    /// 2. Reads user input (with tab completion)
665    /// 3. Parses and executes the command
666    /// 4. Displays results or errors
667    /// 5. Repeats until the user exits
668    ///
669    /// # Multi-line option accumulation (DD-027, #69)
670    ///
671    /// A line ending in a trailing `\` is not dispatched: the marker is
672    /// stripped and the fragment is buffered, the prompt switches to
673    /// [`effective_prompt_multiline`][Self::effective_prompt_multiline],
674    /// and another line is read. The first line that does *not* end in `\`
675    /// completes the buffer — every fragment plus this final one are
676    /// joined with a single space and dispatched through
677    /// [`execute_line`][Self::execute_line] exactly once, with the normal
678    /// prompt restored. Only this fully reconstructed line (no `\`
679    /// markers) can ever reach REPL history — `execute_line()` never sees
680    /// a raw partial fragment.
681    ///
682    /// A `Ctrl+C` while accumulating discards the buffer and returns to
683    /// the normal prompt, mirroring the familiar shell convention of
684    /// abandoning a continued line on interrupt.
685    ///
686    /// This mechanism is entirely local to this loop: [`Self::execute_line`],
687    /// [`ReplParser`], and `CliParser` are untouched, and non-interactive
688    /// paths (`run_script()`, `:load`) never go through it — see DD-027's
689    /// abort-by-construction rule for contexts without a live operator.
690    ///
691    /// # Returns
692    ///
693    /// - `Ok(())` when the user exits normally (via `exit` or `quit`)
694    /// - `Err(_)` on critical errors (I/O failures, etc.)
695    ///
696    /// # Example
697    ///
698    /// ```no_run
699    /// use dynamic_cli::interface::ReplInterface;
700    /// use dynamic_cli::prelude::*;
701    ///
702    /// # #[derive(Default)]
703    /// # struct MyContext;
704    /// # impl ExecutionContext for MyContext {
705    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
706    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
707    /// # }
708    /// # fn main() -> dynamic_cli::Result<()> {
709    /// let registry = CommandRegistry::new();
710    /// let context = Box::new(MyContext::default());
711    ///
712    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
713    /// repl.run()?;
714    /// # Ok(())
715    /// # }
716    /// ```
717    pub fn run(mut self) -> Result<()> {
718        let mut continuation_buffer: Vec<String> = Vec::new();
719
720        loop {
721            let prompt = if continuation_buffer.is_empty() {
722                self.prompt.clone()
723            } else {
724                self.effective_prompt_multiline()
725            };
726
727            let readline = self.editor.readline(&prompt);
728
729            match readline {
730                Ok(raw_line) => {
731                    let line = match Self::accumulate_line(&mut continuation_buffer, &raw_line) {
732                        Some(line) => line,
733                        // Still accumulating: read another line under the
734                        // continuation prompt instead of dispatching.
735                        None => continue,
736                    };
737
738                    if line.is_empty() {
739                        continue;
740                    }
741
742                    if line == "exit" || line == "quit" {
743                        println!("Goodbye!");
744                        break;
745                    }
746
747                    // Parse and execute command.
748                    // History is written inside execute_line(), after successful
749                    // parsing and only when no secure argument is present.
750                    match self.execute_line(&line) {
751                        Ok(()) => {}
752                        Err(e) => {
753                            display_error(&e);
754                        }
755                    }
756                }
757
758                Err(ReadlineError::Interrupted) => {
759                    println!("^C");
760                    // A continued command doesn't survive an interrupt —
761                    // same as a shell discarding a `\`-continued line.
762                    continuation_buffer.clear();
763                    continue;
764                }
765
766                Err(ReadlineError::Eof) => {
767                    println!("exit");
768                    break;
769                }
770
771                Err(err) => {
772                    eprintln!("Error reading input: {}", err);
773                    break;
774                }
775            }
776        }
777
778        self.save_history();
779        Ok(())
780    }
781
782    /// Feed one raw input line into the `\`-continuation buffer (DD-027,
783    /// #69).
784    ///
785    /// Pure and I/O-free by design: [`run`][Self::run] is the only caller,
786    /// but keeping this separate from the `rustyline`-driven loop makes the
787    /// accumulation semantics themselves unit-testable without an
788    /// interactive terminal.
789    ///
790    /// # Returns
791    ///
792    /// - `None` — `raw_line` ended in `\`; the marker was stripped and the
793    ///   fragment pushed onto `buffer`. The caller should read another line
794    ///   under the continuation prompt.
795    /// - `Some(line)` — `raw_line` did not end in `\`, completing the
796    ///   buffer. `buffer` is drained and every fragment (in order) plus
797    ///   `raw_line` are joined with a single space; this is the
798    ///   reconstructed line the caller should dispatch. When `buffer` was
799    ///   already empty, `line` is simply `raw_line.trim()` — the ordinary,
800    ///   non-continued case.
801    fn accumulate_line(buffer: &mut Vec<String>, raw_line: &str) -> Option<String> {
802        let trimmed = raw_line.trim();
803
804        if let Some(fragment) = trimmed.strip_suffix('\\') {
805            buffer.push(fragment.trim_end().to_string());
806            return None;
807        }
808
809        if buffer.is_empty() {
810            return Some(trimmed.to_string());
811        }
812
813        buffer.push(trimmed.to_string());
814        Some(std::mem::take(buffer).join(" "))
815    }
816
817    /// Execute a single line of input.
818    ///
819    /// Parses the line and executes the corresponding command.
820    /// `--help` and `-h` requests are intercepted before dispatch.
821    ///
822    /// History is written here — after successful parsing — so that:
823    /// - Failed or invalid commands are never persisted.
824    /// - Lines containing a `secure: true` argument are silently omitted.
825    fn execute_line(&mut self, line: &str) -> Result<()> {
826        if let Some(output) = self.try_handle_help(line) {
827            print!("{}", output);
828            return Ok(());
829        }
830
831        if let Some(result) = self.try_handle_load(line) {
832            return result;
833        }
834
835        let parser = ReplParser::new(&self.registry);
836        let parsed = parser.parse_line(line)?;
837
838        // Write to history only on successful parse and when no secure
839        // argument is present in the parsed command.
840        if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
841            let _ = self.editor.add_history_entry(line);
842        }
843
844        // Sync tried first (unchanged behaviour), then async via `block_on`
845        // (DD-022). Safe here because the REPL loop is strictly sequential
846        // — one command finishes (readline blocks regardless) before the
847        // next line is even read, so there is no other async task waiting
848        // that `block_on` could starve.
849        //
850        // Wrapped via `from_scalars`: `ReplParser::parse_line` still
851        // produces a plain `HashMap<String, String>` (DD-024 addendum —
852        // repeatable options have no interactive REPL-typing use case, see
853        // `DESIGN_DECISIONS.md`). Every handler receives `&ParsedArgs`
854        // regardless of dispatch path (#39); the REPL path just never
855        // populates `ParsedValue::Repeated` entries.
856        let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
857        if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
858            handler.execute(&mut *self.context, &parsed_args)?;
859        } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
860            futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
861        } else {
862            return Err(DynamicCliError::Execution(
863                ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
864            ));
865        }
866
867        Ok(())
868    }
869}
870
871impl Drop for ReplInterface {
872    fn drop(&mut self) {
873        self.save_history();
874    }
875}
876
877// ============================================================================
878// Tests
879// ============================================================================
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::config::schema::{
885        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
886    };
887    use rustyline::history::History;
888    use std::collections::HashMap;
889
890    #[derive(Default)]
891    struct TestContext {
892        executed_commands: Vec<String>,
893    }
894
895    impl ExecutionContext for TestContext {
896        fn as_any(&self) -> &dyn std::any::Any {
897            self
898        }
899        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
900            self
901        }
902    }
903
904    struct TestHandler {
905        name: String,
906    }
907
908    impl crate::executor::CommandHandler for TestHandler {
909        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
910            let ctx = crate::context::downcast_mut::<TestContext>(context)
911                .expect("Failed to downcast context");
912            ctx.executed_commands.push(self.name.clone());
913            Ok(())
914        }
915    }
916
917    fn create_test_registry() -> CommandRegistry {
918        let mut registry = CommandRegistry::new();
919        let cmd_def = CommandDefinition {
920            name: "test".to_string(),
921            aliases: vec!["t".to_string()],
922            description: "Test command".to_string(),
923            required: false,
924            arguments: vec![],
925            options: vec![],
926            implementation: "test_handler".to_string(),
927            continue_on_failure: false,
928            requires_success: false,
929        };
930        registry
931            .register_sync(
932                cmd_def,
933                Box::new(TestHandler {
934                    name: "test".to_string(),
935                }),
936            )
937            .unwrap();
938        registry
939    }
940
941    fn make_help_config() -> CommandsConfig {
942        use crate::config::schema::{CommandsConfig, Metadata};
943        CommandsConfig {
944            metadata: Metadata {
945                version: "1.0.0".to_string(),
946                prompt: "testapp".to_string(),
947                prompt_suffix: " > ".to_string(),
948            },
949            commands: vec![CommandDefinition {
950                name: "hello".to_string(),
951                aliases: vec!["hi".to_string()],
952                description: "Say hello".to_string(),
953                required: false,
954                arguments: vec![],
955                options: vec![OptionDefinition {
956                    name: "loud".to_string(),
957                    short: Some("l".to_string()),
958                    long: Some("loud".to_string()),
959                    option_type: ArgumentType::Bool,
960                    required: false,
961                    default: Some("false".to_string()),
962                    description: "Loud greeting".to_string(),
963                    choices: vec![],
964                    repeatable: false,
965                    option_parameters: HashMap::new(),
966                }],
967                implementation: "hello_handler".to_string(),
968                continue_on_failure: false,
969                requires_success: false,
970            }],
971            global_options: vec![],
972        }
973    }
974
975    // ── Construction ──────────────────────────────────────────────────────────
976
977    #[test]
978    fn test_repl_interface_creation() {
979        let registry = create_test_registry();
980        let context = Box::new(TestContext::default());
981        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
982        assert!(repl.is_ok());
983    }
984
985    #[test]
986    fn test_repl_interface_creation_with_config() {
987        let registry = create_test_registry();
988        let context = Box::new(TestContext::default());
989        let config = make_help_config();
990        let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
991        assert!(repl.is_ok());
992    }
993
994    // ── execute_line ──────────────────────────────────────────────────────────
995
996    #[test]
997    fn test_repl_execute_line() {
998        let registry = create_test_registry();
999        let context = Box::new(TestContext::default());
1000        let mut repl =
1001            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1002        let result = repl.execute_line("test");
1003        assert!(result.is_ok());
1004        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1005        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
1006    }
1007
1008    #[test]
1009    fn test_repl_execute_with_alias() {
1010        let registry = create_test_registry();
1011        let context = Box::new(TestContext::default());
1012        let mut repl =
1013            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1014        assert!(repl.execute_line("t").is_ok());
1015    }
1016
1017    #[test]
1018    fn test_repl_execute_unknown_command() {
1019        let registry = create_test_registry();
1020        let context = Box::new(TestContext::default());
1021        let mut repl =
1022            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1023        let result = repl.execute_line("unknown");
1024        assert!(result.is_err());
1025        match result.unwrap_err() {
1026            DynamicCliError::Parse(_) => {}
1027            other => panic!("Expected Parse error, got: {:?}", other),
1028        }
1029    }
1030
1031    #[test]
1032    fn test_repl_empty_line() {
1033        let registry = create_test_registry();
1034        let context = Box::new(TestContext::default());
1035        let mut repl =
1036            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1037        assert!(repl.execute_line("").is_err());
1038    }
1039
1040    #[test]
1041    fn test_repl_command_with_args() {
1042        let mut registry = CommandRegistry::new();
1043        let cmd_def = CommandDefinition {
1044            name: "greet".to_string(),
1045            aliases: vec![],
1046            description: "Greet someone".to_string(),
1047            required: false,
1048            arguments: vec![ArgumentDefinition {
1049                name: "name".to_string(),
1050                arg_type: ArgumentType::String,
1051                required: true,
1052                description: "Name".to_string(),
1053                validation: vec![],
1054                secure: false,
1055            }],
1056            options: vec![],
1057            implementation: "greet_handler".to_string(),
1058            continue_on_failure: false,
1059            requires_success: false,
1060        };
1061
1062        struct GreetHandler;
1063        impl crate::executor::CommandHandler for GreetHandler {
1064            fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
1065                assert_eq!(args.get_scalar("name"), Some("Alice"));
1066                Ok(())
1067            }
1068        }
1069
1070        registry
1071            .register_sync(cmd_def, Box::new(GreetHandler))
1072            .unwrap();
1073        let context = Box::new(TestContext::default());
1074        let mut repl =
1075            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1076        assert!(repl.execute_line("greet Alice").is_ok());
1077    }
1078
1079    // ── History path ──────────────────────────────────────────────────────────
1080
1081    #[test]
1082    fn test_repl_history_path() {
1083        let path = ReplInterface::get_history_path("myapp");
1084        if let Some(p) = path {
1085            let path_str = p.to_str().unwrap();
1086            assert!(path_str.contains("myapp"), "path should contain app name");
1087            assert!(
1088                path_str.ends_with("history"),
1089                "path should end with 'history', got: {}",
1090                path_str
1091            );
1092        }
1093    }
1094
1095    // ── Help interception ─────────────────────────────────────────────────────
1096
1097    #[test]
1098    fn test_try_handle_help_without_formatter_returns_none() {
1099        let registry = create_test_registry();
1100        let context = Box::new(TestContext::default());
1101        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1102        assert!(repl.try_handle_help("--help").is_none());
1103        assert!(repl.try_handle_help("-h").is_none());
1104    }
1105
1106    #[test]
1107    fn test_try_handle_help_global() {
1108        use crate::help::DefaultHelpFormatter;
1109        colored::control::set_override(false);
1110        let registry = create_test_registry();
1111        let context = Box::new(TestContext::default());
1112        let config = make_help_config();
1113        let repl = ReplInterface::new(
1114            registry,
1115            context,
1116            "test".to_string(),
1117            Some(config),
1118            Some(Box::new(DefaultHelpFormatter::new())),
1119        )
1120        .unwrap();
1121        let out = repl.try_handle_help("--help");
1122        assert!(out.is_some());
1123        let out = out.unwrap();
1124        assert!(out.contains("testapp"));
1125        assert!(out.contains("hello"));
1126    }
1127
1128    #[test]
1129    fn test_try_handle_help_short_flag() {
1130        use crate::help::DefaultHelpFormatter;
1131        colored::control::set_override(false);
1132        let registry = create_test_registry();
1133        let context = Box::new(TestContext::default());
1134        let config = make_help_config();
1135        let repl = ReplInterface::new(
1136            registry,
1137            context,
1138            "test".to_string(),
1139            Some(config),
1140            Some(Box::new(DefaultHelpFormatter::new())),
1141        )
1142        .unwrap();
1143        let out = repl.try_handle_help("-h");
1144        assert!(out.is_some());
1145        assert!(out.unwrap().contains("testapp"));
1146    }
1147
1148    #[test]
1149    fn test_try_handle_help_with_command_prefix() {
1150        use crate::help::DefaultHelpFormatter;
1151        colored::control::set_override(false);
1152        let registry = create_test_registry();
1153        let context = Box::new(TestContext::default());
1154        let config = make_help_config();
1155        let repl = ReplInterface::new(
1156            registry,
1157            context,
1158            "test".to_string(),
1159            Some(config),
1160            Some(Box::new(DefaultHelpFormatter::new())),
1161        )
1162        .unwrap();
1163        let out = repl.try_handle_help("--help hello");
1164        assert!(out.is_some());
1165        assert!(out.unwrap().contains("hello"));
1166        let out2 = repl.try_handle_help("-h hello");
1167        assert!(out2.is_some());
1168    }
1169
1170    #[test]
1171    fn test_try_handle_help_command_suffix() {
1172        use crate::help::DefaultHelpFormatter;
1173        colored::control::set_override(false);
1174        let registry = create_test_registry();
1175        let context = Box::new(TestContext::default());
1176        let config = make_help_config();
1177        let repl = ReplInterface::new(
1178            registry,
1179            context,
1180            "test".to_string(),
1181            Some(config),
1182            Some(Box::new(DefaultHelpFormatter::new())),
1183        )
1184        .unwrap();
1185        let out = repl.try_handle_help("hello --help");
1186        assert!(out.is_some());
1187        assert!(out.unwrap().contains("hello"));
1188        let out2 = repl.try_handle_help("hello -h");
1189        assert!(out2.is_some());
1190    }
1191
1192    #[test]
1193    fn test_try_handle_help_alias() {
1194        use crate::help::DefaultHelpFormatter;
1195        colored::control::set_override(false);
1196        let registry = create_test_registry();
1197        let context = Box::new(TestContext::default());
1198        let config = make_help_config();
1199        let repl = ReplInterface::new(
1200            registry,
1201            context,
1202            "test".to_string(),
1203            Some(config),
1204            Some(Box::new(DefaultHelpFormatter::new())),
1205        )
1206        .unwrap();
1207        let out = repl.try_handle_help("--help hi");
1208        assert!(out.is_some());
1209        assert!(out.unwrap().contains("hello"));
1210    }
1211
1212    #[test]
1213    fn test_execute_line_help_intercepted() {
1214        use crate::help::DefaultHelpFormatter;
1215        colored::control::set_override(false);
1216        let registry = create_test_registry();
1217        let context = Box::new(TestContext::default());
1218        let config = make_help_config();
1219        let mut repl = ReplInterface::new(
1220            registry,
1221            context,
1222            "test".to_string(),
1223            Some(config),
1224            Some(Box::new(DefaultHelpFormatter::new())),
1225        )
1226        .unwrap();
1227        assert!(repl.execute_line("--help").is_ok());
1228    }
1229
1230    #[test]
1231    fn test_execute_line_normal_command_still_works_with_formatter() {
1232        use crate::help::DefaultHelpFormatter;
1233        let registry = create_test_registry();
1234        let context = Box::new(TestContext::default());
1235        let config = make_help_config();
1236        let mut repl = ReplInterface::new(
1237            registry,
1238            context,
1239            "test".to_string(),
1240            Some(config),
1241            Some(Box::new(DefaultHelpFormatter::new())),
1242        )
1243        .unwrap();
1244        assert!(repl.execute_line("test").is_ok());
1245    }
1246
1247    // ── Tab completion ────────────────────────────────────────────────────────
1248
1249    #[test]
1250    fn test_completer_commands_empty_input() {
1251        let registry = Arc::new(create_test_registry());
1252        let completer = DcliCompleter::new(Arc::clone(&registry), None);
1253        let history = rustyline::history::DefaultHistory::new();
1254        let ctx = rustyline::Context::new(&history);
1255        let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1256        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1257        assert!(names.contains(&"test"));
1258        assert!(names.contains(&"t"));
1259    }
1260
1261    #[test]
1262    fn test_completer_commands_prefix_filter() {
1263        let registry = Arc::new(create_test_registry());
1264        let completer = DcliCompleter::new(Arc::clone(&registry), None);
1265        let history = rustyline::history::DefaultHistory::new();
1266        let ctx = rustyline::Context::new(&history);
1267        let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1268        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1269        assert!(names.contains(&"test"));
1270        assert!(!names.contains(&"t"));
1271    }
1272
1273    #[test]
1274    fn test_completer_flags_after_command() {
1275        let config = Arc::new(make_help_config());
1276        // Registry with "hello" command
1277        let mut registry = CommandRegistry::new();
1278        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1279        struct DummyHandler;
1280        impl crate::executor::CommandHandler for DummyHandler {
1281            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1282                Ok(())
1283            }
1284        }
1285        registry
1286            .register_sync(cmd_def, Box::new(DummyHandler))
1287            .unwrap();
1288        let registry = Arc::new(registry);
1289
1290        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1291        let history = rustyline::history::DefaultHistory::new();
1292        let ctx = rustyline::Context::new(&history);
1293
1294        // "hello " → should propose --loud and -l
1295        let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1296        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1297        assert!(
1298            names.contains(&"--loud"),
1299            "expected --loud, got {:?}",
1300            names
1301        );
1302        assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1303    }
1304
1305    #[test]
1306    fn test_completer_flags_prefix_filter() {
1307        let config = Arc::new(make_help_config());
1308        let mut registry = CommandRegistry::new();
1309        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1310        struct DummyHandler;
1311        impl crate::executor::CommandHandler for DummyHandler {
1312            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1313                Ok(())
1314            }
1315        }
1316        registry
1317            .register_sync(cmd_def, Box::new(DummyHandler))
1318            .unwrap();
1319        let registry = Arc::new(registry);
1320
1321        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1322        let history = rustyline::history::DefaultHistory::new();
1323        let ctx = rustyline::Context::new(&history);
1324
1325        // "hello --l" → only --loud
1326        let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1327        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1328        assert!(names.contains(&"--loud"));
1329        assert!(!names.contains(&"-l"));
1330    }
1331
1332    #[test]
1333    fn test_completer_no_flags_for_unknown_command() {
1334        let config = Arc::new(make_help_config());
1335        let registry = Arc::new(create_test_registry());
1336        let completer = DcliCompleter::new(Arc::clone(&registry), Some(Arc::clone(&config)));
1337        let history = rustyline::history::DefaultHistory::new();
1338        let ctx = rustyline::Context::new(&history);
1339        // "unknown " → empty (command not in registry)
1340        let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1341        assert!(candidates.is_empty());
1342    }
1343
1344    // ── has_secure_arg ────────────────────────────────────────────────────────
1345
1346    /// Build a registry + config with one command that has a `secure` argument.
1347    fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1348        use crate::config::schema::{CommandsConfig, Metadata};
1349
1350        let cmd_def = CommandDefinition {
1351            name: "login".to_string(),
1352            aliases: vec![],
1353            description: "Login command".to_string(),
1354            required: false,
1355            arguments: vec![
1356                ArgumentDefinition {
1357                    name: "username".to_string(),
1358                    arg_type: ArgumentType::String,
1359                    required: true,
1360                    description: "Username".to_string(),
1361                    validation: vec![],
1362                    secure: false,
1363                },
1364                ArgumentDefinition {
1365                    name: "password".to_string(),
1366                    arg_type: ArgumentType::String,
1367                    required: true,
1368                    description: "Password".to_string(),
1369                    validation: vec![],
1370                    secure: true,
1371                },
1372            ],
1373            options: vec![],
1374            implementation: "login_handler".to_string(),
1375            continue_on_failure: false,
1376            requires_success: false,
1377        };
1378
1379        struct LoginHandler;
1380        impl crate::executor::CommandHandler for LoginHandler {
1381            fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1382                Ok(())
1383            }
1384        }
1385
1386        let mut registry = CommandRegistry::new();
1387        registry
1388            .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1389            .unwrap();
1390
1391        let config = CommandsConfig {
1392            metadata: Metadata {
1393                version: "1.0.0".to_string(),
1394                prompt: "testapp".to_string(),
1395                prompt_suffix: " > ".to_string(),
1396            },
1397            commands: vec![cmd_def],
1398            global_options: vec![],
1399        };
1400
1401        (registry, config)
1402    }
1403
1404    #[test]
1405    fn test_has_secure_arg_returns_false_without_config() {
1406        let registry = create_test_registry();
1407        let context = Box::new(TestContext::default());
1408        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1409
1410        let mut args = HashMap::new();
1411        args.insert("password".to_string(), "secret".to_string());
1412
1413        assert!(!repl.has_secure_arg("login", &args));
1414    }
1415
1416    #[test]
1417    fn test_has_secure_arg_returns_false_when_no_secure_field() {
1418        let registry = create_test_registry();
1419        let context = Box::new(TestContext::default());
1420        let config = make_help_config();
1421        let repl =
1422            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1423
1424        let mut args = HashMap::new();
1425        args.insert("loud".to_string(), "true".to_string());
1426
1427        assert!(!repl.has_secure_arg("hello", &args));
1428    }
1429
1430    #[test]
1431    fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1432        let (registry, config) = make_secure_registry_and_config();
1433        let context = Box::new(TestContext::default());
1434        let repl =
1435            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1436
1437        let mut args = HashMap::new();
1438        args.insert("username".to_string(), "alice".to_string());
1439        args.insert("password".to_string(), "secret".to_string());
1440
1441        assert!(repl.has_secure_arg("login", &args));
1442    }
1443
1444    #[test]
1445    fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1446        let (registry, config) = make_secure_registry_and_config();
1447        let context = Box::new(TestContext::default());
1448        let repl =
1449            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1450
1451        // Only username provided — password (secure) absent from parsed args.
1452        let mut args = HashMap::new();
1453        args.insert("username".to_string(), "alice".to_string());
1454
1455        assert!(!repl.has_secure_arg("login", &args));
1456    }
1457
1458    #[test]
1459    fn test_has_secure_arg_returns_false_for_unknown_command() {
1460        let (registry, config) = make_secure_registry_and_config();
1461        let context = Box::new(TestContext::default());
1462        let repl =
1463            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1464
1465        let mut args = HashMap::new();
1466        args.insert("password".to_string(), "secret".to_string());
1467
1468        assert!(!repl.has_secure_arg("nonexistent", &args));
1469    }
1470
1471    // ── Secure argument history filtering ─────────────────────────────────────
1472
1473    #[test]
1474    fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1475        let (registry, config) = make_secure_registry_and_config();
1476        let context = Box::new(TestContext::default());
1477        let mut repl =
1478            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1479
1480        let result = repl.execute_line("login alice secret");
1481        assert!(result.is_ok());
1482
1483        // The line must NOT appear in the in-memory history.
1484        let history = repl.editor.history();
1485        let in_history = (0..history.len()).any(|i| {
1486            history
1487                .get(i, rustyline::history::SearchDirection::Forward)
1488                .ok()
1489                .flatten()
1490                .map(|e| e.entry.as_ref() == "login alice secret")
1491                .unwrap_or(false)
1492        });
1493        assert!(
1494            !in_history,
1495            "secure command line must not be written to history"
1496        );
1497    }
1498
1499    #[test]
1500    fn test_execute_line_without_secure_arg_adds_to_history() {
1501        let registry = create_test_registry();
1502        let context = Box::new(TestContext::default());
1503        let mut repl =
1504            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1505
1506        let result = repl.execute_line("test");
1507        assert!(result.is_ok());
1508
1509        // The line must appear in the in-memory history.
1510        let history = repl.editor.history();
1511        let in_history = (0..history.len()).any(|i| {
1512            history
1513                .get(i, rustyline::history::SearchDirection::Forward)
1514                .ok()
1515                .flatten()
1516                .map(|e| e.entry.as_ref() == "test")
1517                .unwrap_or(false)
1518        });
1519        assert!(
1520            in_history,
1521            "non-secure command line must be written to history"
1522        );
1523    }
1524
1525    // ── :load (#41 scope extension) ─────────────────────────────────────────
1526
1527    fn write_script(content: &str) -> tempfile::NamedTempFile {
1528        use std::io::Write;
1529        let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1530        file.write_all(content.as_bytes())
1531            .expect("failed to write temp script file");
1532        file
1533    }
1534
1535    #[test]
1536    fn test_load_executes_each_line_via_execute_line() {
1537        let registry = create_test_registry();
1538        let context = Box::new(TestContext::default());
1539        let mut repl =
1540            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1541
1542        let script = write_script("test\nt\n");
1543        let line = format!(":load {}", script.path().display());
1544
1545        assert!(repl.execute_line(&line).is_ok());
1546
1547        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1548        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1549    }
1550
1551    #[test]
1552    fn test_load_skips_blank_lines_and_comments() {
1553        let registry = create_test_registry();
1554        let context = Box::new(TestContext::default());
1555        let mut repl =
1556            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1557
1558        let script = write_script("# a comment\n\ntest\n   \n# another\n");
1559        let line = format!(":load {}", script.path().display());
1560
1561        assert!(repl.execute_line(&line).is_ok());
1562
1563        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1564        assert_eq!(ctx.executed_commands, vec!["test"]);
1565    }
1566
1567    #[test]
1568    fn test_load_continues_past_a_failing_line() {
1569        let registry = create_test_registry();
1570        let context = Box::new(TestContext::default());
1571        let mut repl =
1572            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1573
1574        let script = write_script("test\nunknown_command\ntest\n");
1575        let line = format!(":load {}", script.path().display());
1576
1577        // Unlike CliInterface::run_script(Abort), :load never returns Err
1578        // just because a line inside it failed — the failure is displayed
1579        // inline and the load proceeds to the next line.
1580        let result = repl.execute_line(&line);
1581        assert!(result.is_ok());
1582
1583        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1584        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1585    }
1586
1587    #[test]
1588    fn test_load_missing_path_argument_is_an_error() {
1589        let registry = create_test_registry();
1590        let context = Box::new(TestContext::default());
1591        let mut repl =
1592            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1593
1594        let result = repl.execute_line(":load");
1595        assert!(result.is_err());
1596    }
1597
1598    #[test]
1599    fn test_load_missing_file_is_an_error() {
1600        let registry = create_test_registry();
1601        let context = Box::new(TestContext::default());
1602        let mut repl =
1603            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1604
1605        let result = repl.execute_line(":load /nonexistent/path/to/script.txt");
1606        assert!(result.is_err());
1607    }
1608
1609    #[test]
1610    fn test_load_line_itself_is_not_added_to_history() {
1611        let registry = create_test_registry();
1612        let context = Box::new(TestContext::default());
1613        let mut repl =
1614            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1615
1616        let script = write_script("test\n");
1617        let line = format!(":load {}", script.path().display());
1618        assert!(repl.execute_line(&line).is_ok());
1619
1620        let history = repl.editor.history();
1621        let load_in_history = (0..history.len()).any(|i| {
1622            history
1623                .get(i, rustyline::history::SearchDirection::Forward)
1624                .ok()
1625                .flatten()
1626                .map(|e| e.entry.starts_with(":load"))
1627                .unwrap_or(false)
1628        });
1629        assert!(
1630            !load_in_history,
1631            ":load line itself must not be written to history"
1632        );
1633    }
1634
1635    // ========================================================================
1636    // No accidental scope leak from CLI chaining (DD-026, #52 / #56)
1637    // ========================================================================
1638
1639    #[test]
1640    fn test_repl_line_is_never_chained_across_multiple_commands() {
1641        // execute_line() (and, through it, :load) goes through
1642        // ReplParser::parse_line() -> CliParser::parse() (the scalar,
1643        // pre-DD-026 method) — never CliInterface::dispatch()'s
1644        // segmentation. A line naming two registered commands back to
1645        // back must therefore still be read as ONE command whose arity
1646        // is exceeded by the second name, exactly as before chaining
1647        // existed for the CLI — not as two chained commands.
1648        let mut registry = CommandRegistry::new();
1649        for name in ["first", "second"] {
1650            let cmd_def = CommandDefinition {
1651                name: name.to_string(),
1652                aliases: vec![],
1653                description: format!("Test command {}", name),
1654                required: false,
1655                arguments: vec![ArgumentDefinition {
1656                    name: "value".to_string(),
1657                    arg_type: ArgumentType::String,
1658                    required: true,
1659                    description: "Value".to_string(),
1660                    validation: vec![],
1661                    secure: false,
1662                }],
1663                options: vec![],
1664                implementation: format!("{}_handler", name),
1665                continue_on_failure: false,
1666                requires_success: false,
1667            };
1668            registry
1669                .register_sync(
1670                    cmd_def,
1671                    Box::new(TestHandler {
1672                        name: name.to_string(),
1673                    }),
1674                )
1675                .unwrap();
1676        }
1677
1678        let context = Box::new(TestContext::default());
1679        let mut repl =
1680            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1681
1682        // "first" has arity 1; "1" fills it, leaving "second" as a
1683        // genuine overflow — never a second, chained command.
1684        let result = repl.execute_line("first 1 second");
1685
1686        assert!(result.is_err());
1687        match result.unwrap_err() {
1688            DynamicCliError::Parse(ParseError::TooManyArguments { command, .. }) => {
1689                assert_eq!(command, "first");
1690            }
1691            other => panic!("Expected TooManyArguments error, got: {:?}", other),
1692        }
1693
1694        // Neither command actually ran — in particular, "second" was
1695        // never silently executed as a chained segment.
1696        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1697        assert!(ctx.executed_commands.is_empty());
1698    }
1699
1700    // ========================================================================
1701    // Prompt suffix bugfix + multi-line continuation prompt (DD-027, #67)
1702    // ========================================================================
1703
1704    #[test]
1705    fn test_prompt_uses_default_suffix_without_config() {
1706        let registry = create_test_registry();
1707        let context = Box::new(TestContext::default());
1708        let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None).unwrap();
1709
1710        // No config supplied: falls back to the schema's own default,
1711        // not an independently hardcoded literal.
1712        assert_eq!(repl.prompt, "myapp > ");
1713    }
1714
1715    #[test]
1716    fn test_prompt_honours_configured_suffix() {
1717        // Regression test for the bug where `config.metadata.prompt_suffix`
1718        // was silently ignored in favour of a hardcoded " > ".
1719        let registry = create_test_registry();
1720        let context = Box::new(TestContext::default());
1721        let mut config = make_help_config();
1722        config.metadata.prompt_suffix = " $ ".to_string();
1723
1724        let repl =
1725            ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
1726
1727        assert_eq!(repl.prompt, "rpn $ ");
1728    }
1729
1730    #[test]
1731    fn test_effective_prompt_multiline_default_derivation() {
1732        // The exact example from DD-027's closing checklist: default
1733        // suffix " > " -> multi-line default "...> ".
1734        let registry = create_test_registry();
1735        let context = Box::new(TestContext::default());
1736        let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None).unwrap();
1737
1738        assert_eq!(repl.prompt, "rpn > ");
1739        assert_eq!(repl.effective_prompt_multiline(), "...> ");
1740    }
1741
1742    #[test]
1743    fn test_effective_prompt_multiline_uses_configured_suffix() {
1744        // The multi-line default must track a *custom* suffix too, not just
1745        // the " > " default — same bug class as the main prompt. Leading
1746        // whitespace of the suffix is still stripped for the default "..."
1747        // base, same as the " > " case.
1748        let registry = create_test_registry();
1749        let context = Box::new(TestContext::default());
1750        let mut config = make_help_config();
1751        config.metadata.prompt_suffix = " $ ".to_string();
1752
1753        let repl =
1754            ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
1755
1756        assert_eq!(repl.effective_prompt_multiline(), "...$ ");
1757    }
1758
1759    #[test]
1760    fn test_with_prompt_multiline_overrides_base_only() {
1761        // Overriding only replaces the "..." base — the configured suffix
1762        // is still appended automatically.
1763        let registry = create_test_registry();
1764        let context = Box::new(TestContext::default());
1765        let mut config = make_help_config();
1766        config.metadata.prompt_suffix = " $ ".to_string();
1767
1768        let repl = ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None)
1769            .unwrap()
1770            .with_prompt_multiline("_ _ _".to_string());
1771
1772        assert_eq!(repl.effective_prompt_multiline(), "_ _ _ $ ");
1773    }
1774
1775    // ========================================================================
1776    // `\`-continuation accumulation logic (DD-027, #69)
1777    // ========================================================================
1778
1779    #[test]
1780    fn test_accumulate_line_single_line_no_continuation() {
1781        let mut buffer: Vec<String> = Vec::new();
1782        let result = ReplInterface::accumulate_line(&mut buffer, "test");
1783
1784        assert_eq!(result, Some("test".to_string()));
1785        assert!(buffer.is_empty());
1786    }
1787
1788    #[test]
1789    fn test_accumulate_line_trailing_backslash_buffers_and_returns_none() {
1790        let mut buffer: Vec<String> = Vec::new();
1791        let result = ReplInterface::accumulate_line(&mut buffer, "push \\");
1792
1793        assert_eq!(result, None);
1794        assert_eq!(buffer, vec!["push".to_string()]);
1795    }
1796
1797    #[test]
1798    fn test_accumulate_line_multi_fragment_reconstruction() {
1799        // Three lines: two continued, one final — mirrors the DD-027
1800        // example of a command whose options span several REPL lines.
1801        let mut buffer: Vec<String> = Vec::new();
1802
1803        assert_eq!(
1804            ReplInterface::accumulate_line(&mut buffer, "config \\"),
1805            None
1806        );
1807        assert_eq!(
1808            ReplInterface::accumulate_line(&mut buffer, "--format model \\"),
1809            None
1810        );
1811        let result = ReplInterface::accumulate_line(&mut buffer, "source=model.yml");
1812
1813        assert_eq!(
1814            result,
1815            Some("config --format model source=model.yml".to_string())
1816        );
1817        // The buffer is drained once the command completes.
1818        assert!(buffer.is_empty());
1819    }
1820
1821    #[test]
1822    fn test_accumulate_line_strips_marker_and_surrounding_whitespace() {
1823        // The `\` marker and the whitespace immediately before it are never
1824        // part of the reconstructed line — fragments are joined by exactly
1825        // one space, not whatever spacing the user typed.
1826        let mut buffer: Vec<String> = Vec::new();
1827        ReplInterface::accumulate_line(&mut buffer, "test   \\");
1828        let result = ReplInterface::accumulate_line(&mut buffer, "  arg");
1829
1830        assert_eq!(result, Some("test arg".to_string()));
1831    }
1832
1833    #[test]
1834    fn test_run_multiline_reconstruction_reaches_execute_line_once() {
1835        // Simulates what `run()` does with each `readline()` result,
1836        // without needing a real terminal: feed the same raw lines a real
1837        // session would produce, dispatch only once the buffer completes,
1838        // exactly as `run()` itself does.
1839        let registry = create_test_registry();
1840        let context = Box::new(TestContext::default());
1841        let mut repl =
1842            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1843
1844        let mut buffer: Vec<String> = Vec::new();
1845        assert_eq!(ReplInterface::accumulate_line(&mut buffer, "te \\"), None);
1846        let line = ReplInterface::accumulate_line(&mut buffer, "st").unwrap();
1847        assert_eq!(line, "te st");
1848
1849        // "te st" isn't a registered command name — proves the reconstructed
1850        // line, not raw fragments, is what actually gets dispatched.
1851        let result = repl.execute_line(&line);
1852        assert!(result.is_err());
1853    }
1854
1855    #[test]
1856    fn test_load_line_ending_in_backslash_is_not_continued() {
1857        // :load bypasses run()'s accumulation buffer entirely — each script
1858        // line goes straight to execute_line(), one at a time. A trailing
1859        // `\` in a script has no special meaning there (DD-027 scopes
1860        // continuation to the interactive `run()` loop only).
1861        let registry = create_test_registry();
1862        let context = Box::new(TestContext::default());
1863        let mut repl =
1864            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1865
1866        let script = write_script("test \\\ntest\n");
1867        let line = format!(":load {}", script.path().display());
1868
1869        // The first line ("test \") is dispatched as-is — "\" is an
1870        // unexpected extra positional argument for the zero-arity "test"
1871        // command, not silently joined with the next line.
1872        assert!(repl.execute_line(&line).is_ok());
1873
1874        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1875        // Only the second, unadorned "test" line actually executed.
1876        assert_eq!(ctx.executed_commands, vec!["test"]);
1877    }
1878}