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 of command names, `:` directives and option flags
7//! - Colored prompts and error display
8//! - `:`-prefixed directives (`:help`, `:load`, `:quit`, `:exit`), whose
9//!   names can be overridden in the configuration
10//!
11//! # Example
12//!
13//! ```no_run
14//! use dynamic_cli::interface::ReplInterface;
15//! use dynamic_cli::prelude::*;
16//!
17//! # #[derive(Default)]
18//! # struct MyContext;
19//! # impl ExecutionContext for MyContext {
20//! #     fn as_any(&self) -> &dyn std::any::Any { self }
21//! #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
22//! # }
23//! # fn main() -> dynamic_cli::Result<()> {
24//! let registry = CommandRegistry::new();
25//! let context = Box::new(MyContext::default());
26//!
27//! let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
28//! repl.run()?;
29//! # Ok(())
30//! # }
31//! ```
32
33use std::path::PathBuf;
34use std::sync::Arc;
35
36use rustyline::completion::{Completer, Pair};
37use rustyline::error::ReadlineError;
38use rustyline::highlight::Highlighter;
39use rustyline::hint::Hinter;
40use rustyline::validate::Validator;
41use rustyline::{CompletionType, Config, Context, Editor, Helper};
42
43use crate::config::directive::{
44    effective_directives, DirectiveDefinition, ReplDirective, DIRECTIVE_PREFIX,
45};
46use crate::config::schema::CommandsConfig;
47use crate::context::ExecutionContext;
48use crate::error::{display_error, DynamicCliError, ExecutionError, ParseError, Result};
49use crate::help::{command_help_footer, DefaultHelpFormatter, HelpFormatter};
50use crate::parser::{ParsedArgs, ReplParser};
51use crate::registry::CommandRegistry;
52
53// ============================================================================
54// DcliCompleter
55// ============================================================================
56
57/// Tab-completion engine for the REPL.
58///
59/// Completes at three depth levels driven by the YAML configuration:
60///
61/// | Input                    | Candidates                                     |
62/// |--------------------------|------------------------------------------------|
63/// | `<Tab>`                  | all command names + aliases                    |
64/// | `he<Tab>`                | command names/aliases starting with `he`       |
65/// | `:<Tab>`                 | all directive names + aliases, with their `:`  |
66/// | `:he<Tab>`               | directive names/aliases starting with `he`     |
67/// | `hello <Tab>`            | long and short option flags of `hello`         |
68/// | `hello --<Tab>`          | long flags of `hello`                          |
69/// | `hello -<Tab>`           | short flags of `hello`                         |
70///
71/// Directives are completed under their effective names, so a renamed or
72/// translated directive completes under its configured name. Positional
73/// argument values, including the argument of a directive, are not
74/// completed.
75///
76/// The completer holds `Arc` references so it shares the same data as
77/// `ReplInterface` without duplication or unsafe aliasing.
78struct DcliCompleter {
79    /// Shared registry — single source of truth for command names and aliases.
80    registry: Arc<CommandRegistry>,
81
82    /// Shared configuration — source of truth for option flags.
83    /// `None` when the REPL was constructed without a config.
84    config: Option<Arc<CommandsConfig>>,
85
86    /// Shared effective directive table — source of truth for directive
87    /// names and aliases.
88    directives: Arc<[DirectiveDefinition]>,
89}
90
91impl DcliCompleter {
92    fn new(
93        registry: Arc<CommandRegistry>,
94        config: Option<Arc<CommandsConfig>>,
95        directives: Arc<[DirectiveDefinition]>,
96    ) -> Self {
97        Self {
98            registry,
99            config,
100            directives,
101        }
102    }
103
104    /// Directive names and aliases starting with `typed`, each with its
105    /// leading `:`. `typed` includes the `:`.
106    fn directives_matching(&self, typed: &str) -> Vec<String> {
107        self.directives
108            .iter()
109            .flat_map(|def| std::iter::once(&def.name).chain(&def.aliases))
110            .map(|name| format!("{}{}", DIRECTIVE_PREFIX, name))
111            .filter(|candidate| candidate.starts_with(typed))
112            .collect()
113    }
114
115    /// Collect all flag completions for a given canonical command name.
116    ///
117    /// Returns both long forms (`--flag`) and short forms (`-f`) for every
118    /// option defined on the command.
119    fn flags_for(&self, command_name: &str) -> Vec<String> {
120        let config = match &self.config {
121            Some(c) => c,
122            None => return vec![],
123        };
124
125        let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
126            Some(d) => d,
127            None => return vec![],
128        };
129
130        let mut flags = Vec::new();
131        for opt in &cmd_def.options {
132            if let Some(long) = &opt.long {
133                flags.push(format!("--{}", long));
134            }
135            if let Some(short) = &opt.short {
136                flags.push(format!("-{}", short));
137            }
138        }
139        flags
140    }
141}
142
143impl Completer for DcliCompleter {
144    type Candidate = Pair;
145
146    fn complete(
147        &self,
148        line: &str,
149        pos: usize,
150        _ctx: &Context<'_>,
151    ) -> rustyline::Result<(usize, Vec<Pair>)> {
152        // Work only on the portion of the line up to the cursor.
153        let line = &line[..pos];
154        let tokens: Vec<&str> = line.split_whitespace().collect();
155
156        // ── Level 1: no token yet, or first token still being typed ──────────
157        // Complete command names and aliases.
158        let completing_first_token =
159            tokens.is_empty() || (tokens.len() == 1 && !line.ends_with(' '));
160
161        if completing_first_token {
162            let prefix = tokens.first().copied().unwrap_or("");
163            let start = pos - prefix.len();
164
165            // Command names cannot start with `:` (rejected by the config
166            // validator), so a `:` prefix selects directives only.
167            let names: Vec<String> = if prefix.starts_with(DIRECTIVE_PREFIX) {
168                self.directives_matching(prefix)
169            } else {
170                self.registry
171                    .list_commands()
172                    .into_iter()
173                    .flat_map(|def| {
174                        let mut names = vec![def.name.clone()];
175                        names.extend(def.aliases.clone());
176                        names
177                    })
178                    .filter(|name| name.starts_with(prefix))
179                    .collect()
180            };
181
182            let mut candidates: Vec<Pair> = names
183                .into_iter()
184                .map(|name| Pair {
185                    display: name.clone(),
186                    replacement: name,
187                })
188                .collect();
189
190            candidates.sort_by(|a, b| a.display.cmp(&b.display));
191            return Ok((start, candidates));
192        }
193
194        // ── Level 2: first token is a complete command, completing flags ──────
195        // Resolve the command name (handles aliases).
196        let command_token = tokens[0];
197        let canonical = match self.registry.resolve_name(command_token) {
198            Some(name) => name.to_string(),
199            None => return Ok((pos, vec![])),
200        };
201
202        // The word being completed (may be empty if cursor follows a space).
203        let current_word = if line.ends_with(' ') {
204            ""
205        } else {
206            tokens.last().copied().unwrap_or("")
207        };
208
209        // Only offer flag completions when the current word looks like a flag
210        // or when the user pressed Tab on an empty position after the command.
211        let is_flag_context = current_word.is_empty() || current_word.starts_with('-');
212
213        if !is_flag_context {
214            return Ok((pos, vec![]));
215        }
216
217        let start = pos - current_word.len();
218        let mut candidates: Vec<Pair> = self
219            .flags_for(&canonical)
220            .into_iter()
221            .filter(|flag| flag.starts_with(current_word))
222            .map(|flag| Pair {
223                display: flag.clone(),
224                replacement: flag,
225            })
226            .collect();
227
228        candidates.sort_by(|a, b| a.display.cmp(&b.display));
229        Ok((start, candidates))
230    }
231}
232
233// ============================================================================
234// DcliHelper — rustyline Helper glue
235// ============================================================================
236
237/// Rustyline `Helper` implementation that wires `DcliCompleter` into the
238/// editor. The remaining traits (`Hinter`, `Highlighter`, `Validator`) use
239/// their no-op default implementations.
240struct DcliHelper {
241    completer: DcliCompleter,
242}
243
244impl DcliHelper {
245    fn new(
246        registry: Arc<CommandRegistry>,
247        config: Option<Arc<CommandsConfig>>,
248        directives: Arc<[DirectiveDefinition]>,
249    ) -> Self {
250        Self {
251            completer: DcliCompleter::new(registry, config, directives),
252        }
253    }
254}
255
256impl Helper for DcliHelper {}
257
258impl Completer for DcliHelper {
259    type Candidate = Pair;
260
261    fn complete(
262        &self,
263        line: &str,
264        pos: usize,
265        ctx: &Context<'_>,
266    ) -> rustyline::Result<(usize, Vec<Pair>)> {
267        self.completer.complete(line, pos, ctx)
268    }
269}
270
271// No-op implementations required by the Helper supertrait bound.
272impl Hinter for DcliHelper {
273    type Hint = String;
274}
275
276impl Highlighter for DcliHelper {}
277
278impl Validator for DcliHelper {}
279
280// ============================================================================
281// ReplInterface
282// ============================================================================
283
284/// REPL (Read-Eval-Print Loop) interface
285///
286/// Provides an interactive command-line interface with:
287/// - Line editing and history
288/// - Per-application persistent command history
289/// - Tab completion (commands, aliases, directives, option flags)
290/// - Graceful error handling
291/// - `:`-prefixed directives and `--help` interception
292///
293/// # Architecture
294///
295/// ```text
296/// User input → rustyline (DcliHelper) → ReplParser → CommandExecutor → Handler
297///                    ↓                                      ↓
298///             Tab completion                         ExecutionContext
299///    (commands, directives, flags)
300/// ```
301///
302/// # Directives
303///
304/// A line starting with `:` is a framework directive; any other line is an
305/// application command, so a directive never shadows a command. With the
306/// default names:
307///
308/// | Directive         | Aliases    | Effect                                        |
309/// |-------------------|------------|-----------------------------------------------|
310/// | `:help [command]` | `:h`, `:?` | Application help, or the help of one command  |
311/// | `:load <path>`    |            | Run every line of a script file               |
312/// | `:quit`           | `:q`       | Save the session history, then leave          |
313/// | `:exit`           |            | Leave without saving the session history      |
314///
315/// Names, aliases and descriptions come from the configuration's
316/// `directives:` section when present (see [`crate::config::directive`]);
317/// a renamed directive no longer answers to its default name. `exit` or
318/// `quit` typed without `:` is an ordinary command; when the application
319/// has no such command, the error suggests the matching directive.
320///
321/// `--help`, `-h`, `--help <command>` and `<command> --help` are also
322/// intercepted and rendered like `:help`.
323///
324/// # History
325///
326/// Command history is stored per application under the XDG data directory:
327/// - Linux/macOS: `~/.local/share/<app_name>/history`
328/// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
329///
330/// The history file is read when the REPL starts. During the session, new
331/// entries are kept in memory; the file is written only when the session
332/// ends with `:quit` or end of input (Ctrl-D). Leaving with `:exit`, an
333/// input error, a handler that exits the process, a panic or a signal
334/// leaves the file as it was at startup.
335///
336/// Lines containing a `secure: true` argument are never added to history.
337/// Lines that fail to parse and directive lines are not added either.
338pub struct ReplInterface {
339    /// Shared command registry — single source of truth for names, aliases,
340    /// definitions, and handlers.
341    registry: Arc<CommandRegistry>,
342
343    /// Execution context passed to every command handler.
344    context: Box<dyn ExecutionContext>,
345
346    /// Fully rendered prompt string (e.g., "myapp > "), built at construction
347    /// time from the `prompt` argument and `prompt_suffix` below.
348    prompt: String,
349
350    /// Suffix appended after the app-name segment to build both `prompt`
351    /// and the default multi-line continuation prompt (#67).
352    ///
353    /// Sourced from `config.metadata.prompt_suffix` when a config is
354    /// supplied, falling back to [`crate::config::schema::default_prompt_suffix`] otherwise —
355    /// stored separately (rather than re-parsed out of `prompt`) so the
356    /// multi-line default stays correct regardless of what the configured
357    /// suffix actually is.
358    prompt_suffix: String,
359
360    /// Rustyline editor with tab-completion support.
361    editor: Editor<DcliHelper, rustyline::history::DefaultHistory>,
362
363    /// History file path.
364    history_path: Option<PathBuf>,
365
366    /// Application configuration — shared with the completer and used by the
367    /// help formatter. `None` when no config was supplied at construction.
368    config: Option<Arc<CommandsConfig>>,
369
370    /// Help formatter — renders `--help` output.
371    /// `None` when the application was built without a formatter.
372    help_formatter: Option<Box<dyn HelpFormatter>>,
373
374    /// Base (app-name-equivalent) segment of the continuation prompt shown
375    /// while a `\`-continued command is being accumulated (#67).
376    ///
377    /// `None` (the default) means the base falls back to `"..."`. Either
378    /// way, `prompt_suffix` is always appended — this field overrides only
379    /// the segment that replaces the app name, exactly like `prompt`
380    /// (the constructor argument) only ever supplied the app-name segment
381    /// of the main prompt.
382    prompt_multiline: Option<String>,
383
384    /// Effective directive table: the configured overrides merged with the
385    /// defaults, one entry per [`ReplDirective`] in `ReplDirective::ALL`
386    /// order. Defaults only when no config was supplied. Shared with the
387    /// completer.
388    directives: Arc<[DirectiveDefinition]>,
389}
390
391/// What the REPL session does after a line
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393enum SessionControl {
394    /// Read the next line
395    Continue,
396    /// Leave the REPL, writing the session history first or not
397    Leave { save_history: bool },
398}
399
400impl ReplInterface {
401    /// Create a new REPL interface.
402    ///
403    /// All configuration is supplied at construction time so that the
404    /// tab-completion engine and the help formatter share the same data
405    /// without duplication.
406    ///
407    /// # Arguments
408    ///
409    /// * `registry`       — Command registry with all registered commands.
410    /// * `context`        — Execution context passed to handlers.
411    /// * `prompt`         — Prompt prefix (e.g., `"myapp"`). The displayed
412    ///   prompt is `prompt` followed by `config.metadata.prompt_suffix`
413    ///   (e.g., `"myapp > "`) when `config` is supplied, or by
414    ///   `default_prompt_suffix()` (`"myapp > "`) otherwise — the suffix is
415    ///   never hardcoded independently of the config.
416    /// * `config`         — Application configuration for completion, help,
417    ///   and the prompt suffix. Pass `None` to disable all three.
418    /// * `help_formatter` — Help formatter implementation.
419    ///   Pass `None` to use [`DefaultHelpFormatter`] lazily,
420    ///   or supply a custom implementation.
421    ///
422    /// # Errors
423    ///
424    /// Returns an error if rustyline initialisation fails (rare).
425    ///
426    /// # Example
427    ///
428    /// ```no_run
429    /// use dynamic_cli::interface::ReplInterface;
430    /// use dynamic_cli::prelude::*;
431    ///
432    /// # #[derive(Default)]
433    /// # struct MyContext;
434    /// # impl ExecutionContext for MyContext {
435    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
436    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
437    /// # }
438    /// # fn main() -> dynamic_cli::Result<()> {
439    /// let registry = CommandRegistry::new();
440    /// let context = Box::new(MyContext::default());
441    ///
442    /// // Without completion or help:
443    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
444    /// # Ok(())
445    /// # }
446    /// ```
447    pub fn new(
448        registry: CommandRegistry,
449        context: Box<dyn ExecutionContext>,
450        prompt: String,
451        config: Option<CommandsConfig>,
452        help_formatter: Option<Box<dyn HelpFormatter>>,
453    ) -> Result<Self> {
454        // Wrap registry in Arc — shared with the completer.
455        let registry = Arc::new(registry);
456
457        // Determine the prompt suffix *before* config is moved into the Arc
458        // below — sourced from the live config when present (config-first,
459        // principle 7), falling back to the same default the schema itself
460        // uses when no config was supplied.
461        let prompt_suffix = config
462            .as_ref()
463            .map(|c| c.metadata.prompt_suffix.clone())
464            .unwrap_or_else(crate::config::schema::default_prompt_suffix);
465
466        // DA-030 (#92): the directive table is fixed for the session.
467        let directives: Arc<[DirectiveDefinition]> = effective_directives(
468            config
469                .as_ref()
470                .map(|c| c.directives.as_slice())
471                .unwrap_or_default(),
472        )
473        .into();
474
475        // Wrap config in Arc if present — shared with the completer.
476        let config: Option<Arc<CommandsConfig>> = config.map(Arc::new);
477
478        // Build the rustyline editor with Tab completion enabled.
479        let rl_config = Config::builder()
480            .completion_type(CompletionType::List)
481            .build();
482
483        let helper = DcliHelper::new(
484            Arc::clone(&registry),
485            config.clone(),
486            Arc::clone(&directives),
487        );
488
489        let mut editor = Editor::with_config(rl_config).map_err(|e| {
490            ExecutionError::CommandFailed(anyhow::anyhow!("Failed to initialize REPL: {}", e))
491        })?;
492        editor.set_helper(Some(helper));
493
494        // Determine history file path using the prompt as the app name.
495        let history_path = Self::get_history_path(&prompt);
496
497        let mut repl = Self {
498            registry,
499            context,
500            prompt: format!("{}{}", prompt, prompt_suffix),
501            prompt_suffix,
502            editor,
503            history_path,
504            config,
505            help_formatter,
506            prompt_multiline: None,
507            directives,
508        };
509
510        repl.load_history();
511
512        Ok(repl)
513    }
514
515    /// Override the base segment of the continuation prompt shown while a
516    /// `\`-continued command is being accumulated (#67).
517    ///
518    /// `prompt` here plays the same role as the app-name segment passed to
519    /// [`ReplInterface::new`] for the main prompt: only the base is
520    /// supplied, `prompt_suffix` (from the config, or the default) is
521    /// always appended after it by `effective_prompt_multiline()`.
522    /// When this setter is never called, the base defaults to `"..."`.
523    ///
524    /// Purely additive — consuming `self` and returning `Self` like every
525    /// other fluent setter in this crate; no existing `ReplInterface::new()`
526    /// call site changes.
527    ///
528    /// # Example
529    ///
530    /// ```no_run
531    /// use dynamic_cli::interface::ReplInterface;
532    /// use dynamic_cli::prelude::*;
533    ///
534    /// # #[derive(Default)]
535    /// # struct MyContext;
536    /// # impl ExecutionContext for MyContext {
537    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
538    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
539    /// # }
540    /// # fn main() -> dynamic_cli::Result<()> {
541    /// let registry = CommandRegistry::new();
542    /// let context = Box::new(MyContext::default());
543    ///
544    /// let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None)?
545    ///     .with_prompt_multiline("_ _ _".to_string());
546    /// # Ok(())
547    /// # }
548    /// ```
549    pub fn with_prompt_multiline(mut self, prompt: String) -> Self {
550        self.prompt_multiline = Some(prompt);
551        self
552    }
553
554    /// The prompt actually displayed while accumulating a `\`-continued
555    /// command (#67).
556    ///
557    /// With an override set via
558    /// [`with_prompt_multiline`][Self::with_prompt_multiline], the base is
559    /// used as-is, followed by `prompt_suffix` unchanged (e.g. base
560    /// `"_ _ _"` + suffix `" $ "` → `"_ _ _ $ "`).
561    ///
562    /// Without an override, the default base `"..."` is followed by
563    /// `prompt_suffix` **with its leading whitespace stripped**, so that
564    /// `"rpn > "` becomes `"...> "`: `"..."` takes the place of the app
565    /// name *and* of the space right after it, rather than of the app name
566    /// alone, so it reads as one continuous mark
567    /// against the separator glyph instead of leaving a stray gap
568    /// (`"... > "`). This asymmetry is deliberate — an explicit override is
569    /// the caller's own spacing choice and is never second-guessed.
570    fn effective_prompt_multiline(&self) -> String {
571        match &self.prompt_multiline {
572            Some(base) => format!("{}{}", base, self.prompt_suffix),
573            None => format!("...{}", self.prompt_suffix.trim_start()),
574        }
575    }
576
577    /// Render the application help, or the help of `command`.
578    ///
579    /// The application help is followed by the directive list, then by the
580    /// line on the help directive (`Type ':help <command>' …`); both use
581    /// the effective directive names.
582    ///
583    /// Uses the formatter supplied to [`ReplInterface::new`], or
584    /// [`DefaultHelpFormatter`] when none was supplied. Returns `None`
585    /// without a configuration, since there is nothing to render.
586    fn render_help(&self, command: Option<&str>) -> Option<String> {
587        let config = self.config.as_deref()?;
588        // `DefaultHelpFormatter` is a unit struct: the fallback costs no
589        // allocation and is only built when help is actually requested.
590        let default_formatter = DefaultHelpFormatter::new();
591        let formatter: &dyn HelpFormatter =
592            self.help_formatter.as_deref().unwrap_or(&default_formatter);
593
594        Some(match command {
595            Some(cmd) => formatter.format_command(config, cmd),
596            None => {
597                let mut out = formatter.format_app(config);
598                out.push_str(&formatter.format_directives(&self.directives));
599                out.push_str(&command_help_footer(
600                    "Type",
601                    &format!(
602                        "{}{} <command>",
603                        DIRECTIVE_PREFIX,
604                        self.help_directive_name()
605                    ),
606                ));
607                out
608            }
609        })
610    }
611
612    /// Try to handle a `--help` / `-h` request.
613    ///
614    /// Returns `Some(output)` when the line is a help request and a
615    /// configuration is available, `None` otherwise (normal command
616    /// processing continues). Rendering goes through
617    /// [`render_help`][Self::render_help].
618    ///
619    /// Recognized patterns (case-sensitive):
620    ///
621    /// | Input              | Output                    |
622    /// |--------------------|---------------------------|
623    /// | `--help`           | Application-level help    |
624    /// | `-h`               | Application-level help    |
625    /// | `--help <command>` | Per-command help          |
626    /// | `-h <command>`     | Per-command help          |
627    /// | `<command> --help` | Per-command help          |
628    /// | `<command> -h`     | Per-command help          |
629    fn try_handle_help(&self, line: &str) -> Option<String> {
630        let trimmed = line.trim();
631
632        if trimmed == "--help" || trimmed == "-h" {
633            return self.render_help(None);
634        }
635
636        if let Some(rest) = trimmed
637            .strip_prefix("--help ")
638            .or_else(|| trimmed.strip_prefix("-h "))
639        {
640            let cmd = rest.trim();
641            if !cmd.is_empty() {
642                return self.render_help(Some(cmd));
643            }
644        }
645
646        let parts: Vec<&str> = trimmed.split_whitespace().collect();
647        if parts.len() >= 2 {
648            let last = *parts.last().unwrap();
649            if last == "--help" || last == "-h" {
650                return self.render_help(Some(parts[0]));
651            }
652        }
653
654        None
655    }
656
657    /// Dispatch one input line: a directive when it starts with `:`, an
658    /// application command otherwise.
659    ///
660    /// Shared by [`run`][Self::run] and [`load_script`][Self::load_script].
661    /// Returns what the session does next; only `:quit` and `:exit` ask to
662    /// leave. Leaving, and writing the history, is up to the caller.
663    fn handle_line(&mut self, line: &str) -> Result<SessionControl> {
664        let line = line.trim();
665
666        if line.starts_with(DIRECTIVE_PREFIX) {
667            return self.dispatch_directive(line);
668        }
669
670        self.execute_line(line)?;
671        Ok(SessionControl::Continue)
672    }
673
674    /// Directive whose effective name or alias is `name` (typed without
675    /// its `:`), if any.
676    fn resolve_directive(&self, name: &str) -> Option<ReplDirective> {
677        self.directives
678            .iter()
679            .find(|def| def.name == name || def.aliases.iter().any(|alias| alias == name))
680            .map(|def| def.implementation)
681    }
682
683    /// Effective definition of `directive`.
684    fn directive_definition(&self, directive: ReplDirective) -> &DirectiveDefinition {
685        self.directives
686            .iter()
687            .find(|def| def.implementation == directive)
688            // The table holds every variant: built by `effective_directives`.
689            .expect("directive table covers every ReplDirective")
690    }
691
692    /// Run one directive line (starting with `:`).
693    ///
694    /// The first word after `:` selects the directive through the effective
695    /// table; the rest of the line is its argument.
696    fn dispatch_directive(&mut self, line: &str) -> Result<SessionControl> {
697        let body = &line[DIRECTIVE_PREFIX.len_utf8()..];
698        let (name, rest) = match body.split_once(char::is_whitespace) {
699            Some((name, rest)) => (name, rest.trim()),
700            None => (body, ""),
701        };
702
703        let directive = self
704            .resolve_directive(name)
705            .ok_or_else(|| self.unknown_directive_error(name))?;
706
707        // DA-030 (#92): exhaustive match — a new variant must be handled here.
708        match directive {
709            ReplDirective::Help => {
710                if rest.split_whitespace().count() > 1 {
711                    return Err(self.usage_error(name, directive, "takes at most one command"));
712                }
713                let command = (!rest.is_empty()).then_some(rest);
714                let output = self.render_help(command).ok_or_else(|| {
715                    DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
716                        "help is not available"
717                    )))
718                })?;
719                print!("{}", output);
720                Ok(SessionControl::Continue)
721            }
722            ReplDirective::Load => {
723                if rest.is_empty() {
724                    return Err(self.usage_error(name, directive, "requires a file path"));
725                }
726                self.load_script(rest)?;
727                Ok(SessionControl::Continue)
728            }
729            ReplDirective::Quit | ReplDirective::Exit => {
730                if !rest.is_empty() {
731                    return Err(self.usage_error(name, directive, "takes no argument"));
732                }
733                Ok(SessionControl::Leave {
734                    save_history: directive == ReplDirective::Quit,
735                })
736            }
737        }
738    }
739
740    /// Error for a directive typed as `:<typed>` with invalid arguments.
741    fn usage_error(&self, typed: &str, directive: ReplDirective, problem: &str) -> DynamicCliError {
742        DynamicCliError::Parse(ParseError::InvalidSyntax {
743            details: format!("`{}{}` {}", DIRECTIVE_PREFIX, typed, problem),
744            hint: Some(format!(
745                "Usage: {}",
746                self.directive_definition(directive).usage_line()
747            )),
748        })
749    }
750
751    /// Error for `:<typed>` matching no directive, listing the effective
752    /// directive names.
753    fn unknown_directive_error(&self, typed: &str) -> DynamicCliError {
754        let available: Vec<String> = self
755            .directives
756            .iter()
757            .map(|def| format!("{}{}", DIRECTIVE_PREFIX, def.name))
758            .collect();
759        DynamicCliError::Parse(ParseError::InvalidSyntax {
760            details: format!("Unknown directive '{}{}'", DIRECTIVE_PREFIX, typed),
761            hint: Some(format!("Available directives: {}", available.join(", "))),
762        })
763    }
764
765    /// Complete an unknown-command error for the REPL.
766    ///
767    /// - When the unknown command is a directive name or alias typed
768    ///   without its `:` (`quit`), the directive (`:quit`) becomes the
769    ///   first suggestion. The parser only reports an unknown command when
770    ///   the application has no command of that name, so a command always
771    ///   takes precedence.
772    /// - The hint names the help directive under its effective name:
773    ///   `Type ':help' for available commands`.
774    ///
775    /// Any other error is returned unchanged.
776    fn complete_unknown_command(&self, error: DynamicCliError) -> DynamicCliError {
777        match error {
778            DynamicCliError::Parse(ParseError::UnknownCommand {
779                command,
780                mut suggestions,
781                ..
782            }) => {
783                if self.resolve_directive(&command).is_some() {
784                    let directive = format!("{}{}", DIRECTIVE_PREFIX, command);
785                    suggestions.retain(|s| *s != directive);
786                    suggestions.insert(0, directive);
787                }
788                DynamicCliError::Parse(ParseError::UnknownCommand {
789                    command,
790                    suggestions,
791                    hint: Some(format!(
792                        "Type '{}{}' for available commands",
793                        DIRECTIVE_PREFIX,
794                        self.help_directive_name()
795                    )),
796                })
797            }
798            other => other,
799        }
800    }
801
802    /// Effective name of the help directive (`help` unless overridden).
803    fn help_directive_name(&self) -> &str {
804        &self.directive_definition(ReplDirective::Help).name
805    }
806
807    /// Run every line of the script at `path` (`:load <path>`).
808    ///
809    /// Each non-blank, non-comment (`#`-prefixed) line goes through
810    /// [`handle_line`][Self::handle_line], like a typed line: commands,
811    /// `--help` and the `:help` / `:load` directives run normally. A
812    /// directive that leaves the session (`:quit`, `:exit`, under any of
813    /// their names) is refused: it is reported and skipped, and the script
814    /// goes on.
815    ///
816    /// There is no error-policy parameter, unlike
817    /// [`CliInterface::run_script`][crate::interface::CliInterface::run_script]:
818    /// a failing line is reported inline (via [`display_error`]) and the
819    /// load always continues, then prints a `succeeded/attempted` summary.
820    ///
821    /// Loaded lines follow the REPL's scalar-only parsing path, not
822    /// `run_script()`'s typed/repeatable-options path, and are not added to
823    /// history. A script that loads itself (directly or through another
824    /// file) recurses until the file handle limit or the stack is
825    /// exhausted: no cycle detection is implemented.
826    fn load_script(&mut self, path: &str) -> Result<()> {
827        let content = std::fs::read_to_string(path).map_err(|e| {
828            DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
829                "failed to read script file {}: {}",
830                path,
831                e
832            )))
833        })?;
834
835        let mut attempted = 0usize;
836        let mut succeeded = 0usize;
837
838        for (idx, raw_line) in content.lines().enumerate() {
839            let line_number = idx + 1;
840            let script_line = raw_line.trim();
841
842            if script_line.is_empty() || script_line.starts_with('#') {
843                continue;
844            }
845
846            attempted += 1;
847
848            match self.handle_line(script_line) {
849                Ok(SessionControl::Continue) => succeeded += 1,
850                Ok(SessionControl::Leave { .. }) => {
851                    // Nothing has happened yet: leaving is done by `run()`.
852                    let typed = script_line.split_whitespace().next().unwrap_or_default();
853                    eprintln!(
854                        "  :load {} — line {}: '{}' cannot be used in a script; line skipped",
855                        path, line_number, typed
856                    );
857                }
858                Err(e) => {
859                    eprintln!("  :load {} — line {}:", path, line_number);
860                    display_error(&e);
861                }
862            }
863        }
864
865        println!(":load {path}: {succeeded}/{attempted} line(s) succeeded");
866        Ok(())
867    }
868
869    /// Check whether a parsed command involves at least one secure argument.
870    ///
871    /// Looks up the command definition in `self.config` (if available) and
872    /// returns `true` when any argument name present in `parsed_args` is
873    /// marked `secure: true` in the YAML schema.
874    fn has_secure_arg(
875        &self,
876        command_name: &str,
877        parsed_args: &std::collections::HashMap<String, String>,
878    ) -> bool {
879        let config = match &self.config {
880            Some(c) => c,
881            None => return false,
882        };
883
884        let cmd_def = match config.commands.iter().find(|c| c.name == command_name) {
885            Some(d) => d,
886            None => return false,
887        };
888
889        cmd_def
890            .arguments
891            .iter()
892            .any(|arg| arg.secure && parsed_args.contains_key(&arg.name))
893    }
894
895    /// Get the history file path for this application.
896    ///
897    /// Each application gets its own isolated history file under the
898    /// XDG data directory:
899    ///
900    /// - Linux/macOS: `~/.local/share/<app_name>/history`
901    /// - Windows:     `%LOCALAPPDATA%\<app_name>\history`
902    fn get_history_path(app_name: &str) -> Option<PathBuf> {
903        dirs::data_local_dir().map(|data_dir| data_dir.join(app_name).join("history"))
904    }
905
906    /// Load command history from file.
907    fn load_history(&mut self) {
908        if let Some(ref path) = self.history_path {
909            if let Some(parent) = path.parent() {
910                let _ = std::fs::create_dir_all(parent);
911            }
912            let _ = self.editor.load_history(path);
913        }
914    }
915
916    /// Write the session history to the history file.
917    ///
918    /// Called only when the session ends with `:quit` or end of input.
919    fn save_history(&mut self) {
920        if let Some(ref path) = self.history_path {
921            if let Err(e) = self.editor.save_history(path) {
922                eprintln!("Warning: Failed to save command history: {}", e);
923            }
924        }
925    }
926
927    /// Run the REPL loop.
928    ///
929    /// Enters an interactive loop that:
930    /// 1. Displays the prompt (or the continuation prompt while
931    ///    accumulating a `\`-continued command, #69)
932    /// 2. Reads user input (with tab completion)
933    /// 3. Parses and executes the command
934    /// 4. Displays results or errors
935    /// 5. Repeats until `:quit`, `:exit` or end of input
936    ///
937    /// # Multi-line option accumulation (#69)
938    ///
939    /// A line ending in a trailing `\` is not dispatched: the marker is
940    /// stripped and the fragment is buffered, the prompt switches to
941    /// `effective_prompt_multiline()`,
942    /// and another line is read. The first line that does *not* end in `\`
943    /// completes the buffer — every fragment plus this final one are
944    /// joined with a single space and dispatched exactly once, with the
945    /// normal prompt restored. Only this fully reconstructed line (no `\`
946    /// markers) can ever reach REPL history — the dispatch never sees a raw
947    /// partial fragment.
948    ///
949    /// A `Ctrl+C` while accumulating discards the buffer and returns to
950    /// the normal prompt, mirroring the familiar shell convention of
951    /// abandoning a continued line on interrupt.
952    ///
953    /// This mechanism is entirely local to this loop: `execute_line()`,
954    /// [`ReplParser`], and `CliParser` are untouched, and non-interactive
955    /// paths (`run_script()`, `:load`) never go through it: without a live
956    /// operator, nothing can answer a continuation prompt, so an incomplete
957    /// command there fails instead of waiting for more input.
958    ///
959    /// # Returns
960    ///
961    /// - `Ok(())` when the session ends: `:quit` or end of input (Ctrl-D),
962    ///   which save the session history, `:exit`, which does not, or an
963    ///   input error, reported on stderr, which does not either
964    /// - `Err(_)` is reserved for critical errors; command and directive
965    ///   errors are displayed and the loop goes on
966    ///
967    /// # Example
968    ///
969    /// ```no_run
970    /// use dynamic_cli::interface::ReplInterface;
971    /// use dynamic_cli::prelude::*;
972    ///
973    /// # #[derive(Default)]
974    /// # struct MyContext;
975    /// # impl ExecutionContext for MyContext {
976    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
977    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
978    /// # }
979    /// # fn main() -> dynamic_cli::Result<()> {
980    /// let registry = CommandRegistry::new();
981    /// let context = Box::new(MyContext::default());
982    ///
983    /// let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None)?;
984    /// repl.run()?;
985    /// # Ok(())
986    /// # }
987    /// ```
988    pub fn run(mut self) -> Result<()> {
989        let mut continuation_buffer: Vec<String> = Vec::new();
990
991        loop {
992            let prompt = if continuation_buffer.is_empty() {
993                self.prompt.clone()
994            } else {
995                self.effective_prompt_multiline()
996            };
997
998            let readline = self.editor.readline(&prompt);
999
1000            match readline {
1001                Ok(raw_line) => {
1002                    let line = match Self::accumulate_line(&mut continuation_buffer, &raw_line) {
1003                        Some(line) => line,
1004                        // Still accumulating: read another line under the
1005                        // continuation prompt instead of dispatching.
1006                        None => continue,
1007                    };
1008
1009                    if line.is_empty() {
1010                        continue;
1011                    }
1012
1013                    // History entries are added inside execute_line(), after
1014                    // successful parsing and only when no secure argument is
1015                    // present; directives are never added.
1016                    match self.handle_line(&line) {
1017                        Ok(SessionControl::Continue) => {}
1018                        Ok(SessionControl::Leave { save_history }) => {
1019                            self.end_session(save_history);
1020                            break;
1021                        }
1022                        Err(e) => {
1023                            display_error(&e);
1024                        }
1025                    }
1026                }
1027
1028                Err(ReadlineError::Interrupted) => {
1029                    println!("^C");
1030                    // A continued command doesn't survive an interrupt —
1031                    // same as a shell discarding a `\`-continued line.
1032                    continuation_buffer.clear();
1033                    continue;
1034                }
1035
1036                // End of input (Ctrl-D) ends the session like `:quit`.
1037                Err(ReadlineError::Eof) => {
1038                    println!();
1039                    self.end_session(true);
1040                    break;
1041                }
1042
1043                // DA-030 (#92): any other termination leaves the history
1044                // file untouched.
1045                Err(err) => {
1046                    eprintln!("Error reading input: {}", err);
1047                    break;
1048                }
1049            }
1050        }
1051
1052        Ok(())
1053    }
1054
1055    /// End the session: write the history when `save_history` is set, then
1056    /// say goodbye.
1057    fn end_session(&mut self, save_history: bool) {
1058        if save_history {
1059            self.save_history();
1060        }
1061        println!("Goodbye!");
1062    }
1063
1064    /// Feed one raw input line into the `\`-continuation buffer (#69).
1065    ///
1066    /// Pure and I/O-free by design: [`run`][Self::run] is the only caller,
1067    /// but keeping this separate from the `rustyline`-driven loop makes the
1068    /// accumulation semantics themselves unit-testable without an
1069    /// interactive terminal.
1070    ///
1071    /// # Returns
1072    ///
1073    /// - `None` — `raw_line` ended in `\`; the marker was stripped and the
1074    ///   fragment pushed onto `buffer`. The caller should read another line
1075    ///   under the continuation prompt.
1076    /// - `Some(line)` — `raw_line` did not end in `\`, completing the
1077    ///   buffer. `buffer` is drained and every fragment (in order) plus
1078    ///   `raw_line` are joined with a single space; this is the
1079    ///   reconstructed line the caller should dispatch. When `buffer` was
1080    ///   already empty, `line` is simply `raw_line.trim()` — the ordinary,
1081    ///   non-continued case.
1082    fn accumulate_line(buffer: &mut Vec<String>, raw_line: &str) -> Option<String> {
1083        let trimmed = raw_line.trim();
1084
1085        if let Some(fragment) = trimmed.strip_suffix('\\') {
1086            buffer.push(fragment.trim_end().to_string());
1087            return None;
1088        }
1089
1090        if buffer.is_empty() {
1091            return Some(trimmed.to_string());
1092        }
1093
1094        buffer.push(trimmed.to_string());
1095        Some(std::mem::take(buffer).join(" "))
1096    }
1097
1098    /// Execute a single line of input.
1099    ///
1100    /// Parses the line and executes the corresponding command.
1101    /// `--help` and `-h` requests are intercepted before dispatch.
1102    /// Directives are handled earlier, by [`handle_line`][Self::handle_line].
1103    ///
1104    /// History is written here — after successful parsing — so that:
1105    /// - Failed or invalid commands are never persisted.
1106    /// - Lines containing a `secure: true` argument are silently omitted.
1107    fn execute_line(&mut self, line: &str) -> Result<()> {
1108        if let Some(output) = self.try_handle_help(line) {
1109            print!("{}", output);
1110            return Ok(());
1111        }
1112
1113        let parser = ReplParser::new(&self.registry);
1114        let parsed = parser
1115            .parse_line(line)
1116            .map_err(|e| self.complete_unknown_command(e))?;
1117
1118        // Write to history only on successful parse and when no secure
1119        // argument is present in the parsed command.
1120        if !self.has_secure_arg(&parsed.command_name, &parsed.arguments) {
1121            let _ = self.editor.add_history_entry(line);
1122        }
1123
1124        // Sync tried first (unchanged behaviour), then async via `block_on`
1125        // per DA-022 (#8). Safe here because the REPL loop is strictly sequential
1126        // — one command finishes (readline blocks regardless) before the
1127        // next line is even read, so there is no other async task waiting
1128        // that `block_on` could starve.
1129        //
1130        // Wrapped via `from_scalars`: `ReplParser::parse_line` still
1131        // produces a plain `HashMap<String, String>`: repeatable options
1132        // have no interactive REPL-typing use case, see DD-024 (#21). Every
1133        // handler receives `&ParsedArgs`
1134        // regardless of dispatch path (#39); the REPL path just never
1135        // populates `ParsedValue::Repeated` entries.
1136        let parsed_args = ParsedArgs::from_scalars(parsed.arguments);
1137        if let Some(handler) = self.registry.get_handler_sync(&parsed.command_name) {
1138            handler.execute(&mut *self.context, &parsed_args)?;
1139        } else if let Some(handler) = self.registry.get_handler_async(&parsed.command_name) {
1140            futures::executor::block_on(handler.execute(&mut *self.context, &parsed_args))?;
1141        } else {
1142            return Err(DynamicCliError::Execution(
1143                ExecutionError::handler_not_found(&parsed.command_name, "unknown"),
1144            ));
1145        }
1146
1147        Ok(())
1148    }
1149}
1150
1151// ============================================================================
1152// Tests
1153// ============================================================================
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158    use crate::config::schema::{
1159        ArgumentDefinition, ArgumentType, CommandDefinition, OptionDefinition,
1160    };
1161    use rustyline::history::History;
1162    use std::collections::HashMap;
1163
1164    #[derive(Default)]
1165    struct TestContext {
1166        executed_commands: Vec<String>,
1167    }
1168
1169    impl ExecutionContext for TestContext {
1170        fn as_any(&self) -> &dyn std::any::Any {
1171            self
1172        }
1173        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1174            self
1175        }
1176    }
1177
1178    struct TestHandler {
1179        name: String,
1180    }
1181
1182    impl crate::executor::CommandHandler for TestHandler {
1183        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1184            let ctx = crate::context::downcast_mut::<TestContext>(context)
1185                .expect("Failed to downcast context");
1186            ctx.executed_commands.push(self.name.clone());
1187            Ok(())
1188        }
1189    }
1190
1191    fn create_test_registry() -> CommandRegistry {
1192        let mut registry = CommandRegistry::new();
1193        let cmd_def = CommandDefinition {
1194            name: "test".to_string(),
1195            aliases: vec!["t".to_string()],
1196            description: "Test command".to_string(),
1197            required: false,
1198            arguments: vec![],
1199            options: vec![],
1200            implementation: "test_handler".to_string(),
1201            continue_on_failure: false,
1202            requires_success: false,
1203        };
1204        registry
1205            .register_sync(
1206                cmd_def,
1207                Box::new(TestHandler {
1208                    name: "test".to_string(),
1209                }),
1210            )
1211            .unwrap();
1212        registry
1213    }
1214
1215    fn make_help_config() -> CommandsConfig {
1216        use crate::config::schema::{CommandsConfig, Metadata};
1217        CommandsConfig {
1218            metadata: Metadata {
1219                version: "1.0.0".to_string(),
1220                prompt: "testapp".to_string(),
1221                prompt_suffix: " > ".to_string(),
1222            },
1223            commands: vec![CommandDefinition {
1224                name: "hello".to_string(),
1225                aliases: vec!["hi".to_string()],
1226                description: "Say hello".to_string(),
1227                required: false,
1228                arguments: vec![],
1229                options: vec![OptionDefinition {
1230                    name: "loud".to_string(),
1231                    short: Some("l".to_string()),
1232                    long: Some("loud".to_string()),
1233                    option_type: ArgumentType::Bool,
1234                    required: false,
1235                    default: Some("false".to_string()),
1236                    description: "Loud greeting".to_string(),
1237                    choices: vec![],
1238                    repeatable: false,
1239                    option_parameters: HashMap::new(),
1240                }],
1241                implementation: "hello_handler".to_string(),
1242                continue_on_failure: false,
1243                requires_success: false,
1244            }],
1245            global_options: vec![],
1246            directives: vec![],
1247        }
1248    }
1249
1250    // ── Construction ──────────────────────────────────────────────────────────
1251
1252    #[test]
1253    fn test_repl_interface_creation() {
1254        let registry = create_test_registry();
1255        let context = Box::new(TestContext::default());
1256        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None);
1257        assert!(repl.is_ok());
1258    }
1259
1260    #[test]
1261    fn test_repl_interface_creation_with_config() {
1262        let registry = create_test_registry();
1263        let context = Box::new(TestContext::default());
1264        let config = make_help_config();
1265        let repl = ReplInterface::new(registry, context, "test".to_string(), Some(config), None);
1266        assert!(repl.is_ok());
1267    }
1268
1269    // ── execute_line ──────────────────────────────────────────────────────────
1270
1271    #[test]
1272    fn test_repl_execute_line() {
1273        let registry = create_test_registry();
1274        let context = Box::new(TestContext::default());
1275        let mut repl =
1276            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1277        let result = repl.execute_line("test");
1278        assert!(result.is_ok());
1279        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1280        assert_eq!(ctx.executed_commands, vec!["test".to_string()]);
1281    }
1282
1283    #[test]
1284    fn test_repl_execute_with_alias() {
1285        let registry = create_test_registry();
1286        let context = Box::new(TestContext::default());
1287        let mut repl =
1288            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1289        assert!(repl.execute_line("t").is_ok());
1290    }
1291
1292    #[test]
1293    fn test_repl_execute_unknown_command() {
1294        let registry = create_test_registry();
1295        let context = Box::new(TestContext::default());
1296        let mut repl =
1297            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1298        let result = repl.execute_line("unknown");
1299        assert!(result.is_err());
1300        match result.unwrap_err() {
1301            DynamicCliError::Parse(_) => {}
1302            other => panic!("Expected Parse error, got: {:?}", other),
1303        }
1304    }
1305
1306    #[test]
1307    fn test_repl_empty_line() {
1308        let registry = create_test_registry();
1309        let context = Box::new(TestContext::default());
1310        let mut repl =
1311            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1312        assert!(repl.execute_line("").is_err());
1313    }
1314
1315    #[test]
1316    fn test_repl_command_with_args() {
1317        let mut registry = CommandRegistry::new();
1318        let cmd_def = CommandDefinition {
1319            name: "greet".to_string(),
1320            aliases: vec![],
1321            description: "Greet someone".to_string(),
1322            required: false,
1323            arguments: vec![ArgumentDefinition {
1324                name: "name".to_string(),
1325                arg_type: ArgumentType::String,
1326                required: true,
1327                description: "Name".to_string(),
1328                validation: vec![],
1329                secure: false,
1330            }],
1331            options: vec![],
1332            implementation: "greet_handler".to_string(),
1333            continue_on_failure: false,
1334            requires_success: false,
1335        };
1336
1337        struct GreetHandler;
1338        impl crate::executor::CommandHandler for GreetHandler {
1339            fn execute(&self, _ctx: &mut dyn ExecutionContext, args: &ParsedArgs) -> Result<()> {
1340                assert_eq!(args.get_scalar("name"), Some("Alice"));
1341                Ok(())
1342            }
1343        }
1344
1345        registry
1346            .register_sync(cmd_def, Box::new(GreetHandler))
1347            .unwrap();
1348        let context = Box::new(TestContext::default());
1349        let mut repl =
1350            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1351        assert!(repl.execute_line("greet Alice").is_ok());
1352    }
1353
1354    // ── History path ──────────────────────────────────────────────────────────
1355
1356    #[test]
1357    fn test_repl_history_path() {
1358        let path = ReplInterface::get_history_path("myapp");
1359        if let Some(p) = path {
1360            let path_str = p.to_str().unwrap();
1361            assert!(path_str.contains("myapp"), "path should contain app name");
1362            assert!(
1363                path_str.ends_with("history"),
1364                "path should end with 'history', got: {}",
1365                path_str
1366            );
1367        }
1368    }
1369
1370    // ── Help interception ─────────────────────────────────────────────────────
1371
1372    #[test]
1373    fn test_try_handle_help_without_config_returns_none() {
1374        let registry = create_test_registry();
1375        let context = Box::new(TestContext::default());
1376        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1377        assert!(repl.try_handle_help("--help").is_none());
1378        assert!(repl.try_handle_help("-h").is_none());
1379    }
1380
1381    /// Regression test for #76: without an explicit formatter, help falls
1382    /// back to `DefaultHelpFormatter` instead of being skipped.
1383    #[test]
1384    fn test_try_handle_help_without_formatter_uses_default() {
1385        colored::control::set_override(false);
1386        let registry = create_test_registry();
1387        let context = Box::new(TestContext::default());
1388        let config = make_help_config();
1389        let repl =
1390            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1391
1392        let formatter = DefaultHelpFormatter::new();
1393        let expected_app = format!(
1394            "{}{}\nType ':help <command>' for more information on a command.\n",
1395            formatter.format_app(repl.config.as_deref().unwrap()),
1396            formatter.format_directives(&effective_directives(&[]))
1397        );
1398        let expected_cmd =
1399            DefaultHelpFormatter::new().format_command(repl.config.as_deref().unwrap(), "hello");
1400
1401        assert_eq!(repl.try_handle_help("--help"), Some(expected_app.clone()));
1402        assert_eq!(repl.try_handle_help("-h"), Some(expected_app));
1403        for line in ["--help hello", "-h hello", "hello --help", "hello -h"] {
1404            assert_eq!(
1405                repl.try_handle_help(line),
1406                Some(expected_cmd.clone()),
1407                "line: {line}"
1408            );
1409        }
1410    }
1411
1412    /// Regression test for #76 through the dispatch path used by `run()`:
1413    /// `--help` must be handled, not reported as an unknown command.
1414    #[test]
1415    fn test_execute_line_help_without_formatter() {
1416        colored::control::set_override(false);
1417        let registry = create_test_registry();
1418        let context = Box::new(TestContext::default());
1419        let config = make_help_config();
1420        let mut repl =
1421            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1422        assert!(repl.execute_line("--help").is_ok());
1423        assert!(repl.execute_line("hello --help").is_ok());
1424    }
1425
1426    #[test]
1427    fn test_try_handle_help_global() {
1428        colored::control::set_override(false);
1429        let registry = create_test_registry();
1430        let context = Box::new(TestContext::default());
1431        let config = make_help_config();
1432        let repl = ReplInterface::new(
1433            registry,
1434            context,
1435            "test".to_string(),
1436            Some(config),
1437            Some(Box::new(DefaultHelpFormatter::new())),
1438        )
1439        .unwrap();
1440        let out = repl.try_handle_help("--help");
1441        assert!(out.is_some());
1442        let out = out.unwrap();
1443        assert!(out.contains("testapp"));
1444        assert!(out.contains("hello"));
1445    }
1446
1447    #[test]
1448    fn test_try_handle_help_short_flag() {
1449        colored::control::set_override(false);
1450        let registry = create_test_registry();
1451        let context = Box::new(TestContext::default());
1452        let config = make_help_config();
1453        let repl = ReplInterface::new(
1454            registry,
1455            context,
1456            "test".to_string(),
1457            Some(config),
1458            Some(Box::new(DefaultHelpFormatter::new())),
1459        )
1460        .unwrap();
1461        let out = repl.try_handle_help("-h");
1462        assert!(out.is_some());
1463        assert!(out.unwrap().contains("testapp"));
1464    }
1465
1466    #[test]
1467    fn test_try_handle_help_with_command_prefix() {
1468        colored::control::set_override(false);
1469        let registry = create_test_registry();
1470        let context = Box::new(TestContext::default());
1471        let config = make_help_config();
1472        let repl = ReplInterface::new(
1473            registry,
1474            context,
1475            "test".to_string(),
1476            Some(config),
1477            Some(Box::new(DefaultHelpFormatter::new())),
1478        )
1479        .unwrap();
1480        let out = repl.try_handle_help("--help hello");
1481        assert!(out.is_some());
1482        assert!(out.unwrap().contains("hello"));
1483        let out2 = repl.try_handle_help("-h hello");
1484        assert!(out2.is_some());
1485    }
1486
1487    #[test]
1488    fn test_try_handle_help_command_suffix() {
1489        colored::control::set_override(false);
1490        let registry = create_test_registry();
1491        let context = Box::new(TestContext::default());
1492        let config = make_help_config();
1493        let repl = ReplInterface::new(
1494            registry,
1495            context,
1496            "test".to_string(),
1497            Some(config),
1498            Some(Box::new(DefaultHelpFormatter::new())),
1499        )
1500        .unwrap();
1501        let out = repl.try_handle_help("hello --help");
1502        assert!(out.is_some());
1503        assert!(out.unwrap().contains("hello"));
1504        let out2 = repl.try_handle_help("hello -h");
1505        assert!(out2.is_some());
1506    }
1507
1508    #[test]
1509    fn test_try_handle_help_alias() {
1510        colored::control::set_override(false);
1511        let registry = create_test_registry();
1512        let context = Box::new(TestContext::default());
1513        let config = make_help_config();
1514        let repl = ReplInterface::new(
1515            registry,
1516            context,
1517            "test".to_string(),
1518            Some(config),
1519            Some(Box::new(DefaultHelpFormatter::new())),
1520        )
1521        .unwrap();
1522        let out = repl.try_handle_help("--help hi");
1523        assert!(out.is_some());
1524        assert!(out.unwrap().contains("hello"));
1525    }
1526
1527    #[test]
1528    fn test_execute_line_help_intercepted() {
1529        colored::control::set_override(false);
1530        let registry = create_test_registry();
1531        let context = Box::new(TestContext::default());
1532        let config = make_help_config();
1533        let mut repl = ReplInterface::new(
1534            registry,
1535            context,
1536            "test".to_string(),
1537            Some(config),
1538            Some(Box::new(DefaultHelpFormatter::new())),
1539        )
1540        .unwrap();
1541        assert!(repl.execute_line("--help").is_ok());
1542    }
1543
1544    #[test]
1545    fn test_execute_line_normal_command_still_works_with_formatter() {
1546        let registry = create_test_registry();
1547        let context = Box::new(TestContext::default());
1548        let config = make_help_config();
1549        let mut repl = ReplInterface::new(
1550            registry,
1551            context,
1552            "test".to_string(),
1553            Some(config),
1554            Some(Box::new(DefaultHelpFormatter::new())),
1555        )
1556        .unwrap();
1557        assert!(repl.execute_line("test").is_ok());
1558    }
1559
1560    // ── Tab completion ────────────────────────────────────────────────────────
1561
1562    fn default_directives() -> Arc<[DirectiveDefinition]> {
1563        effective_directives(&[]).into()
1564    }
1565
1566    #[test]
1567    fn test_completer_commands_empty_input() {
1568        let registry = Arc::new(create_test_registry());
1569        let completer = DcliCompleter::new(Arc::clone(&registry), None, default_directives());
1570        let history = rustyline::history::DefaultHistory::new();
1571        let ctx = rustyline::Context::new(&history);
1572        let (_, candidates) = completer.complete("", 0, &ctx).unwrap();
1573        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1574        assert!(names.contains(&"test"));
1575        assert!(names.contains(&"t"));
1576    }
1577
1578    #[test]
1579    fn test_completer_commands_prefix_filter() {
1580        let registry = Arc::new(create_test_registry());
1581        let completer = DcliCompleter::new(Arc::clone(&registry), None, default_directives());
1582        let history = rustyline::history::DefaultHistory::new();
1583        let ctx = rustyline::Context::new(&history);
1584        let (_, candidates) = completer.complete("te", 2, &ctx).unwrap();
1585        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1586        assert!(names.contains(&"test"));
1587        assert!(!names.contains(&"t"));
1588    }
1589
1590    #[test]
1591    fn test_completer_flags_after_command() {
1592        let config = Arc::new(make_help_config());
1593        // Registry with "hello" command
1594        let mut registry = CommandRegistry::new();
1595        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1596        struct DummyHandler;
1597        impl crate::executor::CommandHandler for DummyHandler {
1598            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1599                Ok(())
1600            }
1601        }
1602        registry
1603            .register_sync(cmd_def, Box::new(DummyHandler))
1604            .unwrap();
1605        let registry = Arc::new(registry);
1606
1607        let completer = DcliCompleter::new(
1608            Arc::clone(&registry),
1609            Some(Arc::clone(&config)),
1610            default_directives(),
1611        );
1612        let history = rustyline::history::DefaultHistory::new();
1613        let ctx = rustyline::Context::new(&history);
1614
1615        // "hello " → should propose --loud and -l
1616        let (_, candidates) = completer.complete("hello ", 6, &ctx).unwrap();
1617        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1618        assert!(
1619            names.contains(&"--loud"),
1620            "expected --loud, got {:?}",
1621            names
1622        );
1623        assert!(names.contains(&"-l"), "expected -l, got {:?}", names);
1624    }
1625
1626    #[test]
1627    fn test_completer_flags_prefix_filter() {
1628        let config = Arc::new(make_help_config());
1629        let mut registry = CommandRegistry::new();
1630        let cmd_def = make_help_config().commands.into_iter().next().unwrap();
1631        struct DummyHandler;
1632        impl crate::executor::CommandHandler for DummyHandler {
1633            fn execute(&self, _: &mut dyn ExecutionContext, _: &ParsedArgs) -> Result<()> {
1634                Ok(())
1635            }
1636        }
1637        registry
1638            .register_sync(cmd_def, Box::new(DummyHandler))
1639            .unwrap();
1640        let registry = Arc::new(registry);
1641
1642        let completer = DcliCompleter::new(
1643            Arc::clone(&registry),
1644            Some(Arc::clone(&config)),
1645            default_directives(),
1646        );
1647        let history = rustyline::history::DefaultHistory::new();
1648        let ctx = rustyline::Context::new(&history);
1649
1650        // "hello --l" → only --loud
1651        let (_, candidates) = completer.complete("hello --l", 9, &ctx).unwrap();
1652        let names: Vec<&str> = candidates.iter().map(|p| p.display.as_str()).collect();
1653        assert!(names.contains(&"--loud"));
1654        assert!(!names.contains(&"-l"));
1655    }
1656
1657    #[test]
1658    fn test_completer_no_flags_for_unknown_command() {
1659        let config = Arc::new(make_help_config());
1660        let registry = Arc::new(create_test_registry());
1661        let completer = DcliCompleter::new(
1662            Arc::clone(&registry),
1663            Some(Arc::clone(&config)),
1664            default_directives(),
1665        );
1666        let history = rustyline::history::DefaultHistory::new();
1667        let ctx = rustyline::Context::new(&history);
1668        // "unknown " → empty (command not in registry)
1669        let (_, candidates) = completer.complete("unknown ", 8, &ctx).unwrap();
1670        assert!(candidates.is_empty());
1671    }
1672
1673    // ── has_secure_arg ────────────────────────────────────────────────────────
1674
1675    /// Build a registry + config with one command that has a `secure` argument.
1676    fn make_secure_registry_and_config() -> (CommandRegistry, CommandsConfig) {
1677        use crate::config::schema::{CommandsConfig, Metadata};
1678
1679        let cmd_def = CommandDefinition {
1680            name: "login".to_string(),
1681            aliases: vec![],
1682            description: "Login command".to_string(),
1683            required: false,
1684            arguments: vec![
1685                ArgumentDefinition {
1686                    name: "username".to_string(),
1687                    arg_type: ArgumentType::String,
1688                    required: true,
1689                    description: "Username".to_string(),
1690                    validation: vec![],
1691                    secure: false,
1692                },
1693                ArgumentDefinition {
1694                    name: "password".to_string(),
1695                    arg_type: ArgumentType::String,
1696                    required: true,
1697                    description: "Password".to_string(),
1698                    validation: vec![],
1699                    secure: true,
1700                },
1701            ],
1702            options: vec![],
1703            implementation: "login_handler".to_string(),
1704            continue_on_failure: false,
1705            requires_success: false,
1706        };
1707
1708        struct LoginHandler;
1709        impl crate::executor::CommandHandler for LoginHandler {
1710            fn execute(&self, _ctx: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
1711                Ok(())
1712            }
1713        }
1714
1715        let mut registry = CommandRegistry::new();
1716        registry
1717            .register_sync(cmd_def.clone(), Box::new(LoginHandler))
1718            .unwrap();
1719
1720        let config = CommandsConfig {
1721            metadata: Metadata {
1722                version: "1.0.0".to_string(),
1723                prompt: "testapp".to_string(),
1724                prompt_suffix: " > ".to_string(),
1725            },
1726            commands: vec![cmd_def],
1727            global_options: vec![],
1728            directives: vec![],
1729        };
1730
1731        (registry, config)
1732    }
1733
1734    #[test]
1735    fn test_has_secure_arg_returns_false_without_config() {
1736        let registry = create_test_registry();
1737        let context = Box::new(TestContext::default());
1738        let repl = ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1739
1740        let mut args = HashMap::new();
1741        args.insert("password".to_string(), "secret".to_string());
1742
1743        assert!(!repl.has_secure_arg("login", &args));
1744    }
1745
1746    #[test]
1747    fn test_has_secure_arg_returns_false_when_no_secure_field() {
1748        let registry = create_test_registry();
1749        let context = Box::new(TestContext::default());
1750        let config = make_help_config();
1751        let repl =
1752            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1753
1754        let mut args = HashMap::new();
1755        args.insert("loud".to_string(), "true".to_string());
1756
1757        assert!(!repl.has_secure_arg("hello", &args));
1758    }
1759
1760    #[test]
1761    fn test_has_secure_arg_returns_true_when_secure_argument_present() {
1762        let (registry, config) = make_secure_registry_and_config();
1763        let context = Box::new(TestContext::default());
1764        let repl =
1765            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1766
1767        let mut args = HashMap::new();
1768        args.insert("username".to_string(), "alice".to_string());
1769        args.insert("password".to_string(), "secret".to_string());
1770
1771        assert!(repl.has_secure_arg("login", &args));
1772    }
1773
1774    #[test]
1775    fn test_has_secure_arg_returns_false_when_only_non_secure_present() {
1776        let (registry, config) = make_secure_registry_and_config();
1777        let context = Box::new(TestContext::default());
1778        let repl =
1779            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1780
1781        // Only username provided — password (secure) absent from parsed args.
1782        let mut args = HashMap::new();
1783        args.insert("username".to_string(), "alice".to_string());
1784
1785        assert!(!repl.has_secure_arg("login", &args));
1786    }
1787
1788    #[test]
1789    fn test_has_secure_arg_returns_false_for_unknown_command() {
1790        let (registry, config) = make_secure_registry_and_config();
1791        let context = Box::new(TestContext::default());
1792        let repl =
1793            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1794
1795        let mut args = HashMap::new();
1796        args.insert("password".to_string(), "secret".to_string());
1797
1798        assert!(!repl.has_secure_arg("nonexistent", &args));
1799    }
1800
1801    // ── Secure argument history filtering ─────────────────────────────────────
1802
1803    #[test]
1804    fn test_execute_line_with_secure_arg_does_not_add_to_history() {
1805        let (registry, config) = make_secure_registry_and_config();
1806        let context = Box::new(TestContext::default());
1807        let mut repl =
1808            ReplInterface::new(registry, context, "test".to_string(), Some(config), None).unwrap();
1809
1810        let result = repl.execute_line("login alice secret");
1811        assert!(result.is_ok());
1812
1813        // The line must NOT appear in the in-memory history.
1814        let history = repl.editor.history();
1815        let in_history = (0..history.len()).any(|i| {
1816            history
1817                .get(i, rustyline::history::SearchDirection::Forward)
1818                .ok()
1819                .flatten()
1820                .map(|e| e.entry.as_ref() == "login alice secret")
1821                .unwrap_or(false)
1822        });
1823        assert!(
1824            !in_history,
1825            "secure command line must not be written to history"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_execute_line_without_secure_arg_adds_to_history() {
1831        let registry = create_test_registry();
1832        let context = Box::new(TestContext::default());
1833        let mut repl =
1834            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1835
1836        let result = repl.execute_line("test");
1837        assert!(result.is_ok());
1838
1839        // The line must appear in the in-memory history.
1840        let history = repl.editor.history();
1841        let in_history = (0..history.len()).any(|i| {
1842            history
1843                .get(i, rustyline::history::SearchDirection::Forward)
1844                .ok()
1845                .flatten()
1846                .map(|e| e.entry.as_ref() == "test")
1847                .unwrap_or(false)
1848        });
1849        assert!(
1850            in_history,
1851            "non-secure command line must be written to history"
1852        );
1853    }
1854
1855    // ── :load (#41 scope extension) ─────────────────────────────────────────
1856
1857    fn write_script(content: &str) -> tempfile::NamedTempFile {
1858        use std::io::Write;
1859        let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
1860        file.write_all(content.as_bytes())
1861            .expect("failed to write temp script file");
1862        file
1863    }
1864
1865    #[test]
1866    fn test_load_executes_each_line_via_handle_line() {
1867        let registry = create_test_registry();
1868        let context = Box::new(TestContext::default());
1869        let mut repl =
1870            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1871
1872        let script = write_script("test\nt\n");
1873        let line = format!(":load {}", script.path().display());
1874
1875        assert!(repl.handle_line(&line).is_ok());
1876
1877        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1878        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1879    }
1880
1881    #[test]
1882    fn test_load_skips_blank_lines_and_comments() {
1883        let registry = create_test_registry();
1884        let context = Box::new(TestContext::default());
1885        let mut repl =
1886            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1887
1888        let script = write_script("# a comment\n\ntest\n   \n# another\n");
1889        let line = format!(":load {}", script.path().display());
1890
1891        assert!(repl.handle_line(&line).is_ok());
1892
1893        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1894        assert_eq!(ctx.executed_commands, vec!["test"]);
1895    }
1896
1897    #[test]
1898    fn test_load_continues_past_a_failing_line() {
1899        let registry = create_test_registry();
1900        let context = Box::new(TestContext::default());
1901        let mut repl =
1902            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1903
1904        let script = write_script("test\nunknown_command\ntest\n");
1905        let line = format!(":load {}", script.path().display());
1906
1907        // Unlike CliInterface::run_script(Abort), :load never returns Err
1908        // just because a line inside it failed — the failure is displayed
1909        // inline and the load proceeds to the next line.
1910        let result = repl.handle_line(&line);
1911        assert!(result.is_ok());
1912
1913        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
1914        assert_eq!(ctx.executed_commands, vec!["test", "test"]);
1915    }
1916
1917    #[test]
1918    fn test_load_missing_path_argument_is_an_error() {
1919        let registry = create_test_registry();
1920        let context = Box::new(TestContext::default());
1921        let mut repl =
1922            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1923
1924        let result = repl.handle_line(":load");
1925        assert!(result.is_err());
1926    }
1927
1928    #[test]
1929    fn test_load_missing_file_is_an_error() {
1930        let registry = create_test_registry();
1931        let context = Box::new(TestContext::default());
1932        let mut repl =
1933            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1934
1935        let result = repl.handle_line(":load /nonexistent/path/to/script.txt");
1936        assert!(result.is_err());
1937    }
1938
1939    #[test]
1940    fn test_load_line_itself_is_not_added_to_history() {
1941        let registry = create_test_registry();
1942        let context = Box::new(TestContext::default());
1943        let mut repl =
1944            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
1945
1946        let script = write_script("test\n");
1947        let line = format!(":load {}", script.path().display());
1948        assert!(repl.handle_line(&line).is_ok());
1949
1950        let history = repl.editor.history();
1951        let load_in_history = (0..history.len()).any(|i| {
1952            history
1953                .get(i, rustyline::history::SearchDirection::Forward)
1954                .ok()
1955                .flatten()
1956                .map(|e| e.entry.starts_with(":load"))
1957                .unwrap_or(false)
1958        });
1959        assert!(
1960            !load_in_history,
1961            ":load line itself must not be written to history"
1962        );
1963    }
1964
1965    // ========================================================================
1966    // No accidental scope leak from CLI chaining — DD-026 (#52), #56
1967    // ========================================================================
1968
1969    #[test]
1970    fn test_repl_line_is_never_chained_across_multiple_commands() {
1971        // execute_line() (and, through it, :load) goes through
1972        // ReplParser::parse_line() -> CliParser::parse() (the scalar,
1973        // method predating DD-026 (#52)) — never CliInterface::dispatch()'s
1974        // segmentation. A line naming two registered commands back to
1975        // back must therefore still be read as ONE command whose arity
1976        // is exceeded by the second name, exactly as before chaining
1977        // existed for the CLI — not as two chained commands.
1978        let mut registry = CommandRegistry::new();
1979        for name in ["first", "second"] {
1980            let cmd_def = CommandDefinition {
1981                name: name.to_string(),
1982                aliases: vec![],
1983                description: format!("Test command {}", name),
1984                required: false,
1985                arguments: vec![ArgumentDefinition {
1986                    name: "value".to_string(),
1987                    arg_type: ArgumentType::String,
1988                    required: true,
1989                    description: "Value".to_string(),
1990                    validation: vec![],
1991                    secure: false,
1992                }],
1993                options: vec![],
1994                implementation: format!("{}_handler", name),
1995                continue_on_failure: false,
1996                requires_success: false,
1997            };
1998            registry
1999                .register_sync(
2000                    cmd_def,
2001                    Box::new(TestHandler {
2002                        name: name.to_string(),
2003                    }),
2004                )
2005                .unwrap();
2006        }
2007
2008        let context = Box::new(TestContext::default());
2009        let mut repl =
2010            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
2011
2012        // "first" has arity 1; "1" fills it, leaving "second" as a
2013        // genuine overflow — never a second, chained command.
2014        let result = repl.execute_line("first 1 second");
2015
2016        assert!(result.is_err());
2017        match result.unwrap_err() {
2018            DynamicCliError::Parse(ParseError::TooManyArguments { command, .. }) => {
2019                assert_eq!(command, "first");
2020            }
2021            other => panic!("Expected TooManyArguments error, got: {:?}", other),
2022        }
2023
2024        // Neither command actually ran — in particular, "second" was
2025        // never silently executed as a chained segment.
2026        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
2027        assert!(ctx.executed_commands.is_empty());
2028    }
2029
2030    // ========================================================================
2031    // Prompt suffix bugfix + multi-line continuation prompt — DD-027 (#48), #67
2032    // ========================================================================
2033
2034    #[test]
2035    fn test_prompt_uses_default_suffix_without_config() {
2036        let registry = create_test_registry();
2037        let context = Box::new(TestContext::default());
2038        let repl = ReplInterface::new(registry, context, "myapp".to_string(), None, None).unwrap();
2039
2040        // No config supplied: falls back to the schema's own default,
2041        // not an independently hardcoded literal.
2042        assert_eq!(repl.prompt, "myapp > ");
2043    }
2044
2045    #[test]
2046    fn test_prompt_honours_configured_suffix() {
2047        // Regression test for the bug where `config.metadata.prompt_suffix`
2048        // was silently ignored in favour of a hardcoded " > ".
2049        let registry = create_test_registry();
2050        let context = Box::new(TestContext::default());
2051        let mut config = make_help_config();
2052        config.metadata.prompt_suffix = " $ ".to_string();
2053
2054        let repl =
2055            ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
2056
2057        assert_eq!(repl.prompt, "rpn $ ");
2058    }
2059
2060    #[test]
2061    fn test_effective_prompt_multiline_default_derivation() {
2062        // The exact example from the closing checklist of DD-027 (#48): default
2063        // suffix " > " -> multi-line default "...> ".
2064        let registry = create_test_registry();
2065        let context = Box::new(TestContext::default());
2066        let repl = ReplInterface::new(registry, context, "rpn".to_string(), None, None).unwrap();
2067
2068        assert_eq!(repl.prompt, "rpn > ");
2069        assert_eq!(repl.effective_prompt_multiline(), "...> ");
2070    }
2071
2072    #[test]
2073    fn test_effective_prompt_multiline_uses_configured_suffix() {
2074        // The multi-line default must track a *custom* suffix too, not just
2075        // the " > " default — same bug class as the main prompt. Leading
2076        // whitespace of the suffix is still stripped for the default "..."
2077        // base, same as the " > " case.
2078        let registry = create_test_registry();
2079        let context = Box::new(TestContext::default());
2080        let mut config = make_help_config();
2081        config.metadata.prompt_suffix = " $ ".to_string();
2082
2083        let repl =
2084            ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None).unwrap();
2085
2086        assert_eq!(repl.effective_prompt_multiline(), "...$ ");
2087    }
2088
2089    #[test]
2090    fn test_with_prompt_multiline_overrides_base_only() {
2091        // Overriding only replaces the "..." base — the configured suffix
2092        // is still appended automatically.
2093        let registry = create_test_registry();
2094        let context = Box::new(TestContext::default());
2095        let mut config = make_help_config();
2096        config.metadata.prompt_suffix = " $ ".to_string();
2097
2098        let repl = ReplInterface::new(registry, context, "rpn".to_string(), Some(config), None)
2099            .unwrap()
2100            .with_prompt_multiline("_ _ _".to_string());
2101
2102        assert_eq!(repl.effective_prompt_multiline(), "_ _ _ $ ");
2103    }
2104
2105    // ========================================================================
2106    // `\`-continuation accumulation logic — DD-027 (#48), #69
2107    // ========================================================================
2108
2109    #[test]
2110    fn test_accumulate_line_single_line_no_continuation() {
2111        let mut buffer: Vec<String> = Vec::new();
2112        let result = ReplInterface::accumulate_line(&mut buffer, "test");
2113
2114        assert_eq!(result, Some("test".to_string()));
2115        assert!(buffer.is_empty());
2116    }
2117
2118    #[test]
2119    fn test_accumulate_line_trailing_backslash_buffers_and_returns_none() {
2120        let mut buffer: Vec<String> = Vec::new();
2121        let result = ReplInterface::accumulate_line(&mut buffer, "push \\");
2122
2123        assert_eq!(result, None);
2124        assert_eq!(buffer, vec!["push".to_string()]);
2125    }
2126
2127    #[test]
2128    fn test_accumulate_line_multi_fragment_reconstruction() {
2129        // Three lines: two continued, one final — mirrors the example in
2130        // DD-027 (#48) of a command whose options span several REPL lines.
2131        let mut buffer: Vec<String> = Vec::new();
2132
2133        assert_eq!(
2134            ReplInterface::accumulate_line(&mut buffer, "config \\"),
2135            None
2136        );
2137        assert_eq!(
2138            ReplInterface::accumulate_line(&mut buffer, "--format model \\"),
2139            None
2140        );
2141        let result = ReplInterface::accumulate_line(&mut buffer, "source=model.yml");
2142
2143        assert_eq!(
2144            result,
2145            Some("config --format model source=model.yml".to_string())
2146        );
2147        // The buffer is drained once the command completes.
2148        assert!(buffer.is_empty());
2149    }
2150
2151    #[test]
2152    fn test_accumulate_line_strips_marker_and_surrounding_whitespace() {
2153        // The `\` marker and the whitespace immediately before it are never
2154        // part of the reconstructed line — fragments are joined by exactly
2155        // one space, not whatever spacing the user typed.
2156        let mut buffer: Vec<String> = Vec::new();
2157        ReplInterface::accumulate_line(&mut buffer, "test   \\");
2158        let result = ReplInterface::accumulate_line(&mut buffer, "  arg");
2159
2160        assert_eq!(result, Some("test arg".to_string()));
2161    }
2162
2163    #[test]
2164    fn test_run_multiline_reconstruction_reaches_execute_line_once() {
2165        // Simulates what `run()` does with each `readline()` result,
2166        // without needing a real terminal: feed the same raw lines a real
2167        // session would produce, dispatch only once the buffer completes,
2168        // exactly as `run()` itself does.
2169        let registry = create_test_registry();
2170        let context = Box::new(TestContext::default());
2171        let mut repl =
2172            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
2173
2174        let mut buffer: Vec<String> = Vec::new();
2175        assert_eq!(ReplInterface::accumulate_line(&mut buffer, "te \\"), None);
2176        let line = ReplInterface::accumulate_line(&mut buffer, "st").unwrap();
2177        assert_eq!(line, "te st");
2178
2179        // "te st" isn't a registered command name — proves the reconstructed
2180        // line, not raw fragments, is what actually gets dispatched.
2181        let result = repl.execute_line(&line);
2182        assert!(result.is_err());
2183    }
2184
2185    #[test]
2186    fn test_load_line_ending_in_backslash_is_not_continued() {
2187        // :load bypasses run()'s accumulation buffer entirely — each script
2188        // line goes straight to handle_line(), one at a time. A trailing
2189        // `\` in a script has no special meaning there: DD-027 (#48) scopes
2190        // continuation to the interactive `run()` loop only.
2191        let registry = create_test_registry();
2192        let context = Box::new(TestContext::default());
2193        let mut repl =
2194            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
2195
2196        let script = write_script("test \\\ntest\n");
2197        let line = format!(":load {}", script.path().display());
2198
2199        // The first line ("test \") is dispatched as-is — "\" is an
2200        // unexpected extra positional argument for the zero-arity "test"
2201        // command, not silently joined with the next line.
2202        assert!(repl.handle_line(&line).is_ok());
2203
2204        let ctx = crate::context::downcast_ref::<TestContext>(&*repl.context).unwrap();
2205        // Only the second, unadorned "test" line actually executed.
2206        assert_eq!(ctx.executed_commands, vec!["test"]);
2207    }
2208
2209    // ── Directives (#94) ─────────────────────────────────────────────────────
2210
2211    use crate::config::directive::DirectiveDefinition;
2212
2213    fn repl_with(
2214        config: Option<CommandsConfig>,
2215        formatter: Option<Box<dyn HelpFormatter>>,
2216    ) -> ReplInterface {
2217        let registry = create_test_registry();
2218        let context = Box::new(TestContext::default());
2219        ReplInterface::new(registry, context, "test".to_string(), config, formatter).unwrap()
2220    }
2221
2222    /// `make_help_config()` with the given directive overrides
2223    fn config_with_directives(directives: Vec<DirectiveDefinition>) -> CommandsConfig {
2224        let mut config = make_help_config();
2225        config.directives = directives;
2226        config
2227    }
2228
2229    fn override_directive(
2230        implementation: ReplDirective,
2231        name: &str,
2232        aliases: &[&str],
2233    ) -> DirectiveDefinition {
2234        DirectiveDefinition {
2235            implementation,
2236            name: name.to_string(),
2237            aliases: aliases.iter().map(|a| a.to_string()).collect(),
2238            description: "Custom".to_string(),
2239        }
2240    }
2241
2242    fn history_len(repl: &ReplInterface) -> usize {
2243        repl.editor.history().len()
2244    }
2245
2246    fn executed(repl: &ReplInterface) -> Vec<String> {
2247        crate::context::downcast_ref::<TestContext>(&*repl.context)
2248            .unwrap()
2249            .executed_commands
2250            .clone()
2251    }
2252
2253    /// Unwrap an `InvalidSyntax` error into `(details, hint)`
2254    fn invalid_syntax(result: Result<SessionControl>) -> (String, String) {
2255        match result {
2256            Err(DynamicCliError::Parse(ParseError::InvalidSyntax { details, hint })) => {
2257                (details, hint.unwrap_or_default())
2258            }
2259            other => panic!("Expected InvalidSyntax error, got {:?}", other),
2260        }
2261    }
2262
2263    /// Unwrap an `UnknownCommand` error into its suggestions
2264    fn unknown_command_suggestions(result: Result<SessionControl>) -> Vec<String> {
2265        match result {
2266            Err(DynamicCliError::Parse(ParseError::UnknownCommand { suggestions, .. })) => {
2267                suggestions
2268            }
2269            other => panic!("Expected UnknownCommand error, got {:?}", other),
2270        }
2271    }
2272
2273    /// Unwrap an `UnknownCommand` error into its hint
2274    fn unknown_command_hint(result: Result<SessionControl>) -> String {
2275        match result {
2276            Err(DynamicCliError::Parse(ParseError::UnknownCommand { hint, .. })) => {
2277                hint.expect("the REPL sets the hint")
2278            }
2279            other => panic!("Expected UnknownCommand error, got {:?}", other),
2280        }
2281    }
2282
2283    #[test]
2284    fn test_directive_table_defaults_without_config() {
2285        let repl = repl_with(None, None);
2286        assert_eq!(&repl.directives[..], effective_directives(&[]).as_slice());
2287        assert_eq!(repl.resolve_directive("q"), Some(ReplDirective::Quit));
2288        assert_eq!(repl.resolve_directive("?"), Some(ReplDirective::Help));
2289        assert_eq!(repl.resolve_directive("test"), None);
2290    }
2291
2292    #[test]
2293    fn test_directive_help_forms_without_formatter() {
2294        colored::control::set_override(false);
2295        let mut repl = repl_with(Some(make_help_config()), None);
2296        for line in [
2297            ":help",
2298            ":h",
2299            ":?",
2300            ":help hello",
2301            ":h hi",
2302            "  :help  hello  ",
2303        ] {
2304            assert_eq!(
2305                repl.handle_line(line).unwrap(),
2306                SessionControl::Continue,
2307                "{}",
2308                line
2309            );
2310        }
2311    }
2312
2313    #[test]
2314    fn test_directive_help_forms_with_formatter() {
2315        colored::control::set_override(false);
2316        let mut repl = repl_with(
2317            Some(make_help_config()),
2318            Some(Box::new(DefaultHelpFormatter::new())),
2319        );
2320        for line in [":help", ":h", ":?", ":help hello"] {
2321            assert_eq!(
2322                repl.handle_line(line).unwrap(),
2323                SessionControl::Continue,
2324                "{}",
2325                line
2326            );
2327        }
2328    }
2329
2330    #[test]
2331    fn test_directive_help_renders_like_help_flag() {
2332        colored::control::set_override(false);
2333        let repl = repl_with(Some(make_help_config()), None);
2334        assert_eq!(repl.render_help(None), repl.try_handle_help("--help"));
2335        assert_eq!(
2336            repl.render_help(Some("hello")),
2337            repl.try_handle_help("--help hello")
2338        );
2339    }
2340
2341    // ── Help hints (#86) ─────────────────────────────────────────────────────
2342
2343    #[test]
2344    fn test_unknown_command_hint_names_help_directive() {
2345        let mut repl = repl_with(Some(make_help_config()), None);
2346        assert_eq!(
2347            unknown_command_hint(repl.handle_line("frobnicate")),
2348            "Type ':help' for available commands"
2349        );
2350    }
2351
2352    #[test]
2353    fn test_unknown_command_hint_without_config() {
2354        // The hint does not depend on the configuration.
2355        let mut repl = repl_with(None, None);
2356        assert_eq!(
2357            unknown_command_hint(repl.handle_line("frobnicate")),
2358            "Type ':help' for available commands"
2359        );
2360    }
2361
2362    #[test]
2363    fn test_unknown_command_hint_with_directive_suggestion() {
2364        colored::control::set_override(false);
2365        let mut repl = repl_with(Some(make_help_config()), None);
2366        let error = repl.handle_line("quit").unwrap_err();
2367        let formatted = crate::error::format_error(&error);
2368
2369        let suggestion_at = formatted.find(":quit").expect("directive suggested");
2370        let hint_at = formatted
2371            .find("Type ':help' for available commands")
2372            .expect("hint shown");
2373        assert!(suggestion_at < hint_at, "{}", formatted);
2374    }
2375
2376    #[test]
2377    fn test_help_hints_use_translated_help_directive() {
2378        colored::control::set_override(false);
2379        let mut repl = repl_with(
2380            Some(config_with_directives(vec![override_directive(
2381                ReplDirective::Help,
2382                "aide",
2383                &["?"],
2384            )])),
2385            None,
2386        );
2387
2388        assert_eq!(
2389            unknown_command_hint(repl.handle_line("frobnicate")),
2390            "Type ':aide' for available commands"
2391        );
2392
2393        let app_help = repl.render_help(None).unwrap();
2394        assert!(
2395            app_help.ends_with("\nType ':aide <command>' for more information on a command.\n"),
2396            "{}",
2397            app_help
2398        );
2399        assert!(!app_help.contains(":help"), "{}", app_help);
2400    }
2401
2402    #[test]
2403    fn test_app_help_footer_names_help_directive() {
2404        colored::control::set_override(false);
2405        let repl = repl_with(Some(make_help_config()), None);
2406        let config = repl.config.as_deref().unwrap();
2407
2408        let formatter = DefaultHelpFormatter::new();
2409        assert_eq!(
2410            repl.render_help(None).unwrap(),
2411            format!(
2412                "{}{}\nType ':help <command>' for more information on a command.\n",
2413                formatter.format_app(config),
2414                formatter.format_directives(&repl.directives)
2415            )
2416        );
2417        // Per-command help has no footer.
2418        assert_eq!(
2419            repl.render_help(Some("hello")).unwrap(),
2420            DefaultHelpFormatter::new().format_command(config, "hello")
2421        );
2422    }
2423
2424    #[test]
2425    fn test_directive_help_without_config_is_not_available() {
2426        let mut repl = repl_with(None, None);
2427        let err = repl.handle_line(":help").unwrap_err();
2428        assert!(err.to_string().contains("help is not available"), "{}", err);
2429    }
2430
2431    #[test]
2432    fn test_directive_help_with_two_arguments_is_a_usage_error() {
2433        let mut repl = repl_with(Some(make_help_config()), None);
2434        let (details, hint) = invalid_syntax(repl.handle_line(":h hello extra"));
2435        assert!(details.contains("`:h`"), "{}", details);
2436        assert_eq!(hint, "Usage: :help [command]");
2437    }
2438
2439    #[test]
2440    fn test_directive_quit_and_exit_leave() {
2441        let mut repl = repl_with(None, None);
2442        let save = SessionControl::Leave { save_history: true };
2443        let discard = SessionControl::Leave {
2444            save_history: false,
2445        };
2446        assert_eq!(repl.handle_line(":quit").unwrap(), save);
2447        assert_eq!(repl.handle_line(":q").unwrap(), save);
2448        assert_eq!(repl.handle_line(":exit").unwrap(), discard);
2449    }
2450
2451    #[test]
2452    fn test_directive_quit_with_argument_is_a_usage_error() {
2453        let mut repl = repl_with(None, None);
2454        let (details, hint) = invalid_syntax(repl.handle_line(":q now"));
2455        assert!(details.contains("takes no argument"), "{}", details);
2456        assert_eq!(hint, "Usage: :quit");
2457    }
2458
2459    #[test]
2460    fn test_directive_load_without_path_shows_usage() {
2461        let mut repl = repl_with(None, None);
2462        let (details, hint) = invalid_syntax(repl.handle_line(":load"));
2463        assert!(details.contains("requires a file path"), "{}", details);
2464        assert_eq!(hint, "Usage: :load <path>");
2465    }
2466
2467    // rustyline's `save_history()` sets the process-wide umask to 0o177
2468    // while it creates the history file. A directory created meanwhile by
2469    // another test thread, such as a `tempfile::tempdir()`, loses its
2470    // search bit, and creating a file inside it fails with EACCES. The
2471    // tests below create a directory or save the history, so each one holds
2472    // this lock for its whole body.
2473    static HISTORY_FILE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2474
2475    fn history_file_lock() -> std::sync::MutexGuard<'static, ()> {
2476        HISTORY_FILE_LOCK
2477            .lock()
2478            .unwrap_or_else(|poisoned| poisoned.into_inner())
2479    }
2480
2481    #[test]
2482    fn test_end_session_with_save_writes_history_file() {
2483        let _lock = history_file_lock();
2484        let dir = tempfile::tempdir().unwrap();
2485        let path = dir.path().join("history");
2486        let mut repl = repl_with(None, None);
2487        repl.history_path = Some(path.clone());
2488
2489        assert!(repl.handle_line("test").is_ok());
2490        repl.end_session(true);
2491
2492        let content = std::fs::read_to_string(&path).unwrap();
2493        assert!(content.lines().any(|l| l == "test"), "{}", content);
2494    }
2495
2496    #[test]
2497    fn test_end_session_without_save_leaves_history_file_unchanged() {
2498        let _lock = history_file_lock();
2499        let dir = tempfile::tempdir().unwrap();
2500        let path = dir.path().join("history");
2501        std::fs::write(&path, "previous\n").unwrap();
2502        let mut repl = repl_with(None, None);
2503        repl.history_path = Some(path.clone());
2504
2505        assert!(repl.handle_line("test").is_ok());
2506        repl.end_session(false);
2507
2508        assert_eq!(std::fs::read_to_string(&path).unwrap(), "previous\n");
2509    }
2510
2511    #[test]
2512    fn test_drop_does_not_write_history_file() {
2513        let _lock = history_file_lock();
2514        let dir = tempfile::tempdir().unwrap();
2515        let path = dir.path().join("history");
2516        let mut repl = repl_with(None, None);
2517        repl.history_path = Some(path.clone());
2518
2519        assert!(repl.handle_line("test").is_ok());
2520        drop(repl);
2521
2522        assert!(!path.exists());
2523    }
2524
2525    #[test]
2526    fn test_directive_lines_are_not_added_to_history() {
2527        colored::control::set_override(false);
2528        let mut repl = repl_with(Some(make_help_config()), None);
2529        // `new()` loads the history file of the "test" prompt, if any.
2530        let before = history_len(&repl);
2531        for line in [":help", ":quit", ":exit", ":unknown"] {
2532            let _ = repl.handle_line(line);
2533        }
2534        assert_eq!(history_len(&repl), before);
2535    }
2536
2537    #[test]
2538    fn test_bare_quit_and_exit_no_longer_leave() {
2539        let mut repl = repl_with(None, None);
2540
2541        let suggestions = unknown_command_suggestions(repl.handle_line("quit"));
2542        assert_eq!(suggestions.first().map(String::as_str), Some(":quit"));
2543
2544        let suggestions = unknown_command_suggestions(repl.handle_line("exit"));
2545        assert_eq!(suggestions.first().map(String::as_str), Some(":exit"));
2546
2547        let suggestions = unknown_command_suggestions(repl.handle_line("q"));
2548        assert_eq!(suggestions.first().map(String::as_str), Some(":q"));
2549    }
2550
2551    #[test]
2552    fn test_unknown_command_unrelated_to_directives_gets_no_directive() {
2553        let mut repl = repl_with(None, None);
2554        let suggestions = unknown_command_suggestions(repl.handle_line("frobnicate"));
2555        assert!(suggestions.iter().all(|s| !s.starts_with(':')));
2556    }
2557
2558    #[test]
2559    fn test_application_command_named_like_a_directive_runs() {
2560        let mut registry = create_test_registry();
2561        let exit = CommandDefinition {
2562            name: "exit".to_string(),
2563            aliases: vec![],
2564            description: "Application exit command".to_string(),
2565            required: false,
2566            arguments: vec![],
2567            options: vec![],
2568            implementation: "exit_handler".to_string(),
2569            continue_on_failure: false,
2570            requires_success: false,
2571        };
2572        registry
2573            .register_sync(
2574                exit,
2575                Box::new(TestHandler {
2576                    name: "exit".to_string(),
2577                }),
2578            )
2579            .unwrap();
2580        let context = Box::new(TestContext::default());
2581        let mut repl =
2582            ReplInterface::new(registry, context, "test".to_string(), None, None).unwrap();
2583
2584        assert_eq!(repl.handle_line("exit").unwrap(), SessionControl::Continue);
2585        assert_eq!(executed(&repl), vec!["exit"]);
2586        // The directive stays reachable with its prefix.
2587        assert_eq!(
2588            repl.handle_line(":exit").unwrap(),
2589            SessionControl::Leave {
2590                save_history: false
2591            }
2592        );
2593    }
2594
2595    #[test]
2596    fn test_translated_directive_names_replace_defaults() {
2597        let mut repl = repl_with(
2598            Some(config_with_directives(vec![
2599                override_directive(ReplDirective::Quit, "quitter", &[]),
2600                override_directive(ReplDirective::Load, "charger", &["c"]),
2601            ])),
2602            None,
2603        );
2604
2605        assert_eq!(
2606            repl.handle_line(":quitter").unwrap(),
2607            SessionControl::Leave { save_history: true }
2608        );
2609        // `quit` and its default alias `q` no longer match.
2610        assert!(repl.handle_line(":quit").is_err());
2611        assert!(repl.handle_line(":q").is_err());
2612        // Untouched directives keep their defaults.
2613        assert_eq!(
2614            repl.handle_line(":exit").unwrap(),
2615            SessionControl::Leave {
2616                save_history: false
2617            }
2618        );
2619
2620        let script = write_script("test\n");
2621        let line = format!(":c {}", script.path().display());
2622        assert!(repl.handle_line(&line).is_ok());
2623        assert_eq!(executed(&repl), vec!["test"]);
2624
2625        let (_, hint) = invalid_syntax(repl.handle_line(":charger"));
2626        assert_eq!(hint, "Usage: :charger <path>");
2627
2628        let suggestions = unknown_command_suggestions(repl.handle_line("quitter"));
2629        assert_eq!(suggestions.first().map(String::as_str), Some(":quitter"));
2630        // `quit` is no longer a directive name: no directive suggested.
2631        let suggestions = unknown_command_suggestions(repl.handle_line("quit"));
2632        assert!(!suggestions.iter().any(|s| s == ":quit"));
2633    }
2634
2635    #[test]
2636    fn test_unknown_directive_lists_effective_directives() {
2637        let mut repl = repl_with(
2638            Some(config_with_directives(vec![override_directive(
2639                ReplDirective::Help,
2640                "aide",
2641                &[],
2642            )])),
2643            None,
2644        );
2645
2646        let (details, hint) = invalid_syntax(repl.handle_line(":foo"));
2647        assert_eq!(details, "Unknown directive ':foo'");
2648        assert_eq!(hint, "Available directives: :aide, :load, :quit, :exit");
2649
2650        let (details, _) = invalid_syntax(repl.handle_line(":"));
2651        assert_eq!(details, "Unknown directive ':'");
2652    }
2653
2654    // ── Directive discoverability (#95) ──────────────────────────────────────
2655
2656    /// Display strings and start offset of the completion of `line`, with
2657    /// the cursor at its end.
2658    fn complete_with(directives: Arc<[DirectiveDefinition]>, line: &str) -> (usize, Vec<String>) {
2659        let completer = DcliCompleter::new(Arc::new(create_test_registry()), None, directives);
2660        let history = rustyline::history::DefaultHistory::new();
2661        let ctx = rustyline::Context::new(&history);
2662        let (start, candidates) = completer.complete(line, line.len(), &ctx).unwrap();
2663        (
2664            start,
2665            candidates
2666                .into_iter()
2667                .map(|pair| pair.replacement)
2668                .collect(),
2669        )
2670    }
2671
2672    #[test]
2673    fn test_completer_colon_lists_all_directives() {
2674        let (start, names) = complete_with(default_directives(), ":");
2675        assert_eq!(start, 0);
2676        assert_eq!(
2677            names,
2678            vec![":?", ":exit", ":h", ":help", ":load", ":q", ":quit"]
2679        );
2680    }
2681
2682    #[test]
2683    fn test_completer_directive_prefix() {
2684        assert_eq!(complete_with(default_directives(), ":he").1, vec![":help"]);
2685        assert_eq!(
2686            complete_with(default_directives(), ":q").1,
2687            vec![":q", ":quit"]
2688        );
2689        assert!(complete_with(default_directives(), ":zz").1.is_empty());
2690        // Leading spaces: the replacement starts at the token.
2691        assert_eq!(
2692            complete_with(default_directives(), "  :lo"),
2693            (2, vec![":load".to_string()])
2694        );
2695    }
2696
2697    #[test]
2698    fn test_completer_directives_follow_configuration() {
2699        let table: Arc<[DirectiveDefinition]> =
2700            effective_directives(&[override_directive(ReplDirective::Help, "aide", &["a"])]).into();
2701        assert_eq!(
2702            complete_with(Arc::clone(&table), ":a").1,
2703            vec![":a", ":aide"]
2704        );
2705        assert!(complete_with(table, ":h").1.is_empty());
2706    }
2707
2708    #[test]
2709    fn test_completer_commands_unchanged_by_directives() {
2710        // No `:` candidates without a leading `:`; directive arguments are
2711        // not completed.
2712        let (_, names) = complete_with(default_directives(), "");
2713        assert!(names.iter().all(|n| !n.starts_with(':')), "{:?}", names);
2714        assert!(names.contains(&"test".to_string()));
2715        assert!(complete_with(default_directives(), ":help ").1.is_empty());
2716    }
2717
2718    #[test]
2719    fn test_completer_shares_repl_directive_table() {
2720        let repl = repl_with(
2721            Some(config_with_directives(vec![override_directive(
2722                ReplDirective::Quit,
2723                "quitter",
2724                &[],
2725            )])),
2726            None,
2727        );
2728        let helper = repl.editor.helper().expect("helper installed");
2729        assert!(Arc::ptr_eq(&helper.completer.directives, &repl.directives));
2730    }
2731
2732    #[test]
2733    fn test_app_help_lists_directives_before_footer() {
2734        colored::control::set_override(false);
2735        let repl = repl_with(Some(make_help_config()), None);
2736        let help = repl.render_help(None).unwrap();
2737
2738        let commands_at = help.find("COMMANDS:").expect("commands");
2739        let directives_at = help.find("DIRECTIVES:").expect("directives");
2740        let footer_at = help.find("Type ':help <command>'").expect("footer");
2741        assert!(
2742            commands_at < directives_at && directives_at < footer_at,
2743            "{}",
2744            help
2745        );
2746        for usage in [":help [command]", ":load <path>", ":quit", ":exit"] {
2747            assert!(help.contains(usage), "{}", help);
2748        }
2749    }
2750
2751    #[test]
2752    fn test_app_help_lists_translated_directives() {
2753        colored::control::set_override(false);
2754        let repl = repl_with(
2755            Some(config_with_directives(vec![
2756                override_directive(ReplDirective::Help, "aide", &["?"]),
2757                override_directive(ReplDirective::Load, "charger", &[]),
2758            ])),
2759            None,
2760        );
2761        let help = repl.render_help(None).unwrap();
2762
2763        assert!(help.contains(":aide [command]"), "{}", help);
2764        assert!(help.contains("[aliases: :?]"), "{}", help);
2765        assert!(help.contains(":charger <path>"), "{}", help);
2766        assert!(!help.contains(":help"), "{}", help);
2767        assert!(!help.contains(":load"), "{}", help);
2768        // `--help` in the REPL renders the same output.
2769        assert_eq!(Some(help), repl.try_handle_help("--help"));
2770    }
2771
2772    #[test]
2773    fn test_app_help_uses_custom_directive_listing() {
2774        struct ListingFormatter;
2775        impl HelpFormatter for ListingFormatter {
2776            fn format_app(&self, _: &CommandsConfig) -> String {
2777                "APP\n".to_string()
2778            }
2779            fn format_command(&self, _: &CommandsConfig, command: &str) -> String {
2780                command.to_string()
2781            }
2782            fn format_directives(&self, directives: &[DirectiveDefinition]) -> String {
2783                let names: Vec<&str> = directives.iter().map(|d| d.name.as_str()).collect();
2784                format!("DIR {}\n", names.join(","))
2785            }
2786        }
2787
2788        colored::control::set_override(false);
2789        let repl = repl_with(Some(make_help_config()), Some(Box::new(ListingFormatter)));
2790        assert_eq!(
2791            repl.render_help(None).unwrap(),
2792            "APP\nDIR help,load,quit,exit\n\
2793             \nType ':help <command>' for more information on a command.\n"
2794        );
2795    }
2796
2797    #[test]
2798    fn test_command_help_has_no_directive_list() {
2799        colored::control::set_override(false);
2800        let repl = repl_with(Some(make_help_config()), None);
2801        let help = repl.render_help(Some("hello")).unwrap();
2802        assert!(!help.contains("DIRECTIVES:"), "{}", help);
2803    }
2804
2805    #[test]
2806    fn test_load_refuses_leaving_directives_and_runs_other_lines() {
2807        colored::control::set_override(false);
2808        let mut repl = repl_with(Some(make_help_config()), None);
2809
2810        let script = write_script("test\n:quit\n:q\n:exit\n:help\ntest\n");
2811        let line = format!(":load {}", script.path().display());
2812
2813        assert_eq!(repl.handle_line(&line).unwrap(), SessionControl::Continue);
2814        assert_eq!(executed(&repl), vec!["test", "test"]);
2815    }
2816}