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