Skip to main content

dynamic_cli/interface/
cli.rs

1//! CLI (Command-Line Interface) implementation
2//!
3//! This module provides a simple CLI interface that parses command-line
4//! arguments, executes the corresponding command, and exits.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use dynamic_cli::interface::CliInterface;
10//! use dynamic_cli::prelude::*;
11//!
12//! # #[derive(Default)]
13//! # struct MyContext;
14//! # impl ExecutionContext for MyContext {
15//! #     fn as_any(&self) -> &dyn std::any::Any { self }
16//! #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
17//! # }
18//! # fn main() -> dynamic_cli::Result<()> {
19//! let registry = CommandRegistry::new();
20//! let context = Box::new(MyContext::default());
21//!
22//! let cli = CliInterface::new(registry, context);
23//! cli.run(std::env::args().skip(1).collect())?;
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::context::ExecutionContext;
29use crate::error::{display_error, format_error, DynamicCliError, ExecutionError, Result};
30use crate::parser::{CliParser, ParsedArgs, ReplParser};
31use crate::registry::CommandRegistry;
32use std::path::Path;
33use std::process;
34
35/// Hint attached to an unknown command: in CLI mode, `--help` lists the
36/// commands.
37const COMMANDS_HINT: &str = "Run with '--help' for available commands";
38
39/// One resolved, fully-parsed command within a (possibly single-command)
40/// chain (#52) — the unit [`CliInterface::segment`] produces and
41/// [`CliInterface::execute_segment`] consumes.
42///
43/// `name` is owned rather than borrowed from the registry: `resolve_name`
44/// / `get_definition` are cheap, stateless lookups (no per-name state to
45/// track across a chain — the same name may legitimately appear more
46/// than once), so re-resolving by owned `String` at execution time avoids
47/// tying this struct to the registry's borrow for the whole dispatch.
48#[derive(Debug)]
49struct ResolvedSegment {
50    /// Canonical (alias-resolved) command name.
51    name: String,
52    /// Already-typed, already-validated arguments for this command.
53    parsed: ParsedArgs,
54}
55
56/// CLI (Command-Line Interface) handler
57///
58/// Provides a simple interface for executing commands from command-line arguments.
59/// The CLI parses arguments, executes the command, and exits.
60///
61/// # Architecture
62///
63/// ```text
64/// Command-line args → CliParser → CommandExecutor → Handler
65///                                       ↓
66///                                  ExecutionContext
67/// ```
68///
69/// # Error Handling
70///
71/// Errors are displayed to stderr with colored formatting (if enabled)
72/// and the process exits with appropriate exit codes:
73/// - `0`: Success
74/// - `1`: Execution error
75/// - `2`: Argument parsing error
76/// - `3`: Other errors
77pub struct CliInterface {
78    /// Command registry containing all available commands
79    registry: CommandRegistry,
80
81    /// Execution context (owned by the interface)
82    context: Box<dyn ExecutionContext>,
83}
84
85impl CliInterface {
86    /// Create a new CLI interface
87    ///
88    /// # Arguments
89    ///
90    /// * `registry` - Command registry with all registered commands
91    /// * `context` - Execution context (will be consumed by the interface)
92    ///
93    /// # Example
94    ///
95    /// ```no_run
96    /// use dynamic_cli::interface::CliInterface;
97    /// use dynamic_cli::prelude::*;
98    ///
99    /// # #[derive(Default)]
100    /// # struct MyContext;
101    /// # impl ExecutionContext for MyContext {
102    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
103    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
104    /// # }
105    /// let registry = CommandRegistry::new();
106    /// let context = Box::new(MyContext::default());
107    ///
108    /// let cli = CliInterface::new(registry, context);
109    /// ```
110    pub fn new(registry: CommandRegistry, context: Box<dyn ExecutionContext>) -> Self {
111        Self { registry, context }
112    }
113
114    /// Run the CLI with provided arguments
115    ///
116    /// Parses the arguments, executes the corresponding command, and handles errors.
117    /// This method consumes `self` as the CLI typically runs once and exits.
118    ///
119    /// # Arguments
120    ///
121    /// * `args` - Command-line arguments (typically from `env::args().skip(1)`)
122    ///
123    /// # Returns
124    ///
125    /// - `Ok(())` on success
126    /// - `Err(DynamicCliError)` on any error (parsing, validation, execution)
127    ///
128    /// # Exit Codes
129    ///
130    /// The caller should handle errors and exit with appropriate codes:
131    /// - Parse errors → exit code 2
132    /// - Execution errors → exit code 1
133    /// - Other errors → exit code 3
134    ///
135    /// # Example
136    ///
137    /// ```no_run
138    /// use dynamic_cli::interface::CliInterface;
139    /// use dynamic_cli::prelude::*;
140    /// use std::process;
141    ///
142    /// # #[derive(Default)]
143    /// # struct MyContext;
144    /// # impl ExecutionContext for MyContext {
145    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
146    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
147    /// # }
148    /// # fn main() {
149    /// let registry = CommandRegistry::new();
150    /// let context = Box::new(MyContext::default());
151    /// let cli = CliInterface::new(registry, context);
152    ///
153    /// if let Err(e) = cli.run(std::env::args().skip(1).collect()) {
154    ///     eprintln!("Error: {}", e);
155    ///     process::exit(1);
156    /// }
157    /// # }
158    /// ```
159    pub fn run(mut self, args: Vec<String>) -> Result<()> {
160        // Handle empty arguments (show help or error)
161        if args.is_empty() {
162            return Err(DynamicCliError::Parse(
163                crate::error::ParseError::InvalidSyntax {
164                    details: "No command specified".to_string(),
165                    hint: Some("Try 'help' to see available commands".to_string()),
166                },
167            ));
168        }
169
170        self.dispatch(&args)
171    }
172
173    /// Resolve, parse, and execute an already-tokenized command line — one
174    /// or more chained commands (#52).
175    ///
176    /// Shared by [`run`][Self::run] (one dispatch from CLI args) and
177    /// [`run_script`][Self::run_script] (one dispatch per script line) —
178    /// the actual resolution/parsing/execution logic lives here exactly
179    /// once, so script lines go through the same `ParsedArgs` path as
180    /// command-line arguments, with no duplicate parsing logic (see #41).
181    /// `run_script()`
182    /// gains chaining for free through this shared method, with no code
183    /// change of its own.
184    ///
185    /// [`Self::segment`] resolves and parses every command in the line up
186    /// front (so a genuinely too-long single command still errors exactly
187    /// as before chaining existed). A single, non-chained command
188    /// (`total == 1`) then executes through the exact pre-#55/#56 path —
189    /// no chain-position wrapping, no skip bookkeeping, identical error
190    /// variants for existing callers to match on. Two or more segments go
191    /// through [`Self::execute_chain`], which applies each command's
192    /// `continue_on_failure`/`requires_success` settings.
193    fn dispatch(&mut self, args: &[String]) -> Result<()> {
194        let segments = self.segment(args)?;
195
196        if segments.len() == 1 {
197            return self.execute_segment(&segments[0]);
198        }
199
200        self.execute_chain(&segments)
201    }
202
203    /// Execute two or more already-resolved segments, applying the chain
204    /// failure policy (#56).
205    ///
206    /// A running `chain_has_failure` flag, set on the first segment that
207    /// fails (regardless of that segment's own `continue_on_failure`),
208    /// drives two things for every later segment:
209    ///
210    /// - If the segment's `requires_success` is `true` and a failure has
211    ///   already occurred anywhere earlier in the chain, it is **skipped**
212    ///   — not executed, not counted as an additional failure — and
213    ///   reported with `Skipped: command {n}/{total} ('{name}') — a
214    ///   preceding command failed`, printed immediately (there is no
215    ///   other way to surface a skip, since it never produces an `Err`).
216    /// - Otherwise the segment executes as usual
217    ///   ([`Self::execute_segment`]). On failure, the error is wrapped
218    ///   with its chain position (`Error in command {n}/{total}
219    ///   ('{name}'): {existing format_error output}`, reusing
220    ///   `format_error`/`display_error` — no change to
221    ///   `error/display.rs`). If this segment's own `continue_on_failure`
222    ///   is `false`, the chain stops here. Either way, only the *first*
223    ///   failure's wrapped error is kept as the chain's outcome — a later
224    ///   failure (whether it stops the chain or is itself absorbed) never
225    ///   overwrites it, so the exit code always reflects the triggering
226    ///   failure.
227    ///
228    /// Deliberately not printed as it happens: unlike a skip, a failure's
229    /// wrapped message reaches the user exactly once, through the normal
230    /// `Err` return path (either immediately here, or from the caller
231    /// once this method returns it at the end of the loop) — printing it
232    /// again here would double it up.
233    fn execute_chain(&mut self, segments: &[ResolvedSegment]) -> Result<()> {
234        let total = segments.len();
235        let mut chain_has_failure = false;
236        let mut triggering_failure: Option<DynamicCliError> = None;
237
238        for (idx, segment) in segments.iter().enumerate() {
239            let position = idx + 1;
240
241            let (requires_success, continue_on_failure) = self
242                .registry
243                .get_definition(&segment.name)
244                .map(|d| (d.requires_success, d.continue_on_failure))
245                .unwrap_or((false, false));
246
247            if chain_has_failure && requires_success {
248                eprintln!(
249                    "Skipped: command {}/{} ('{}') — a preceding command failed",
250                    position, total, segment.name
251                );
252                continue;
253            }
254
255            if let Err(e) = self.execute_segment(segment) {
256                let wrapped = wrap_chain_error(position, total, &segment.name, e);
257
258                if !chain_has_failure {
259                    triggering_failure = Some(wrapped);
260                }
261                chain_has_failure = true;
262
263                if !continue_on_failure {
264                    break;
265                }
266            }
267        }
268
269        match triggering_failure {
270            Some(e) => Err(e),
271            None => Ok(()),
272        }
273    }
274
275    /// Resolve and parse a full, already-tokenized command line into one
276    /// or more [`ResolvedSegment`]s, without executing any of them
277    /// (#52 / #55).
278    ///
279    /// For the current segment, the parser consumes its options and
280    /// positional arguments up to the command's declared arity
281    /// ([`CliParser::parse_typed_segment`], #54). Once arity is
282    /// exhausted, the next bare token is looked up against
283    /// [`CommandRegistry::resolve_name`] (aliases included): a match
284    /// starts the next segment; no match raises the same
285    /// [`crate::error::ParseError::too_many_arguments`] a single,
286    /// non-chained command would raise today — no observable behaviour
287    /// change for existing callers. A genuinely unknown command name is
288    /// reported as [`crate::error::ParseError::UnknownCommand`] with
289    /// similar names, as before chaining existed, and a hint pointing to
290    /// `--help`.
291    ///
292    /// `resolve_name`/`get_definition` are stateless lookups, so the same
293    /// command name (or one of its aliases) may legitimately resolve more
294    /// than once within a single chain — nothing here tracks "already
295    /// consumed" names, nor should it: repeating a command within a chain
296    /// is supported on purpose (#55/#56).
297    ///
298    /// **Known, accepted limitation:** if a command line supplies
299    /// one token more than that command's declared arity, and that
300    /// leftover token happens to also be a registered command name, it is
301    /// silently absorbed as the start of the next segment instead of
302    /// raising `too_many_arguments` — segmentation cannot distinguish "a
303    /// stray extra value" from "the next command" once arity is
304    /// exhausted. No `--`-style local escape is implemented; documented
305    /// as an accepted constraint, not scheduled for a fix.
306    fn segment(&self, args: &[String]) -> Result<Vec<ResolvedSegment>> {
307        let mut segments = Vec::new();
308        let mut offset = 0;
309
310        loop {
311            let command_name = &args[offset];
312
313            let resolved_name = self.registry.resolve_name(command_name).ok_or_else(|| {
314                crate::error::ParseError::unknown_command_with_hint(
315                    command_name,
316                    &self
317                        .registry
318                        .list_commands()
319                        .iter()
320                        .map(|cmd| cmd.name.clone())
321                        .collect::<Vec<_>>(),
322                    COMMANDS_HINT,
323                )
324            })?;
325
326            let definition = self.registry.get_definition(resolved_name).ok_or_else(|| {
327                DynamicCliError::Registry(crate::error::RegistryError::missing_handler(
328                    resolved_name,
329                ))
330            })?;
331
332            let parser = CliParser::new(definition);
333            let (parsed_map, consumed) = parser.parse_typed_segment(&args[offset + 1..])?;
334
335            segments.push(ResolvedSegment {
336                name: resolved_name.to_string(),
337                parsed: ParsedArgs::new(parsed_map),
338            });
339
340            let next = offset + 1 + consumed;
341            if next == args.len() {
342                break;
343            }
344
345            if self.registry.resolve_name(&args[next]).is_none() {
346                return Err(crate::error::ParseError::too_many_arguments(
347                    &definition.name,
348                    definition.arguments.len(),
349                    definition.arguments.len() + 1,
350                )
351                .into());
352            }
353
354            offset = next;
355        }
356
357        Ok(segments)
358    }
359
360    /// Execute one already-resolved, already-parsed segment.
361    ///
362    /// Sync handler tried first (unchanged behaviour); if absent, the
363    /// async path is driven via `block_on` — safe here because
364    /// `run()`/`run_script()` are strictly sequential, one-shot dispatch,
365    /// per segment exactly as for a single command before chaining
366    /// existed.
367    fn execute_segment(&mut self, segment: &ResolvedSegment) -> Result<()> {
368        if let Some(handler) = self.registry.get_handler_sync(&segment.name) {
369            handler.execute(&mut *self.context, &segment.parsed)?;
370        } else if let Some(handler) = self.registry.get_handler_async(&segment.name) {
371            futures::executor::block_on(handler.execute(&mut *self.context, &segment.parsed))?;
372        } else {
373            // segment() already resolved this name to a definition, so
374            // this branch means a command is registered (schema-wise)
375            // without either a sync or async handler — re-fetched here
376            // only for the implementation name in the error message.
377            let implementation = self
378                .registry
379                .get_definition(&segment.name)
380                .map(|d| d.implementation.as_str())
381                .unwrap_or("");
382            return Err(DynamicCliError::Execution(
383                crate::error::ExecutionError::handler_not_found(&segment.name, implementation),
384            ));
385        }
386
387        Ok(())
388    }
389
390    /// Run the CLI with automatic error handling and exit
391    ///
392    /// This is a convenience method that:
393    /// 1. Runs the CLI with provided arguments
394    /// 2. Handles errors by displaying them to stderr
395    /// 3. Exits the process with appropriate exit code
396    ///
397    /// This method never returns.
398    ///
399    /// # Arguments
400    ///
401    /// * `args` - Command-line arguments
402    ///
403    /// # Example
404    ///
405    /// ```no_run
406    /// use dynamic_cli::interface::CliInterface;
407    /// use dynamic_cli::prelude::*;
408    ///
409    /// # #[derive(Default)]
410    /// # struct MyContext;
411    /// # impl ExecutionContext for MyContext {
412    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
413    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
414    /// # }
415    /// # fn main() {
416    /// let registry = CommandRegistry::new();
417    /// let context = Box::new(MyContext::default());
418    /// let cli = CliInterface::new(registry, context);
419    ///
420    /// // This will handle errors and exit automatically
421    /// cli.run_and_exit(std::env::args().skip(1).collect());
422    /// # }
423    /// ```
424    pub fn run_and_exit(self, args: Vec<String>) -> ! {
425        match self.run(args) {
426            Ok(()) => process::exit(0),
427            Err(e) => {
428                display_error(&e);
429
430                // Exit with appropriate code based on error type
431                let exit_code = match e {
432                    DynamicCliError::Parse(_) => 2,
433                    DynamicCliError::Validation(_) => 2,
434                    DynamicCliError::Execution(_) => 1,
435                    _ => 3,
436                };
437
438                process::exit(exit_code);
439            }
440        }
441    }
442
443    /// Run a batch of command lines read from a file (#41).
444    ///
445    /// Each non-blank, non-comment (`#`-prefixed) line is tokenized the
446    /// same quote-aware way as a typed REPL line (via
447    /// [`ReplParser::tokenize`]), then dispatched through the exact same
448    /// resolve → parse → execute path as [`run`][Self::run] — no
449    /// duplicate parsing logic, and repeatable options are fully
450    /// preserved since dispatch goes through `parse_typed_segment()` either way.
451    ///
452    /// # Error policy
453    ///
454    /// `policy` decides what happens when a line fails:
455    /// - [`ScriptErrorPolicy::Abort`]: stop immediately, returning `Err`
456    ///   for the failing line. Lines before it have already run.
457    /// - [`ScriptErrorPolicy::Continue`]: record the failure and proceed
458    ///   to the next line. The method still returns `Ok`, with every
459    ///   failure listed in the returned [`ScriptOutcome`].
460    ///
461    /// Every failure — whether it aborts the run or not — is reported
462    /// with its 1-based line number, wrapped in
463    /// [`ExecutionError::CommandFailed`]
464    /// (reusing the existing error hierarchy; no new enum variant, so no
465    /// breaking change to `ExecutionError`'s non-`#[non_exhaustive]`
466    /// shape).
467    ///
468    /// # Example
469    ///
470    /// ```no_run
471    /// use dynamic_cli::interface::{CliInterface, ScriptErrorPolicy};
472    /// use dynamic_cli::prelude::*;
473    ///
474    /// # #[derive(Default)]
475    /// # struct MyContext;
476    /// # impl ExecutionContext for MyContext {
477    /// #     fn as_any(&self) -> &dyn std::any::Any { self }
478    /// #     fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
479    /// # }
480    /// # fn main() -> dynamic_cli::Result<()> {
481    /// let registry = CommandRegistry::new();
482    /// let context = Box::new(MyContext::default());
483    /// let cli = CliInterface::new(registry, context);
484    ///
485    /// let outcome = cli.run_script("commands.txt", ScriptErrorPolicy::Continue)?;
486    /// println!("{}/{} lines succeeded", outcome.lines_succeeded, outcome.lines_executed);
487    /// # Ok(())
488    /// # }
489    /// ```
490    pub fn run_script(
491        mut self,
492        path: impl AsRef<Path>,
493        policy: ScriptErrorPolicy,
494    ) -> Result<ScriptOutcome> {
495        let path = path.as_ref();
496        let content = std::fs::read_to_string(path).map_err(|e| {
497            DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
498                "failed to read script file {}: {}",
499                path.display(),
500                e
501            )))
502        })?;
503
504        let mut outcome = ScriptOutcome {
505            lines_executed: 0,
506            lines_succeeded: 0,
507            failures: Vec::new(),
508        };
509
510        for (idx, raw_line) in content.lines().enumerate() {
511            let line_number = idx + 1;
512            let line = raw_line.trim();
513
514            if line.is_empty() || line.starts_with('#') {
515                continue;
516            }
517
518            outcome.lines_executed += 1;
519
520            // Scoped so the borrow of `self.registry` ends before
521            // `self.dispatch(&mut self, ...)` needs exclusive access below.
522            // `tokenize` is a pure function of the line text — it doesn't
523            // read `self.registry` — but it lives on `ReplParser`, so a
524            // throwaway instance is the reuse path rather than duplicating
525            // the quote-handling logic here.
526            let tokens_result = {
527                let tokenizer = ReplParser::new(&self.registry);
528                tokenizer.tokenize(line)
529            };
530
531            let tokens = match tokens_result {
532                Ok(t) => t,
533                Err(e) => {
534                    let wrapped = wrap_line_error(line_number, e);
535                    if policy == ScriptErrorPolicy::Abort {
536                        return Err(wrapped);
537                    }
538                    outcome.failures.push((line_number, wrapped));
539                    continue;
540                }
541            };
542
543            if tokens.is_empty() {
544                continue;
545            }
546
547            match self.dispatch(&tokens) {
548                Ok(()) => outcome.lines_succeeded += 1,
549                Err(e) => {
550                    let wrapped = wrap_line_error(line_number, e);
551                    if policy == ScriptErrorPolicy::Abort {
552                        return Err(wrapped);
553                    }
554                    outcome.failures.push((line_number, wrapped));
555                }
556            }
557        }
558
559        Ok(outcome)
560    }
561}
562
563/// Wrap an error with its 1-based script line number, reusing the
564/// existing [`ExecutionError::CommandFailed`] variant so adding
565/// line-number context never requires a breaking change to the error
566/// hierarchy.
567fn wrap_line_error(line_number: usize, source: DynamicCliError) -> DynamicCliError {
568    DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
569        "line {}: {}",
570        line_number,
571        source
572    )))
573}
574
575/// Wrap a chain segment's failure with its 1-based position (#52 / #56),
576/// reusing the existing [`ExecutionError::CommandFailed`]
577/// variant — same idiom as [`wrap_line_error`] — and the existing
578/// [`format_error`] for the inner message, so no change to
579/// `error/display.rs` is needed. Position (not name) is what
580/// distinguishes two failures of the same repeated command at different
581/// points in a chain.
582fn wrap_chain_error(
583    position: usize,
584    total: usize,
585    name: &str,
586    source: DynamicCliError,
587) -> DynamicCliError {
588    DynamicCliError::Execution(ExecutionError::CommandFailed(anyhow::anyhow!(
589        "Error in command {}/{} ('{}'): {}",
590        position,
591        total,
592        name,
593        format_error(&source)
594    )))
595}
596
597/// What [`CliInterface::run_script`] does when a line fails.
598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
599pub enum ScriptErrorPolicy {
600    /// Stop at the first failing line — [`run_script`][CliInterface::run_script]
601    /// returns `Err` immediately, with the lines before it already run.
602    Abort,
603    /// Record the failure and keep going —
604    /// [`run_script`][CliInterface::run_script] returns `Ok` with every
605    /// failure listed in [`ScriptOutcome::failures`].
606    Continue,
607}
608
609/// Result of a full [`CliInterface::run_script`] run.
610#[derive(Debug)]
611pub struct ScriptOutcome {
612    /// Number of non-blank, non-comment lines dispatched (attempted).
613    pub lines_executed: usize,
614    /// Number of those lines that succeeded.
615    pub lines_succeeded: usize,
616    /// `(1-based line number, wrapped error)` for every line that failed.
617    /// Always empty when `policy` was
618    /// [`ScriptErrorPolicy::Abort`] and the run
619    /// completed (an abort returns `Err` instead of populating this).
620    pub failures: Vec<(usize, DynamicCliError)>,
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::config::schema::{ArgumentDefinition, ArgumentType, CommandDefinition};
627
628    // Test context
629    #[derive(Default)]
630    struct TestContext {
631        executed_command: Option<String>,
632        // Ordered record of every handler executed so far — additive,
633        // needed to assert chain execution order — DD-026 (#52), #55 —
634        // without disturbing `executed_command` (kept for any existing
635        // single-dispatch assertions).
636        executed_commands: Vec<String>,
637    }
638
639    impl ExecutionContext for TestContext {
640        fn as_any(&self) -> &dyn std::any::Any {
641            self
642        }
643
644        fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
645            self
646        }
647    }
648
649    // Test handler
650    struct TestHandler {
651        name: String,
652    }
653
654    impl crate::executor::CommandHandler for TestHandler {
655        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
656            let ctx = crate::context::downcast_mut::<TestContext>(context)
657                .expect("Failed to downcast context");
658            ctx.executed_command = Some(self.name.clone());
659            ctx.executed_commands.push(self.name.clone());
660            Ok(())
661        }
662    }
663
664    /// A handler that always fails, recording the attempt first — needed
665    /// to exercise `continue_on_failure`/`requires_success` (#52 / #56),
666    /// which only ever activate downstream of a failure.
667    struct FailingHandler {
668        name: String,
669    }
670
671    impl crate::executor::CommandHandler for FailingHandler {
672        fn execute(&self, context: &mut dyn ExecutionContext, _args: &ParsedArgs) -> Result<()> {
673            let ctx = crate::context::downcast_mut::<TestContext>(context)
674                .expect("Failed to downcast context");
675            ctx.executed_commands.push(self.name.clone());
676            Err(DynamicCliError::Execution(ExecutionError::CommandFailed(
677                anyhow::anyhow!("{} deliberately failed", self.name),
678            )))
679        }
680    }
681
682    fn create_test_registry() -> CommandRegistry {
683        let mut registry = CommandRegistry::new();
684
685        // Create a simple command definition
686        let cmd_def = CommandDefinition {
687            name: "test".to_string(),
688            aliases: vec!["t".to_string()],
689            description: "Test command".to_string(),
690            required: false,
691            arguments: vec![],
692            options: vec![],
693            implementation: "test_handler".to_string(),
694            continue_on_failure: false,
695            requires_success: false,
696        };
697
698        let handler = Box::new(TestHandler {
699            name: "test".to_string(),
700        });
701
702        registry
703            .register_sync(cmd_def, handler)
704            .expect("Failed to register command");
705
706        registry
707    }
708
709    #[test]
710    fn test_cli_interface_creation() {
711        let registry = create_test_registry();
712        let context = Box::new(TestContext::default());
713
714        let _cli = CliInterface::new(registry, context);
715        // If this compiles and runs, creation works
716    }
717
718    #[test]
719    fn test_cli_run_simple_command() {
720        let registry = create_test_registry();
721        let context = Box::new(TestContext::default());
722        let cli = CliInterface::new(registry, context);
723
724        let result = cli.run(vec!["test".to_string()]);
725        assert!(result.is_ok());
726    }
727
728    #[test]
729    fn test_cli_run_with_alias() {
730        let registry = create_test_registry();
731        let context = Box::new(TestContext::default());
732        let cli = CliInterface::new(registry, context);
733
734        let result = cli.run(vec!["t".to_string()]);
735        assert!(result.is_ok());
736    }
737
738    #[test]
739    fn test_cli_empty_args() {
740        let registry = create_test_registry();
741        let context = Box::new(TestContext::default());
742        let cli = CliInterface::new(registry, context);
743
744        let result = cli.run(vec![]);
745        assert!(result.is_err());
746
747        match result.unwrap_err() {
748            DynamicCliError::Parse(crate::error::ParseError::InvalidSyntax { .. }) => {}
749            other => panic!("Expected InvalidSyntax error, got: {:?}", other),
750        }
751    }
752
753    #[test]
754    fn test_cli_unknown_command() {
755        let registry = create_test_registry();
756        let context = Box::new(TestContext::default());
757        let cli = CliInterface::new(registry, context);
758
759        let result = cli.run(vec!["unknown".to_string()]);
760        assert!(result.is_err());
761
762        match result.unwrap_err() {
763            DynamicCliError::Parse(crate::error::ParseError::UnknownCommand { hint, .. }) => {
764                assert_eq!(
765                    hint.as_deref(),
766                    Some("Run with '--help' for available commands")
767                );
768            }
769            other => panic!("Expected UnknownCommand error, got: {:?}", other),
770        }
771    }
772
773    #[test]
774    fn test_cli_command_with_args() {
775        let mut registry = CommandRegistry::new();
776
777        // Command with argument
778        let cmd_def = CommandDefinition {
779            name: "greet".to_string(),
780            aliases: vec![],
781            description: "Greet someone".to_string(),
782            required: false,
783            arguments: vec![ArgumentDefinition {
784                name: "name".to_string(),
785                arg_type: ArgumentType::String,
786                required: true,
787                description: "Name to greet".to_string(),
788                validation: vec![],
789                secure: false,
790            }],
791            options: vec![],
792            implementation: "greet_handler".to_string(),
793            continue_on_failure: false,
794            requires_success: false,
795        };
796
797        struct GreetHandler;
798        impl crate::executor::CommandHandler for GreetHandler {
799            fn execute(
800                &self,
801                _context: &mut dyn ExecutionContext,
802                args: &ParsedArgs,
803            ) -> Result<()> {
804                assert_eq!(args.get_scalar("name"), Some("Alice"));
805                Ok(())
806            }
807        }
808
809        registry
810            .register_sync(cmd_def, Box::new(GreetHandler))
811            .unwrap();
812
813        let context = Box::new(TestContext::default());
814        let cli = CliInterface::new(registry, context);
815
816        let result = cli.run(vec!["greet".to_string(), "Alice".to_string()]);
817        assert!(result.is_ok());
818    }
819
820    // ========================================================================
821    // run_script tests (#41)
822    // ========================================================================
823
824    fn write_script(content: &str) -> tempfile::NamedTempFile {
825        use std::io::Write;
826        let mut file = tempfile::NamedTempFile::new().expect("failed to create temp script file");
827        file.write_all(content.as_bytes())
828            .expect("failed to write temp script file");
829        file
830    }
831
832    #[test]
833    fn test_run_script_all_lines_succeed() {
834        let registry = create_test_registry();
835        let context = Box::new(TestContext::default());
836        let cli = CliInterface::new(registry, context);
837
838        let script = write_script("test\nt\ntest\n");
839        let outcome = cli
840            .run_script(script.path(), ScriptErrorPolicy::Abort)
841            .expect("run_script should succeed when every line succeeds");
842
843        assert_eq!(outcome.lines_executed, 3);
844        assert_eq!(outcome.lines_succeeded, 3);
845        assert!(outcome.failures.is_empty());
846    }
847
848    #[test]
849    fn test_run_script_skips_blank_lines_and_comments() {
850        let registry = create_test_registry();
851        let context = Box::new(TestContext::default());
852        let cli = CliInterface::new(registry, context);
853
854        let script = write_script("# a comment\n\ntest\n   \n# another\nt\n");
855        let outcome = cli
856            .run_script(script.path(), ScriptErrorPolicy::Abort)
857            .expect("run_script should succeed");
858
859        // Only the two real command lines count.
860        assert_eq!(outcome.lines_executed, 2);
861        assert_eq!(outcome.lines_succeeded, 2);
862    }
863
864    #[test]
865    fn test_run_script_continue_policy_records_failures_and_keeps_going() {
866        let registry = create_test_registry();
867        let context = Box::new(TestContext::default());
868        let cli = CliInterface::new(registry, context);
869
870        let script = write_script("test\nunknown_command\ntest\n");
871        let outcome = cli
872            .run_script(script.path(), ScriptErrorPolicy::Continue)
873            .expect("Continue policy should return Ok even with a failing line");
874
875        assert_eq!(outcome.lines_executed, 3);
876        assert_eq!(outcome.lines_succeeded, 2);
877        assert_eq!(outcome.failures.len(), 1);
878        assert_eq!(outcome.failures[0].0, 2); // 1-based line number
879    }
880
881    #[test]
882    fn test_run_script_abort_policy_stops_at_first_failure() {
883        let registry = create_test_registry();
884        let context = Box::new(TestContext::default());
885        let cli = CliInterface::new(registry, context);
886
887        // A third "test" line would succeed if reached — it must not be.
888        let script = write_script("test\nunknown_command\ntest\n");
889        let result = cli.run_script(script.path(), ScriptErrorPolicy::Abort);
890
891        assert!(result.is_err());
892        match result.unwrap_err() {
893            DynamicCliError::Execution(ExecutionError::CommandFailed(e)) => {
894                assert!(e.to_string().contains("line 2"));
895            }
896            other => panic!("Expected wrapped CommandFailed error, got: {:?}", other),
897        }
898    }
899
900    #[test]
901    fn test_run_script_respects_quoted_tokens() {
902        let mut registry = CommandRegistry::new();
903        let cmd_def = CommandDefinition {
904            name: "greet".to_string(),
905            aliases: vec![],
906            description: "Greet someone".to_string(),
907            required: false,
908            arguments: vec![ArgumentDefinition {
909                name: "name".to_string(),
910                arg_type: ArgumentType::String,
911                required: true,
912                description: "Name to greet".to_string(),
913                validation: vec![],
914                secure: false,
915            }],
916            options: vec![],
917            implementation: "greet_handler".to_string(),
918            continue_on_failure: false,
919            requires_success: false,
920        };
921
922        struct GreetHandler;
923        impl crate::executor::CommandHandler for GreetHandler {
924            fn execute(
925                &self,
926                _context: &mut dyn ExecutionContext,
927                args: &ParsedArgs,
928            ) -> Result<()> {
929                assert_eq!(args.get_scalar("name"), Some("Alice Wonderland"));
930                Ok(())
931            }
932        }
933
934        registry
935            .register_sync(cmd_def, Box::new(GreetHandler))
936            .unwrap();
937
938        let context = Box::new(TestContext::default());
939        let cli = CliInterface::new(registry, context);
940
941        let script = write_script(r#"greet "Alice Wonderland""#);
942        let outcome = cli
943            .run_script(script.path(), ScriptErrorPolicy::Abort)
944            .expect("quoted argument should tokenize as a single value");
945
946        assert_eq!(outcome.lines_succeeded, 1);
947    }
948
949    #[test]
950    fn test_run_script_missing_file() {
951        let registry = create_test_registry();
952        let context = Box::new(TestContext::default());
953        let cli = CliInterface::new(registry, context);
954
955        let result = cli.run_script("/nonexistent/path/to/script.txt", ScriptErrorPolicy::Abort);
956        assert!(result.is_err());
957    }
958
959    // ========================================================================
960    // segment() / dispatch() chaining tests — DD-026 (#52), #55
961    // ========================================================================
962
963    /// Register a command taking exactly `arity` required `String`
964    /// positional arguments (`arg0`, `arg1`, ...) and no options, backed
965    /// by a [`TestHandler`] that records its name (and, in order, into
966    /// [`TestContext::executed_commands`]).
967    fn register_arity_command(registry: &mut CommandRegistry, name: &str, arity: usize) {
968        let arguments = (0..arity)
969            .map(|i| ArgumentDefinition {
970                name: format!("arg{}", i),
971                arg_type: ArgumentType::String,
972                required: true,
973                description: format!("Argument {}", i),
974                validation: vec![],
975                secure: false,
976            })
977            .collect();
978
979        let cmd_def = CommandDefinition {
980            name: name.to_string(),
981            aliases: vec![],
982            description: format!("Test command {}", name),
983            required: false,
984            arguments,
985            options: vec![],
986            implementation: format!("{}_handler", name),
987            continue_on_failure: false,
988            requires_success: false,
989        };
990
991        registry
992            .register_sync(
993                cmd_def,
994                Box::new(TestHandler {
995                    name: name.to_string(),
996                }),
997            )
998            .expect("Failed to register command");
999    }
1000
1001    #[test]
1002    fn test_segment_single_command_produces_one_segment() {
1003        // Single-command case (today's behaviour): exactly one segment,
1004        // correctly parsed.
1005        let mut registry = CommandRegistry::new();
1006        register_arity_command(&mut registry, "greet", 1);
1007        let context = Box::new(TestContext::default());
1008        let cli = CliInterface::new(registry, context);
1009
1010        let args = vec!["greet".to_string(), "Alice".to_string()];
1011        let segments = cli.segment(&args).unwrap();
1012
1013        assert_eq!(segments.len(), 1);
1014        assert_eq!(segments[0].name, "greet");
1015        assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("Alice"));
1016    }
1017
1018    #[test]
1019    fn test_segment_single_command_overflow_still_raises_too_many_arguments() {
1020        // A genuinely too-long single command (no chain intended, the
1021        // leftover token isn't a registered command) must raise the
1022        // identical error dispatch() raised before chaining existed.
1023        let mut registry = CommandRegistry::new();
1024        register_arity_command(&mut registry, "greet", 1);
1025        let context = Box::new(TestContext::default());
1026        let cli = CliInterface::new(registry, context);
1027
1028        let args = vec![
1029            "greet".to_string(),
1030            "Alice".to_string(),
1031            "extra".to_string(),
1032        ];
1033        let result = cli.segment(&args);
1034
1035        assert!(result.is_err());
1036        match result.unwrap_err() {
1037            DynamicCliError::Parse(crate::error::ParseError::TooManyArguments {
1038                command,
1039                expected,
1040                got,
1041                ..
1042            }) => {
1043                assert_eq!(command, "greet");
1044                assert_eq!(expected, 1);
1045                assert_eq!(got, 2);
1046            }
1047            other => panic!("Expected TooManyArguments error, got: {:?}", other),
1048        }
1049    }
1050
1051    #[test]
1052    fn test_segment_multi_command_chain_produces_three_segments() {
1053        // Generic three-command chain (structurally the same shape as
1054        // the chrom-rs-motivated example of DD-026 (#52) — a couple of
1055        // argument-taking commands followed by a zero-arity terminal
1056        // command — but with arbitrary names, since chrom-rs is only ever
1057        // an illustration, never the justification).
1058        let mut registry = CommandRegistry::new();
1059        register_arity_command(&mut registry, "first", 1);
1060        register_arity_command(&mut registry, "second", 1);
1061        register_arity_command(&mut registry, "third", 0);
1062        let context = Box::new(TestContext::default());
1063        let cli = CliInterface::new(registry, context);
1064
1065        let args = vec![
1066            "first".to_string(),
1067            "1".to_string(),
1068            "second".to_string(),
1069            "2".to_string(),
1070            "third".to_string(),
1071        ];
1072        let segments = cli.segment(&args).unwrap();
1073
1074        assert_eq!(segments.len(), 3);
1075        assert_eq!(segments[0].name, "first");
1076        assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("1"));
1077        assert_eq!(segments[1].name, "second");
1078        assert_eq!(segments[1].parsed.get_scalar("arg0"), Some("2"));
1079        assert_eq!(segments[2].name, "third");
1080    }
1081
1082    #[test]
1083    fn test_segment_unknown_command_produces_unknown_command_error() {
1084        // Unchanged behaviour: a name that resolves to nothing at the
1085        // start of a segment (first position here — the only position
1086        // structurally reachable, since a later boundary token that
1087        // fails to resolve is by construction reported as
1088        // too_many_arguments against the preceding segment instead, not
1089        // as unknown_command) still raises the existing suggestion-aware
1090        // error.
1091        let registry = create_test_registry();
1092        let context = Box::new(TestContext::default());
1093        let cli = CliInterface::new(registry, context);
1094
1095        let args = vec!["nope".to_string()];
1096        let result = cli.segment(&args);
1097
1098        assert!(result.is_err());
1099        match result.unwrap_err() {
1100            DynamicCliError::Parse(crate::error::ParseError::UnknownCommand { .. }) => {}
1101            other => panic!("Expected UnknownCommand error, got: {:?}", other),
1102        }
1103    }
1104
1105    #[test]
1106    fn test_segment_repeated_command_name_resolves_each_occurrence_independently() {
1107        // resolve_name()/get_definition() are stateless lookups: the same
1108        // command name may legitimately appear more than once in a
1109        // single chain, each occurrence carrying its own arguments.
1110        let mut registry = CommandRegistry::new();
1111        register_arity_command(&mut registry, "source", 1);
1112        register_arity_command(&mut registry, "run", 0);
1113        let context = Box::new(TestContext::default());
1114        let cli = CliInterface::new(registry, context);
1115
1116        let args = vec![
1117            "source".to_string(),
1118            "modelfile".to_string(),
1119            "source".to_string(),
1120            "solverfile".to_string(),
1121            "run".to_string(),
1122        ];
1123        let segments = cli.segment(&args).unwrap();
1124
1125        assert_eq!(segments.len(), 3);
1126        assert_eq!(segments[0].name, "source");
1127        assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("modelfile"));
1128        assert_eq!(segments[1].name, "source");
1129        assert_eq!(segments[1].parsed.get_scalar("arg0"), Some("solverfile"));
1130        assert_eq!(segments[2].name, "run");
1131    }
1132
1133    #[test]
1134    fn test_dispatch_executes_chain_in_order() {
1135        // End-to-end: segmentation feeding execute_segment() actually
1136        // runs every resolved segment, in order — not just parses them.
1137        let mut registry = CommandRegistry::new();
1138        register_arity_command(&mut registry, "first", 1);
1139        register_arity_command(&mut registry, "second", 1);
1140        register_arity_command(&mut registry, "third", 0);
1141        let context = Box::new(TestContext::default());
1142        let mut cli = CliInterface::new(registry, context);
1143
1144        let args = vec![
1145            "first".to_string(),
1146            "1".to_string(),
1147            "second".to_string(),
1148            "2".to_string(),
1149            "third".to_string(),
1150        ];
1151        cli.dispatch(&args).expect("chain should execute fully");
1152
1153        let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context)
1154            .expect("Failed to downcast context");
1155        assert_eq!(
1156            ctx.executed_commands,
1157            vec![
1158                "first".to_string(),
1159                "second".to_string(),
1160                "third".to_string()
1161            ]
1162        );
1163    }
1164
1165    #[test]
1166    fn test_segment_known_limitation_extra_token_matching_command_name_is_silently_absorbed() {
1167        // Documented, accepted limitation of DD-026 (#52): one token more than a
1168        // command's declared arity, which happens to also be a
1169        // registered command name, is silently read as the start of the
1170        // next segment instead of raising too_many_arguments.
1171        // Deliberately reproduced and pinned down here as *expected*
1172        // (not a bug to fix) — see the "Known limitation" note of DD-026 (#52).
1173        let mut registry = CommandRegistry::new();
1174        register_arity_command(&mut registry, "greet", 1);
1175        register_arity_command(&mut registry, "run", 0);
1176        let context = Box::new(TestContext::default());
1177        let cli = CliInterface::new(registry, context);
1178
1179        // Intent could plausibly have been "greet Alice" with a stray
1180        // trailing "run" (typo, or a genuinely too-long command) — but
1181        // because "run" is also a registered zero-arity command, it is
1182        // read as the next segment rather than reported as an error.
1183        let args = vec!["greet".to_string(), "Alice".to_string(), "run".to_string()];
1184        let segments = cli
1185            .segment(&args)
1186            .expect("known limitation: no error is raised here, by design");
1187
1188        assert_eq!(segments.len(), 2);
1189        assert_eq!(segments[0].name, "greet");
1190        assert_eq!(segments[0].parsed.get_scalar("arg0"), Some("Alice"));
1191        assert_eq!(segments[1].name, "run");
1192    }
1193
1194    // ========================================================================
1195    // execute_chain() — continue_on_failure / requires_success — DD-026 (#52), #56
1196    // ========================================================================
1197
1198    /// Register a zero-arity command with the given chain-policy fields,
1199    /// backed by [`FailingHandler`] when `fails` is `true` or
1200    /// [`TestHandler`] otherwise.
1201    fn register_chain_command(
1202        registry: &mut CommandRegistry,
1203        name: &str,
1204        continue_on_failure: bool,
1205        requires_success: bool,
1206        fails: bool,
1207    ) {
1208        let cmd_def = CommandDefinition {
1209            name: name.to_string(),
1210            aliases: vec![],
1211            description: format!("Test command {}", name),
1212            required: false,
1213            arguments: vec![],
1214            options: vec![],
1215            implementation: format!("{}_handler", name),
1216            continue_on_failure,
1217            requires_success,
1218        };
1219
1220        let handler: Box<dyn crate::executor::CommandHandler> = if fails {
1221            Box::new(FailingHandler {
1222                name: name.to_string(),
1223            })
1224        } else {
1225            Box::new(TestHandler {
1226                name: name.to_string(),
1227            })
1228        };
1229
1230        registry
1231            .register_sync(cmd_def, handler)
1232            .expect("Failed to register command");
1233    }
1234
1235    #[test]
1236    fn test_execute_chain_continue_on_failure_false_stops_chain() {
1237        let mut registry = CommandRegistry::new();
1238        register_chain_command(&mut registry, "a", false, false, true); // fails, does not absorb
1239        register_chain_command(&mut registry, "b", false, false, false);
1240        let context = Box::new(TestContext::default());
1241        let mut cli = CliInterface::new(registry, context);
1242
1243        let args = vec!["a".to_string(), "b".to_string()];
1244        let result = cli.dispatch(&args);
1245
1246        assert!(result.is_err());
1247        assert!(result
1248            .unwrap_err()
1249            .to_string()
1250            .contains("Error in command 1/2 ('a')"));
1251
1252        let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
1253        assert_eq!(
1254            ctx.executed_commands,
1255            vec!["a".to_string()],
1256            "'b' must never run once 'a' stops the chain"
1257        );
1258    }
1259
1260    #[test]
1261    fn test_execute_chain_continue_on_failure_true_proceeds_and_still_errors() {
1262        let mut registry = CommandRegistry::new();
1263        register_chain_command(&mut registry, "a", true, false, true); // fails, absorbed
1264        register_chain_command(&mut registry, "b", false, false, false);
1265        let context = Box::new(TestContext::default());
1266        let mut cli = CliInterface::new(registry, context);
1267
1268        let args = vec!["a".to_string(), "b".to_string()];
1269        let result = cli.dispatch(&args);
1270
1271        // The chain still reports Err overall (exit code must not be 0
1272        // just because every segment was *attempted*), but it's the
1273        // triggering ('a') failure that's reported.
1274        assert!(result.is_err());
1275        assert!(result
1276            .unwrap_err()
1277            .to_string()
1278            .contains("Error in command 1/2 ('a')"));
1279
1280        let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
1281        assert_eq!(
1282            ctx.executed_commands,
1283            vec!["a".to_string(), "b".to_string()],
1284            "'b' must still run: 'a''s failure was absorbed"
1285        );
1286    }
1287
1288    #[test]
1289    fn test_execute_chain_requires_success_skips_after_earlier_failure() {
1290        let mut registry = CommandRegistry::new();
1291        register_chain_command(&mut registry, "a", true, false, true); // fails, absorbed
1292        register_chain_command(&mut registry, "b", false, true, false); // requires_success
1293        let context = Box::new(TestContext::default());
1294        let mut cli = CliInterface::new(registry, context);
1295
1296        let args = vec!["a".to_string(), "b".to_string()];
1297        let result = cli.dispatch(&args);
1298
1299        assert!(result.is_err());
1300        let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
1301        assert_eq!(
1302            ctx.executed_commands,
1303            vec!["a".to_string()],
1304            "'b' must be skipped, not executed, once 'a' has failed"
1305        );
1306    }
1307
1308    #[test]
1309    fn test_execute_chain_requires_success_runs_normally_without_a_preceding_failure() {
1310        // requires_success is moot when nothing earlier in the chain has
1311        // failed — the segment runs exactly as if the flag were absent.
1312        let mut registry = CommandRegistry::new();
1313        register_chain_command(&mut registry, "a", false, false, false); // succeeds
1314        register_chain_command(&mut registry, "b", false, true, false); // requires_success, succeeds
1315        let context = Box::new(TestContext::default());
1316        let mut cli = CliInterface::new(registry, context);
1317
1318        let args = vec!["a".to_string(), "b".to_string()];
1319        cli.dispatch(&args)
1320            .expect("no failure anywhere in the chain");
1321
1322        let ctx = crate::context::downcast_ref::<TestContext>(&*cli.context).unwrap();
1323        assert_eq!(
1324            ctx.executed_commands,
1325            vec!["a".to_string(), "b".to_string()]
1326        );
1327    }
1328
1329    #[test]
1330    fn test_execute_chain_reports_repeated_command_name_by_position_not_name_early() {
1331        let mut registry = CommandRegistry::new();
1332        register_chain_command(&mut registry, "ok", false, false, false);
1333        register_chain_command(&mut registry, "source", true, false, true); // fails, absorbed
1334        let context = Box::new(TestContext::default());
1335        let mut cli = CliInterface::new(registry, context);
1336
1337        // "source" fails at position 2 of 4.
1338        let args = vec![
1339            "ok".to_string(),
1340            "source".to_string(),
1341            "ok".to_string(),
1342            "ok".to_string(),
1343        ];
1344        let result = cli.dispatch(&args);
1345
1346        assert!(result.is_err());
1347        let message = result.unwrap_err().to_string();
1348        assert!(message.contains("Error in command 2/4 ('source')"));
1349        assert!(!message.contains("4/4"));
1350    }
1351
1352    #[test]
1353    fn test_execute_chain_reports_repeated_command_name_by_position_not_name_late() {
1354        let mut registry = CommandRegistry::new();
1355        register_chain_command(&mut registry, "ok", false, false, false);
1356        register_chain_command(&mut registry, "source", true, false, true); // fails, absorbed
1357        let context = Box::new(TestContext::default());
1358        let mut cli = CliInterface::new(registry, context);
1359
1360        // Same two command names as the previous test, but "source" fails
1361        // at position 4 of 4 this time — the message must reflect *this*
1362        // position, not collide with or get deduplicated against the
1363        // other test's "2/4" message.
1364        let args = vec![
1365            "ok".to_string(),
1366            "ok".to_string(),
1367            "ok".to_string(),
1368            "source".to_string(),
1369        ];
1370        let result = cli.dispatch(&args);
1371
1372        assert!(result.is_err());
1373        let message = result.unwrap_err().to_string();
1374        assert!(message.contains("Error in command 4/4 ('source')"));
1375        assert!(!message.contains("2/4"));
1376    }
1377
1378    #[test]
1379    fn test_run_script_chain_failure_reports_chain_position_and_line_number() {
1380        // Integration: run_script() itself needs no code change (#56) —
1381        // a chain inside a single script line is dispatched through the
1382        // same dispatch()/execute_chain() path, and wrap_line_error()
1383        // (unchanged) wraps whatever dispatch() returns, so the final
1384        // message carries both the line number and the chain position.
1385        let mut registry = CommandRegistry::new();
1386        register_chain_command(&mut registry, "a", false, false, true); // fails
1387        register_chain_command(&mut registry, "b", false, false, false);
1388        let context = Box::new(TestContext::default());
1389        let cli = CliInterface::new(registry, context);
1390
1391        let script = write_script("a b\n");
1392        let outcome = cli
1393            .run_script(script.path(), ScriptErrorPolicy::Continue)
1394            .expect("Continue policy should return Ok even with a failing line");
1395
1396        assert_eq!(outcome.failures.len(), 1);
1397        let (line_number, error) = &outcome.failures[0];
1398        assert_eq!(*line_number, 1);
1399        let message = error.to_string();
1400        assert!(message.contains("line 1"));
1401        assert!(message.contains("Error in command 1/2 ('a')"));
1402    }
1403}