Skip to main content

dynamic_cli/parser/
cli_parser.rs

1//! CLI argument parser
2//!
3//! This module provides the [`CliParser`] which parses Unix-style command-line
4//! arguments into a structured HashMap. It handles:
5//! - Positional arguments
6//! - Short options (`-v`)
7//! - Long options (`--verbose`)
8//! - Options with values (`-o file.txt`, `--output=file.txt`)
9//! - Type conversion and validation
10//!
11//! # Example
12//!
13//! ```
14//! use dynamic_cli::parser::cli_parser::CliParser;
15//! use dynamic_cli::config::schema::{CommandDefinition, ArgumentDefinition, ArgumentType};
16//!
17//! let definition = CommandDefinition {
18//!     name: "process".to_string(),
19//!     aliases: vec![],
20//!     description: "Process files".to_string(),
21//!     required: false,
22//!     arguments: vec![
23//!         ArgumentDefinition {
24//!             name: "input".to_string(),
25//!             arg_type: ArgumentType::Path,
26//!             required: true,
27//!             description: "Input file".to_string(),
28//!             validation: vec![],
29//!             secure: false,
30//!         }
31//!     ],
32//!     options: vec![],
33//!     implementation: "handler".to_string(),
34//!     continue_on_failure: false,
35//!     requires_success: false,
36//! };
37//!
38//! let parser = CliParser::new(&definition);
39//! let args = vec!["file.txt".to_string()];
40//! let parsed = parser.parse(&args).unwrap();
41//!
42//! assert_eq!(parsed.get("input"), Some(&"file.txt".to_string()));
43//! ```
44
45#[allow(unused_imports)]
46use crate::config::schema::{ArgumentDefinition, CommandDefinition, OptionDefinition};
47use crate::error::{ParseError, Result};
48use crate::parser::type_parser;
49use std::collections::HashMap;
50
51/// CLI argument parser
52///
53/// Parses command-line arguments according to a [`CommandDefinition`].
54/// The parser handles both positional arguments and named options
55/// with type conversion and validation.
56///
57/// # Lifetime
58///
59/// The parser holds a reference to a [`CommandDefinition`] and therefore
60/// has a lifetime parameter `'a` that must outlive the parser.
61///
62/// # Example
63///
64/// ```
65/// use dynamic_cli::parser::cli_parser::CliParser;
66/// use dynamic_cli::config::schema::{
67///     CommandDefinition, OptionDefinition, ArgumentType
68/// };
69/// use std::collections::HashMap;
70///
71/// let definition = CommandDefinition {
72///     name: "test".to_string(),
73///     aliases: vec![],
74///     description: "Test command".to_string(),
75///     required: false,
76///     arguments: vec![],
77///     options: vec![
78///         OptionDefinition {
79///             name: "verbose".to_string(),
80///             short: Some("v".to_string()),
81///             long: Some("verbose".to_string()),
82///             option_type: ArgumentType::Bool,
83///             required: false,
84///             default: Some("false".to_string()),
85///             description: "Verbose output".to_string(),
86///             choices: vec![],
87///             repeatable: false,
88///             option_parameters: HashMap::new(),
89///         }
90///     ],
91///     implementation: "handler".to_string(),
92///     continue_on_failure: false,
93///     requires_success: false,
94/// };
95///
96/// let parser = CliParser::new(&definition);
97/// let args = vec!["-v".to_string()];
98/// let parsed = parser.parse(&args).unwrap();
99///
100/// assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
101/// ```
102pub struct CliParser<'a> {
103    /// The command definition that specifies expected arguments and options
104    definition: &'a CommandDefinition,
105}
106
107/// A single occurrence of a repeatable option
108///
109/// Produced when a `repeatable: true` option is encountered on the
110/// command line: `--output csv file=results.csv resolution=100` becomes
111/// `OptionOccurrence { discriminant: "csv", params: {"file": "results.csv",
112/// "resolution": "100"} }`.
113///
114/// `params` values are stored as strings after type validation against
115/// `option_parameters[discriminant]`, consistent with how scalar option
116/// and argument values are stored (see [`ParsedValue::Scalar`]).
117#[derive(Debug, Clone, PartialEq)]
118pub struct OptionOccurrence {
119    /// The token immediately following the flag, validated against the
120    /// option's `choices`.
121    pub discriminant: String,
122    /// The `key=value` pairs supplied for this occurrence.
123    pub params: HashMap<String, String>,
124}
125
126/// The value parsed for a single positional argument or option
127///
128/// [`CliParser::parse_typed`] returns `HashMap<String, ParsedValue>` so
129/// that repeatable options (which may occur zero or more times, each
130/// with their own sub-parameters) and plain scalar values can coexist in
131/// a single result map. [`CliParser::parse`] remains additive and
132/// unaffected — see its docs for how the two relate.
133#[derive(Debug, Clone, PartialEq)]
134pub enum ParsedValue {
135    /// A plain positional argument or non-repeatable option value.
136    Scalar(String),
137    /// Every occurrence of a repeatable option, in command-line order.
138    Repeated(Vec<OptionOccurrence>),
139}
140
141impl<'a> CliParser<'a> {
142    /// Create a new CLI parser for the given command definition
143    ///
144    /// # Arguments
145    ///
146    /// * `definition` - The command definition specifying expected arguments
147    ///
148    /// # Example
149    ///
150    /// ```
151    /// use dynamic_cli::parser::cli_parser::CliParser;
152    /// use dynamic_cli::config::schema::CommandDefinition;
153    ///
154    /// # let definition = CommandDefinition {
155    /// #     name: "test".to_string(),
156    /// #     aliases: vec![],
157    /// #     description: "".to_string(),
158    /// #     required: false,
159    /// #     arguments: vec![],
160    /// #     options: vec![],
161    /// #     implementation: "".to_string(),
162    /// #     continue_on_failure: false,
163    /// #     requires_success: false,
164    /// # };
165    /// let parser = CliParser::new(&definition);
166    /// ```
167    pub fn new(definition: &'a CommandDefinition) -> Self {
168        Self { definition }
169    }
170
171    /// Parse command-line arguments into a HashMap of strings
172    ///
173    /// Thin, non-breaking wrapper around [`Self::parse_typed`] for callers
174    /// that only deal in scalar values. Any [`ParsedValue::Repeated`] entry
175    /// (i.e. any `repeatable: true` option) is silently dropped from the
176    /// result — no command definition predating DD-024 can have one, so
177    /// existing callers see no behaviour change. Once the dispatch layer
178    /// is migrated to consume [`crate::parser::ParsedArgs`] directly
179    /// (#39, in progress — the type exists but `interface/cli.rs` and
180    /// `interface/repl.rs` still call this method, not `parse_typed`),
181    /// this method can be removed.
182    ///
183    /// # Arguments
184    ///
185    /// * `args` - Slice of argument strings (excluding the command name)
186    ///
187    /// # Returns
188    ///
189    /// A HashMap mapping argument/option names to their string values.
190    /// All values are stored as strings after type validation.
191    ///
192    /// # Errors
193    ///
194    /// - [`ParseError::MissingArgument`] if required arguments are missing
195    /// - [`ParseError::MissingOption`] if required options are missing
196    /// - [`ParseError::UnknownOption`] if an unrecognized option is provided
197    /// - [`ParseError::TypeParseError`] if a value cannot be converted to its expected type
198    /// - [`ParseError::TooManyArguments`] if more positional arguments than expected
199    ///
200    /// # Example
201    ///
202    /// ```
203    /// use dynamic_cli::parser::cli_parser::CliParser;
204    /// use dynamic_cli::config::schema::{
205    ///     CommandDefinition, ArgumentDefinition, ArgumentType
206    /// };
207    ///
208    /// let definition = CommandDefinition {
209    ///     name: "greet".to_string(),
210    ///     aliases: vec![],
211    ///     description: "Greet someone".to_string(),
212    ///     required: false,
213    ///     arguments: vec![
214    ///         ArgumentDefinition {
215    ///             name: "name".to_string(),
216    ///             arg_type: ArgumentType::String,
217    ///             required: true,
218    ///             description: "Name".to_string(),
219    ///             validation: vec![],
220    ///             secure: false,
221    ///         }
222    ///     ],
223    ///     options: vec![],
224    ///     implementation: "handler".to_string(),
225    ///     continue_on_failure: false,
226    ///     requires_success: false,
227    /// };
228    ///
229    /// let parser = CliParser::new(&definition);
230    /// let result = parser.parse(&["Alice".to_string()]).unwrap();
231    /// assert_eq!(result.get("name"), Some(&"Alice".to_string()));
232    /// ```
233    pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
234        let typed = self.parse_typed(args)?;
235
236        Ok(typed
237            .into_iter()
238            .filter_map(|(name, value)| match value {
239                ParsedValue::Scalar(s) => Some((name, s)),
240                ParsedValue::Repeated(_) => None,
241            })
242            .collect())
243    }
244
245    /// Parse command-line arguments into a HashMap of [`ParsedValue`]
246    ///
247    /// Like [`Self::parse`], but preserves repeatable options as
248    /// [`ParsedValue::Repeated`] instead of dropping them. This is the
249    /// method that actually implements DD-024 parsing; `parse()` is a
250    /// filtering wrapper around it.
251    ///
252    /// # Arguments
253    ///
254    /// * `args` - Slice of argument strings (excluding the command name)
255    ///
256    /// # Errors
257    ///
258    /// In addition to the errors documented on [`Self::parse`]:
259    /// - [`ParseError::UnknownDiscriminant`] if the token following a
260    ///   repeatable option's flag is not in that option's `choices`
261    /// - [`ParseError::UnknownOptionParameter`] if a `key=value` pair uses
262    ///   a key not declared in `option_parameters[discriminant]`
263    /// - [`ParseError::MissingRequiredOptionParameter`] if a required key
264    ///   is absent from an occurrence
265    /// - [`ParseError::DuplicateOptionOccurrence`] if the same
266    ///   discriminant is supplied twice with identical `key=value` pairs
267    pub fn parse_typed(&self, args: &[String]) -> Result<HashMap<String, ParsedValue>> {
268        let mut result = HashMap::new();
269        let mut positional_index = 0;
270        let mut i = 0;
271
272        // Parse arguments
273        while i < args.len() {
274            let arg = &args[i];
275
276            if arg.starts_with("--") {
277                // Long option
278                self.parse_long_option(arg, args, &mut i, &mut result)?;
279            } else if arg.starts_with('-') && arg.len() > 1 {
280                // Short option (ensure it's not just a negative number)
281                if arg
282                    .chars()
283                    .nth(1)
284                    .map(|c| c.is_ascii_digit())
285                    .unwrap_or(false)
286                {
287                    // This is a negative number, treat as positional
288                    self.parse_positional_argument(arg, positional_index, &mut result)?;
289                    positional_index += 1;
290                } else {
291                    self.parse_short_option(arg, args, &mut i, &mut result)?;
292                }
293            } else {
294                // Positional argument
295                self.parse_positional_argument(arg, positional_index, &mut result)?;
296                positional_index += 1;
297            }
298
299            i += 1;
300        }
301
302        // Apply defaults for missing optional options
303        self.apply_defaults(&mut result)?;
304
305        // Validate all required arguments are present
306        self.validate_required_arguments(&result)?;
307        self.validate_required_options(&result)?;
308
309        Ok(result)
310    }
311
312    /// Parse command-line arguments, stopping cleanly at a segment boundary
313    /// instead of erroring on positional-arity overflow (DD-026, #52).
314    ///
315    /// Shares [`Self::parse_typed`]'s token loop and every option-parsing
316    /// helper it calls ([`Self::parse_long_option`], [`Self::parse_short_option`],
317    /// [`Self::parse_repeatable_occurrence`]) unchanged. The only
318    /// difference is what happens when a bare (non-flag) token is reached
319    /// once `positional_index` has already reached
320    /// `self.definition.arguments.len()`: where [`Self::parse_typed`]
321    /// calls [`Self::parse_positional_argument`] and gets back
322    /// [`crate::error::ParseError::too_many_arguments`], this method stops
323    /// the loop immediately instead — without consuming that token,
324    /// without erroring — then runs the same finishing steps
325    /// (`apply_defaults` / `validate_required_arguments` /
326    /// `validate_required_options`) on whatever was accumulated so far.
327    ///
328    /// [`Self::parse_typed`] itself is not modified by this method's
329    /// existence: it keeps calling [`Self::parse_positional_argument`]
330    /// directly and erroring immediately on overflow, so [`Self::parse`]
331    /// and any existing caller keep today's exact behaviour.
332    ///
333    /// # Returns
334    ///
335    /// `(parsed, consumed)`, where `consumed` is the number of tokens of
336    /// `args` that belong to this command. When the loop reaches the end
337    /// of `args` with no leftover boundary token (the single-command,
338    /// non-chained case), `consumed == args.len()` and `parsed` is
339    /// identical to what [`Self::parse_typed`] would return for the same
340    /// input.
341    ///
342    /// # Errors
343    ///
344    /// Same as [`Self::parse_typed`] for every case *other* than
345    /// positional-arity overflow, which this method never raises — an
346    /// overflowing bare token is reported to the caller via `consumed`
347    /// instead, for [`crate::registry::CommandRegistry::resolve_name`] to
348    /// resolve as the next chain segment.
349    ///
350    /// # Example
351    ///
352    /// ```
353    /// use dynamic_cli::parser::cli_parser::{CliParser, ParsedValue};
354    /// use dynamic_cli::config::schema::{
355    ///     CommandDefinition, ArgumentDefinition, ArgumentType
356    /// };
357    ///
358    /// let definition = CommandDefinition {
359    ///     name: "config".to_string(),
360    ///     aliases: vec![],
361    ///     description: "Configure a source".to_string(),
362    ///     required: false,
363    ///     arguments: vec![
364    ///         ArgumentDefinition {
365    ///             name: "source".to_string(),
366    ///             arg_type: ArgumentType::Path,
367    ///             required: true,
368    ///             description: "Source file".to_string(),
369    ///             validation: vec![],
370    ///             secure: false,
371    ///         }
372    ///     ],
373    ///     options: vec![],
374    ///     implementation: "config_handler".to_string(),
375    ///     continue_on_failure: false,
376    ///     requires_success: false,
377    /// };
378    ///
379    /// let parser = CliParser::new(&definition);
380    /// // "solve" is the next chained command's name — arity for "config"
381    /// // (one positional) is already satisfied by "model.yml".
382    /// let args = vec!["model.yml".to_string(), "solve".to_string()];
383    /// let (parsed, consumed) = parser.parse_typed_segment(&args).unwrap();
384    ///
385    /// assert_eq!(consumed, 1);
386    /// assert_eq!(
387    ///     parsed.get("source"),
388    ///     Some(&ParsedValue::Scalar("model.yml".to_string()))
389    /// );
390    /// ```
391    pub fn parse_typed_segment(
392        &self,
393        args: &[String],
394    ) -> Result<(HashMap<String, ParsedValue>, usize)> {
395        let mut result = HashMap::new();
396        let mut positional_index = 0;
397        let mut i = 0;
398
399        while i < args.len() {
400            let arg = &args[i];
401
402            if arg.starts_with("--") {
403                // Long option
404                self.parse_long_option(arg, args, &mut i, &mut result)?;
405            } else if arg.starts_with('-') && arg.len() > 1 {
406                // Short option (ensure it's not just a negative number)
407                if arg
408                    .chars()
409                    .nth(1)
410                    .map(|c| c.is_ascii_digit())
411                    .unwrap_or(false)
412                {
413                    // This is a negative number, treat as positional —
414                    // subject to the same arity-boundary check below.
415                    if positional_index >= self.definition.arguments.len() {
416                        break;
417                    }
418                    self.parse_positional_argument(arg, positional_index, &mut result)?;
419                    positional_index += 1;
420                } else {
421                    self.parse_short_option(arg, args, &mut i, &mut result)?;
422                }
423            } else {
424                // Positional argument, or (arity already exhausted) the
425                // segment boundary: stop here, without consuming or
426                // erroring, and let the caller resolve it as the next
427                // chain segment.
428                if positional_index >= self.definition.arguments.len() {
429                    break;
430                }
431                self.parse_positional_argument(arg, positional_index, &mut result)?;
432                positional_index += 1;
433            }
434
435            i += 1;
436        }
437
438        // Apply defaults for missing optional options
439        self.apply_defaults(&mut result)?;
440
441        // Validate all required arguments are present
442        self.validate_required_arguments(&result)?;
443        self.validate_required_options(&result)?;
444
445        Ok((result, i))
446    }
447
448    /// Parse a long option (--option or --option=value)
449    fn parse_long_option(
450        &self,
451        arg: &str,
452        args: &[String],
453        index: &mut usize,
454        result: &mut HashMap<String, ParsedValue>,
455    ) -> Result<()> {
456        let arg_without_dashes = &arg[2..];
457
458        // Check for --option=value format
459        if let Some(eq_pos) = arg_without_dashes.find('=') {
460            let option_name = &arg_without_dashes[..eq_pos];
461            let value = &arg_without_dashes[eq_pos + 1..];
462
463            let option = self.find_option_by_long(option_name)?;
464            if option.repeatable {
465                // A repeatable option's discriminant/params are never
466                // attached via `=` — only the space-separated form is
467                // supported (see parse_repeatable_occurrence).
468                return Err(ParseError::InvalidSyntax {
469                    details: format!(
470                        "Option --{} is repeatable and does not support --{}=<value>",
471                        option.name, option.name
472                    ),
473                    hint: Some(format!(
474                        "Usage: --{} <{}> [key=value ...]",
475                        option.name,
476                        option.choices.join("|")
477                    )),
478                }
479                .into());
480            }
481            let parsed_value = type_parser::parse_value(value, option.option_type)?;
482            result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
483        } else {
484            // --option format (value might be next arg)
485            let option = self.find_option_by_long(arg_without_dashes)?;
486
487            if option.repeatable {
488                self.parse_repeatable_occurrence(option, args, index, result)?;
489            } else if matches!(
490                option.option_type,
491                crate::config::schema::ArgumentType::Bool
492            ) {
493                result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
494            } else {
495                // Non-boolean: expect value in next argument
496                *index += 1;
497                if *index >= args.len() {
498                    return Err(ParseError::InvalidSyntax {
499                        details: format!(
500                            "Option --{} requires a value",
501                            option.long.as_ref().unwrap()
502                        ),
503                        hint: Some(format!(
504                            "Usage: --{}=<value> or --{} <value>",
505                            option.long.as_ref().unwrap(),
506                            option.long.as_ref().unwrap()
507                        )),
508                    }
509                    .into());
510                }
511
512                let value = &args[*index];
513                let parsed_value = type_parser::parse_value(value, option.option_type)?;
514                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
515            }
516        }
517
518        Ok(())
519    }
520
521    /// Parse a short option (-o or -o value)
522    fn parse_short_option(
523        &self,
524        arg: &str,
525        args: &[String],
526        index: &mut usize,
527        result: &mut HashMap<String, ParsedValue>,
528    ) -> Result<()> {
529        let short_flag = &arg[1..2];
530        let option = self.find_option_by_short(short_flag)?;
531
532        if option.repeatable {
533            if arg.len() > 2 {
534                return Err(ParseError::InvalidSyntax {
535                    details: format!(
536                        "Option -{} is repeatable and does not support an attached value",
537                        short_flag
538                    ),
539                    hint: Some(format!(
540                        "Usage: -{} <{}> [key=value ...]",
541                        short_flag,
542                        option.choices.join("|")
543                    )),
544                }
545                .into());
546            }
547            self.parse_repeatable_occurrence(option, args, index, result)?;
548        } else if matches!(
549            option.option_type,
550            crate::config::schema::ArgumentType::Bool
551        ) {
552            result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
553        } else {
554            // Check if value is attached (e.g., -ovalue)
555            if arg.len() > 2 {
556                let value = &arg[2..];
557                let parsed_value = type_parser::parse_value(value, option.option_type)?;
558                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
559            } else {
560                // Value is next argument
561                *index += 1;
562                if *index >= args.len() {
563                    return Err(ParseError::InvalidSyntax {
564                        details: format!("Option -{} requires a value", short_flag),
565                        hint: Some(format!(
566                            "Usage: -{}<value> or -{} <value>",
567                            short_flag, short_flag
568                        )),
569                    }
570                    .into());
571                }
572
573                let value = &args[*index];
574                let parsed_value = type_parser::parse_value(value, option.option_type)?;
575                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
576            }
577        }
578
579        Ok(())
580    }
581
582    /// Parse a positional argument
583    fn parse_positional_argument(
584        &self,
585        value: &str,
586        index: usize,
587        result: &mut HashMap<String, ParsedValue>,
588    ) -> Result<()> {
589        if index >= self.definition.arguments.len() {
590            return Err(ParseError::too_many_arguments(
591                &self.definition.name,
592                self.definition.arguments.len(),
593                index + 1,
594            )
595            .into());
596        }
597
598        let arg_def = &self.definition.arguments[index];
599        let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
600        result.insert(arg_def.name.clone(), ParsedValue::Scalar(parsed_value));
601
602        Ok(())
603    }
604
605    /// Parse one occurrence of a repeatable option
606    ///
607    /// On entry, `index` points at the option's flag token. Reads the
608    /// discriminant token immediately following it, validates it against
609    /// `option.choices`, then consumes `key=value` tokens until the next
610    /// flag (any token starting with `-`), a bare token that isn't a
611    /// `key=value` pair, or the end of input. A `key=value` pair can never
612    /// start with `-` itself, so the flag check is unambiguous; a bare
613    /// non-`key=value` token ends the occurrence's span without error,
614    /// leaving it for the caller (`parse_typed` / `parse_typed_segment`,
615    /// #54) to treat as whatever it actually is — this command's next
616    /// positional argument, or, in a chained invocation (DD-026, #52),
617    /// the next segment's boundary token.
618    ///
619    /// On return, `index` points at the last token consumed (the
620    /// discriminant if no parameters followed, or the last `key=value`
621    /// token), matching the convention already used by
622    /// [`Self::parse_long_option`] / [`Self::parse_short_option`] — the
623    /// caller's own `i += 1` advances past it.
624    fn parse_repeatable_occurrence(
625        &self,
626        option: &OptionDefinition,
627        args: &[String],
628        index: &mut usize,
629        result: &mut HashMap<String, ParsedValue>,
630    ) -> Result<()> {
631        // Read the discriminant token.
632        *index += 1;
633        if *index >= args.len() {
634            return Err(ParseError::InvalidSyntax {
635                details: format!("Option --{} requires a discriminant", option.name),
636                hint: Some(format!(
637                    "Usage: --{} <{}> [key=value ...]",
638                    option.name,
639                    option.choices.join("|")
640                )),
641            }
642            .into());
643        }
644        let discriminant = args[*index].clone();
645        if !option.choices.contains(&discriminant) {
646            return Err(ParseError::UnknownDiscriminant {
647                option: option.name.clone(),
648                value: discriminant,
649                valid_choices: option.choices.clone(),
650                suggestion: Some(format!(
651                    "Run --help {} to see valid --{} kinds.",
652                    self.definition.name, option.name
653                )),
654            }
655            .into());
656        }
657
658        // Guaranteed present by validate_options() (#36) once
659        // repeatable/choices/option_parameters consistency has been
660        // validated at config-load time.
661        let empty: Vec<ArgumentDefinition> = Vec::new();
662        let param_defs = option
663            .option_parameters
664            .get(&discriminant)
665            .unwrap_or(&empty);
666
667        // Consume key=value tokens until the next flag, a non-key=value
668        // bare token, or end of input.
669        let mut params: HashMap<String, String> = HashMap::new();
670        while *index + 1 < args.len() {
671            let next = &args[*index + 1];
672            if next.starts_with('-') {
673                break;
674            }
675
676            let eq_pos = match next.find('=') {
677                Some(pos) => pos,
678                // Not a key=value pair: the occurrence's span ends here
679                // (see doc comment above) rather than erroring.
680                None => break,
681            };
682            let key = &next[..eq_pos];
683            let value = &next[eq_pos + 1..];
684
685            let arg_def = param_defs.iter().find(|a| a.name == key).ok_or_else(|| {
686                ParseError::UnknownOptionParameter {
687                    option: option.name.clone(),
688                    discriminant: discriminant.clone(),
689                    key: key.to_string(),
690                    valid_keys: param_defs.iter().map(|a| a.name.clone()).collect(),
691                    suggestion: Some(format!(
692                        "Run --help {} to see valid keys for --{} {}.",
693                        self.definition.name, option.name, discriminant
694                    )),
695                }
696            })?;
697
698            let typed_value = type_parser::parse_value(value, arg_def.arg_type)?;
699            params.insert(key.to_string(), typed_value);
700
701            *index += 1;
702        }
703
704        // Validate required keys are present.
705        for arg_def in param_defs {
706            if arg_def.required && !params.contains_key(&arg_def.name) {
707                return Err(ParseError::MissingRequiredOptionParameter {
708                    option: option.name.clone(),
709                    discriminant: discriminant.clone(),
710                    key: arg_def.name.clone(),
711                    suggestion: Some(format!(
712                        "Run --help {} to see required keys for --{} {}.",
713                        self.definition.name, option.name, discriminant
714                    )),
715                }
716                .into());
717            }
718        }
719
720        let occurrence = OptionOccurrence {
721            discriminant: discriminant.clone(),
722            params,
723        };
724
725        match result
726            .entry(option.name.clone())
727            .or_insert_with(|| ParsedValue::Repeated(Vec::new()))
728        {
729            ParsedValue::Repeated(occurrences) => {
730                if occurrences.contains(&occurrence) {
731                    return Err(ParseError::DuplicateOptionOccurrence {
732                        option: option.name.clone(),
733                        discriminant: occurrence.discriminant.clone(),
734                        params: occurrence.params.clone().into_iter().collect(),
735                        suggestion: Some(format!(
736                            "Remove one of the two identical --{} {} occurrences.",
737                            option.name, discriminant
738                        )),
739                    }
740                    .into());
741                }
742                occurrences.push(occurrence);
743            }
744            ParsedValue::Scalar(_) => unreachable!(
745                "option '{}' marked repeatable cannot already hold a Scalar value",
746                option.name
747            ),
748        }
749
750        Ok(())
751    }
752
753    /// Apply default values for options not provided
754    fn apply_defaults(&self, result: &mut HashMap<String, ParsedValue>) -> Result<()> {
755        for option in &self.definition.options {
756            if !result.contains_key(&option.name) {
757                if let Some(ref default) = option.default {
758                    // Validate the default value
759                    let parsed_default = type_parser::parse_value(default, option.option_type)?;
760                    result.insert(option.name.clone(), ParsedValue::Scalar(parsed_default));
761                }
762            }
763        }
764        Ok(())
765    }
766
767    /// Validate that all required arguments are present
768    fn validate_required_arguments(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
769        for arg in &self.definition.arguments {
770            if arg.required && !result.contains_key(&arg.name) {
771                return Err(ParseError::missing_argument(&arg.name, &self.definition.name).into());
772            }
773        }
774        Ok(())
775    }
776
777    /// Validate that all required options are present
778    fn validate_required_options(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
779        for option in &self.definition.options {
780            if option.required && !result.contains_key(&option.name) {
781                return Err(ParseError::missing_option(
782                    &option
783                        .long
784                        .clone()
785                        .or(option.short.clone())
786                        .unwrap_or_default(),
787                    &self.definition.name,
788                )
789                .into());
790            }
791        }
792        Ok(())
793    }
794
795    /// Find an option by its long form
796    fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
797        self.definition
798            .options
799            .iter()
800            .find(|opt| opt.long.as_deref() == Some(long))
801            .ok_or_else(|| {
802                let available: Vec<String> = self
803                    .definition
804                    .options
805                    .iter()
806                    .filter_map(|o| o.long.clone())
807                    .collect();
808                ParseError::unknown_option_with_suggestions(
809                    &format!("--{}", long),
810                    &self.definition.name,
811                    &available,
812                )
813                .into()
814            })
815    }
816
817    /// Find an option by its short form
818    fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
819        self.definition
820            .options
821            .iter()
822            .find(|opt| opt.short.as_deref() == Some(short))
823            .ok_or_else(|| {
824                let available: Vec<String> = self
825                    .definition
826                    .options
827                    .iter()
828                    .filter_map(|o| o.short.clone())
829                    .collect();
830                ParseError::unknown_option_with_suggestions(
831                    &format!("-{}", short),
832                    &self.definition.name,
833                    &available,
834                )
835                .into()
836            })
837    }
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::config::schema::{ArgumentType, OptionDefinition};
844
845    /// Helper to create a test command definition
846    fn create_test_definition() -> CommandDefinition {
847        CommandDefinition {
848            name: "test".to_string(),
849            aliases: vec![],
850            description: "Test command".to_string(),
851            required: false,
852            arguments: vec![
853                ArgumentDefinition {
854                    name: "input".to_string(),
855                    arg_type: ArgumentType::Path,
856                    required: true,
857                    description: "Input file".to_string(),
858                    validation: vec![],
859                    secure: false,
860                },
861                ArgumentDefinition {
862                    name: "output".to_string(),
863                    arg_type: ArgumentType::Path,
864                    required: false,
865                    description: "Output file".to_string(),
866                    validation: vec![],
867                    secure: false,
868                },
869            ],
870            options: vec![
871                OptionDefinition {
872                    name: "verbose".to_string(),
873                    short: Some("v".to_string()),
874                    long: Some("verbose".to_string()),
875                    option_type: ArgumentType::Bool,
876                    required: false,
877                    default: Some("false".to_string()),
878                    description: "Verbose output".to_string(),
879                    choices: vec![],
880                    repeatable: false,
881                    option_parameters: HashMap::new(),
882                },
883                OptionDefinition {
884                    name: "count".to_string(),
885                    short: Some("c".to_string()),
886                    long: Some("count".to_string()),
887                    option_type: ArgumentType::Integer,
888                    required: false,
889                    default: Some("10".to_string()),
890                    description: "Count".to_string(),
891                    choices: vec![],
892                    repeatable: false,
893                    option_parameters: HashMap::new(),
894                },
895            ],
896            implementation: "handler".to_string(),
897            continue_on_failure: false,
898            requires_success: false,
899        }
900    }
901
902    // ========================================================================
903    // Positional arguments tests
904    // ========================================================================
905
906    #[test]
907    fn test_parse_single_positional_argument() {
908        let definition = create_test_definition();
909        let parser = CliParser::new(&definition);
910
911        let args = vec!["input.txt".to_string()];
912        let result = parser.parse(&args).unwrap();
913
914        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
915    }
916
917    #[test]
918    fn test_parse_multiple_positional_arguments() {
919        let definition = create_test_definition();
920        let parser = CliParser::new(&definition);
921
922        let args = vec!["input.txt".to_string(), "output.txt".to_string()];
923        let result = parser.parse(&args).unwrap();
924
925        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
926        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
927    }
928
929    #[test]
930    fn test_parse_missing_required_argument() {
931        let definition = create_test_definition();
932        let parser = CliParser::new(&definition);
933
934        let args: Vec<String> = vec![];
935        let result = parser.parse(&args);
936
937        assert!(result.is_err());
938        match result.unwrap_err() {
939            crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
940                argument, ..
941            }) => {
942                assert_eq!(argument, "input");
943            }
944            other => panic!("Expected MissingArgument error, got {:?}", other),
945        }
946    }
947
948    #[test]
949    fn test_parse_too_many_positional_arguments() {
950        let definition = create_test_definition();
951        let parser = CliParser::new(&definition);
952
953        let args = vec![
954            "input.txt".to_string(),
955            "output.txt".to_string(),
956            "extra.txt".to_string(),
957        ];
958        let result = parser.parse(&args);
959
960        assert!(result.is_err());
961        match result.unwrap_err() {
962            crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
963            other => panic!("Expected TooManyArguments error, got {:?}", other),
964        }
965    }
966
967    // ========================================================================
968    // Long options tests
969    // ========================================================================
970
971    #[test]
972    fn test_parse_long_boolean_option() {
973        let definition = create_test_definition();
974        let parser = CliParser::new(&definition);
975
976        let args = vec!["input.txt".to_string(), "--verbose".to_string()];
977        let result = parser.parse(&args).unwrap();
978
979        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
980    }
981
982    #[test]
983    fn test_parse_long_option_with_equals() {
984        let definition = create_test_definition();
985        let parser = CliParser::new(&definition);
986
987        let args = vec!["input.txt".to_string(), "--count=42".to_string()];
988        let result = parser.parse(&args).unwrap();
989
990        assert_eq!(result.get("count"), Some(&"42".to_string()));
991    }
992
993    #[test]
994    fn test_parse_long_option_with_space() {
995        let definition = create_test_definition();
996        let parser = CliParser::new(&definition);
997
998        let args = vec![
999            "input.txt".to_string(),
1000            "--count".to_string(),
1001            "42".to_string(),
1002        ];
1003        let result = parser.parse(&args).unwrap();
1004
1005        assert_eq!(result.get("count"), Some(&"42".to_string()));
1006    }
1007
1008    #[test]
1009    fn test_parse_unknown_long_option() {
1010        let definition = create_test_definition();
1011        let parser = CliParser::new(&definition);
1012
1013        let args = vec!["input.txt".to_string(), "--unknown".to_string()];
1014        let result = parser.parse(&args);
1015
1016        assert!(result.is_err());
1017        match result.unwrap_err() {
1018            crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
1019            other => panic!("Expected UnknownOption error, got {:?}", other),
1020        }
1021    }
1022
1023    // ========================================================================
1024    // Short options tests
1025    // ========================================================================
1026
1027    #[test]
1028    fn test_parse_short_boolean_option() {
1029        let definition = create_test_definition();
1030        let parser = CliParser::new(&definition);
1031
1032        let args = vec!["input.txt".to_string(), "-v".to_string()];
1033        let result = parser.parse(&args).unwrap();
1034
1035        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1036    }
1037
1038    #[test]
1039    fn test_parse_short_option_with_space() {
1040        let definition = create_test_definition();
1041        let parser = CliParser::new(&definition);
1042
1043        let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
1044        let result = parser.parse(&args).unwrap();
1045
1046        assert_eq!(result.get("count"), Some(&"42".to_string()));
1047    }
1048
1049    #[test]
1050    fn test_parse_short_option_attached_value() {
1051        let definition = create_test_definition();
1052        let parser = CliParser::new(&definition);
1053
1054        let args = vec!["input.txt".to_string(), "-c42".to_string()];
1055        let result = parser.parse(&args).unwrap();
1056
1057        assert_eq!(result.get("count"), Some(&"42".to_string()));
1058    }
1059
1060    #[test]
1061    fn test_parse_negative_number_as_positional() {
1062        let definition = create_test_definition();
1063        let parser = CliParser::new(&definition);
1064
1065        // -123 should be treated as a positional argument, not an option
1066        let args = vec!["-123".to_string()];
1067        let result = parser.parse(&args).unwrap();
1068
1069        assert_eq!(result.get("input"), Some(&"-123".to_string()));
1070    }
1071
1072    // ========================================================================
1073    // Default values tests
1074    // ========================================================================
1075
1076    #[test]
1077    fn test_apply_default_values() {
1078        let definition = create_test_definition();
1079        let parser = CliParser::new(&definition);
1080
1081        let args = vec!["input.txt".to_string()];
1082        let result = parser.parse(&args).unwrap();
1083
1084        // Default values should be applied
1085        assert_eq!(result.get("verbose"), Some(&"false".to_string()));
1086        assert_eq!(result.get("count"), Some(&"10".to_string()));
1087    }
1088
1089    #[test]
1090    fn test_override_default_values() {
1091        let definition = create_test_definition();
1092        let parser = CliParser::new(&definition);
1093
1094        let args = vec![
1095            "input.txt".to_string(),
1096            "-v".to_string(),
1097            "-c".to_string(),
1098            "5".to_string(),
1099        ];
1100        let result = parser.parse(&args).unwrap();
1101
1102        // Provided values should override defaults
1103        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1104        assert_eq!(result.get("count"), Some(&"5".to_string()));
1105    }
1106
1107    // ========================================================================
1108    // Type conversion tests
1109    // ========================================================================
1110
1111    #[test]
1112    fn test_type_conversion_error() {
1113        let definition = create_test_definition();
1114        let parser = CliParser::new(&definition);
1115
1116        // "abc" cannot be parsed as integer
1117        let args = vec![
1118            "input.txt".to_string(),
1119            "--count".to_string(),
1120            "abc".to_string(),
1121        ];
1122        let result = parser.parse(&args);
1123
1124        assert!(result.is_err());
1125    }
1126
1127    // ========================================================================
1128    // Integration tests
1129    // ========================================================================
1130
1131    #[test]
1132    fn test_parse_complex_command_line() {
1133        let definition = create_test_definition();
1134        let parser = CliParser::new(&definition);
1135
1136        let args = vec![
1137            "input.txt".to_string(),
1138            "output.txt".to_string(),
1139            "--verbose".to_string(),
1140            "--count=100".to_string(),
1141        ];
1142        let result = parser.parse(&args).unwrap();
1143
1144        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1145        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1146        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1147        assert_eq!(result.get("count"), Some(&"100".to_string()));
1148    }
1149
1150    #[test]
1151    fn test_parse_mixed_options_and_arguments() {
1152        let definition = create_test_definition();
1153        let parser = CliParser::new(&definition);
1154
1155        // Options can be interspersed with positional arguments
1156        let args = vec![
1157            "--verbose".to_string(),
1158            "input.txt".to_string(),
1159            "-c".to_string(),
1160            "50".to_string(),
1161            "output.txt".to_string(),
1162        ];
1163        let result = parser.parse(&args).unwrap();
1164
1165        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1166        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1167        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1168        assert_eq!(result.get("count"), Some(&"50".to_string()));
1169    }
1170
1171    // ========================================================================
1172    // DD-024: repeatable options with option_parameters (#38)
1173    // ========================================================================
1174
1175    /// Helper: a command with a repeatable `--output` option, mirroring
1176    /// the chrom-rs motivating example (csv with an optional resolution,
1177    /// plot with just a file).
1178    fn create_repeatable_test_definition() -> CommandDefinition {
1179        let mut option_parameters = HashMap::new();
1180        option_parameters.insert(
1181            "csv".to_string(),
1182            vec![
1183                ArgumentDefinition {
1184                    name: "file".to_string(),
1185                    arg_type: ArgumentType::Path,
1186                    required: true,
1187                    description: "Destination CSV file".to_string(),
1188                    validation: vec![],
1189                    secure: false,
1190                },
1191                ArgumentDefinition {
1192                    name: "resolution".to_string(),
1193                    arg_type: ArgumentType::Integer,
1194                    required: false,
1195                    description: "Time-step resolution".to_string(),
1196                    validation: vec![],
1197                    secure: false,
1198                },
1199            ],
1200        );
1201        option_parameters.insert(
1202            "plot".to_string(),
1203            vec![ArgumentDefinition {
1204                name: "file".to_string(),
1205                arg_type: ArgumentType::Path,
1206                required: true,
1207                description: "Destination image file".to_string(),
1208                validation: vec![],
1209                secure: false,
1210            }],
1211        );
1212
1213        CommandDefinition {
1214            name: "export".to_string(),
1215            aliases: vec![],
1216            description: "Export simulation results".to_string(),
1217            required: false,
1218            arguments: vec![],
1219            options: vec![OptionDefinition {
1220                name: "output".to_string(),
1221                short: None,
1222                long: Some("output".to_string()),
1223                option_type: ArgumentType::String,
1224                required: false,
1225                default: None,
1226                description: "Write results in one or more output kinds".to_string(),
1227                choices: vec!["csv".to_string(), "plot".to_string()],
1228                repeatable: true,
1229                option_parameters,
1230            }],
1231            implementation: "export_handler".to_string(),
1232            continue_on_failure: false,
1233            requires_success: false,
1234        }
1235    }
1236
1237    #[test]
1238    fn test_parse_repeatable_option_single_occurrence() {
1239        let definition = create_repeatable_test_definition();
1240        let parser = CliParser::new(&definition);
1241
1242        let args = vec![
1243            "--output".to_string(),
1244            "csv".to_string(),
1245            "file=results.csv".to_string(),
1246        ];
1247        let result = parser.parse_typed(&args).unwrap();
1248
1249        match result.get("output") {
1250            Some(ParsedValue::Repeated(occurrences)) => {
1251                assert_eq!(occurrences.len(), 1);
1252                assert_eq!(occurrences[0].discriminant, "csv");
1253                assert_eq!(
1254                    occurrences[0].params.get("file"),
1255                    Some(&"results.csv".to_string())
1256                );
1257            }
1258            other => panic!("Expected Repeated([csv]), got {:?}", other),
1259        }
1260    }
1261
1262    #[test]
1263    fn test_parse_repeatable_option_optional_param_can_be_omitted() {
1264        let definition = create_repeatable_test_definition();
1265        let parser = CliParser::new(&definition);
1266
1267        let args = vec![
1268            "--output".to_string(),
1269            "csv".to_string(),
1270            "file=results.csv".to_string(),
1271        ];
1272        let result = parser.parse_typed(&args).unwrap();
1273
1274        match result.get("output") {
1275            Some(ParsedValue::Repeated(occurrences)) => {
1276                assert_eq!(occurrences[0].params.get("resolution"), None);
1277            }
1278            other => panic!("Expected Repeated([csv]), got {:?}", other),
1279        }
1280    }
1281
1282    #[test]
1283    fn test_parse_repeatable_option_with_optional_param_provided() {
1284        let definition = create_repeatable_test_definition();
1285        let parser = CliParser::new(&definition);
1286
1287        let args = vec![
1288            "--output".to_string(),
1289            "csv".to_string(),
1290            "file=results.csv".to_string(),
1291            "resolution=100".to_string(),
1292        ];
1293        let result = parser.parse_typed(&args).unwrap();
1294
1295        match result.get("output") {
1296            Some(ParsedValue::Repeated(occurrences)) => {
1297                assert_eq!(
1298                    occurrences[0].params.get("resolution"),
1299                    Some(&"100".to_string())
1300                );
1301            }
1302            other => panic!("Expected Repeated([csv]), got {:?}", other),
1303        }
1304    }
1305
1306    #[test]
1307    fn test_parse_repeatable_option_multiple_discriminants_both_parse() {
1308        let definition = create_repeatable_test_definition();
1309        let parser = CliParser::new(&definition);
1310
1311        let args = vec![
1312            "--output".to_string(),
1313            "csv".to_string(),
1314            "file=results.csv".to_string(),
1315            "--output".to_string(),
1316            "plot".to_string(),
1317            "file=chart.png".to_string(),
1318        ];
1319        let result = parser.parse_typed(&args).unwrap();
1320
1321        match result.get("output") {
1322            Some(ParsedValue::Repeated(occurrences)) => {
1323                assert_eq!(occurrences.len(), 2);
1324                assert_eq!(occurrences[0].discriminant, "csv");
1325                assert_eq!(occurrences[1].discriminant, "plot");
1326            }
1327            other => panic!("Expected Repeated([csv, plot]), got {:?}", other),
1328        }
1329    }
1330
1331    #[test]
1332    fn test_parse_repeatable_option_same_discriminant_different_params_both_kept() {
1333        let definition = create_repeatable_test_definition();
1334        let parser = CliParser::new(&definition);
1335
1336        let args = vec![
1337            "--output".to_string(),
1338            "csv".to_string(),
1339            "file=a.csv".to_string(),
1340            "--output".to_string(),
1341            "csv".to_string(),
1342            "file=b.csv".to_string(),
1343            "resolution=50".to_string(),
1344        ];
1345        let result = parser.parse_typed(&args).unwrap();
1346
1347        match result.get("output") {
1348            Some(ParsedValue::Repeated(occurrences)) => {
1349                assert_eq!(occurrences.len(), 2);
1350            }
1351            other => panic!("Expected Repeated([csv, csv]), got {:?}", other),
1352        }
1353    }
1354
1355    #[test]
1356    fn test_parse_repeatable_option_duplicate_occurrence_errors() {
1357        let definition = create_repeatable_test_definition();
1358        let parser = CliParser::new(&definition);
1359
1360        let args = vec![
1361            "--output".to_string(),
1362            "csv".to_string(),
1363            "file=a.csv".to_string(),
1364            "--output".to_string(),
1365            "csv".to_string(),
1366            "file=a.csv".to_string(),
1367        ];
1368        let result = parser.parse_typed(&args);
1369
1370        assert!(result.is_err());
1371        match result.unwrap_err() {
1372            crate::error::DynamicCliError::Parse(ParseError::DuplicateOptionOccurrence {
1373                ..
1374            }) => {}
1375            other => panic!("Expected DuplicateOptionOccurrence error, got {:?}", other),
1376        }
1377    }
1378
1379    #[test]
1380    fn test_parse_repeatable_option_missing_required_param_errors() {
1381        let definition = create_repeatable_test_definition();
1382        let parser = CliParser::new(&definition);
1383
1384        // "file" is required for the csv discriminant and is not supplied.
1385        let args = vec!["--output".to_string(), "csv".to_string()];
1386        let result = parser.parse_typed(&args);
1387
1388        assert!(result.is_err());
1389        match result.unwrap_err() {
1390            crate::error::DynamicCliError::Parse(ParseError::MissingRequiredOptionParameter {
1391                key,
1392                ..
1393            }) => {
1394                assert_eq!(key, "file");
1395            }
1396            other => panic!(
1397                "Expected MissingRequiredOptionParameter error, got {:?}",
1398                other
1399            ),
1400        }
1401    }
1402
1403    #[test]
1404    fn test_parse_repeatable_option_unknown_param_key_errors() {
1405        let definition = create_repeatable_test_definition();
1406        let parser = CliParser::new(&definition);
1407
1408        let args = vec![
1409            "--output".to_string(),
1410            "csv".to_string(),
1411            "file=a.csv".to_string(),
1412            "compression=gzip".to_string(),
1413        ];
1414        let result = parser.parse_typed(&args);
1415
1416        assert!(result.is_err());
1417        match result.unwrap_err() {
1418            crate::error::DynamicCliError::Parse(ParseError::UnknownOptionParameter {
1419                key,
1420                ..
1421            }) => {
1422                assert_eq!(key, "compression");
1423            }
1424            other => panic!("Expected UnknownOptionParameter error, got {:?}", other),
1425        }
1426    }
1427
1428    #[test]
1429    fn test_parse_repeatable_option_unknown_discriminant_errors() {
1430        let definition = create_repeatable_test_definition();
1431        let parser = CliParser::new(&definition);
1432
1433        let args = vec![
1434            "--output".to_string(),
1435            "xml".to_string(),
1436            "file=a.xml".to_string(),
1437        ];
1438        let result = parser.parse_typed(&args);
1439
1440        assert!(result.is_err());
1441        match result.unwrap_err() {
1442            crate::error::DynamicCliError::Parse(ParseError::UnknownDiscriminant {
1443                value, ..
1444            }) => {
1445                assert_eq!(value, "xml");
1446            }
1447            other => panic!("Expected UnknownDiscriminant error, got {:?}", other),
1448        }
1449    }
1450
1451    #[test]
1452    fn test_parse_legacy_drops_repeated_values() {
1453        // parse() (Option A design: non-breaking wrapper) must keep
1454        // working for definitions with no repeatable options — and
1455        // silently drop Repeated entries rather than erroring, since no
1456        // pre-DD-024 caller can represent them anyway.
1457        let definition = create_repeatable_test_definition();
1458        let parser = CliParser::new(&definition);
1459
1460        let args = vec![
1461            "--output".to_string(),
1462            "csv".to_string(),
1463            "file=a.csv".to_string(),
1464        ];
1465        let result = parser.parse(&args).unwrap();
1466
1467        assert_eq!(result.get("output"), None);
1468    }
1469
1470    // ========================================================================
1471    // parse_typed_segment() tests (DD-026, #52 / #54)
1472    // ========================================================================
1473
1474    /// Helper: one positional argument (still open arity) *and* a
1475    /// repeatable option, so a repeatable occurrence's key=value span can
1476    /// be followed by this same command's own remaining positional value —
1477    /// the scenario #54's boundary-case acceptance criterion targets.
1478    fn create_repeatable_and_positional_test_definition() -> CommandDefinition {
1479        let mut option_parameters = HashMap::new();
1480        option_parameters.insert(
1481            "csv".to_string(),
1482            vec![ArgumentDefinition {
1483                name: "file".to_string(),
1484                arg_type: ArgumentType::Path,
1485                required: true,
1486                description: "Destination CSV file".to_string(),
1487                validation: vec![],
1488                secure: false,
1489            }],
1490        );
1491
1492        CommandDefinition {
1493            name: "output".to_string(),
1494            aliases: vec![],
1495            description: "Configure output and a target label".to_string(),
1496            required: false,
1497            arguments: vec![ArgumentDefinition {
1498                name: "target".to_string(),
1499                arg_type: ArgumentType::String,
1500                required: false,
1501                description: "Target label".to_string(),
1502                validation: vec![],
1503                secure: false,
1504            }],
1505            options: vec![OptionDefinition {
1506                name: "format".to_string(),
1507                short: None,
1508                long: Some("format".to_string()),
1509                option_type: ArgumentType::String,
1510                required: false,
1511                default: None,
1512                description: "Output format".to_string(),
1513                choices: vec!["csv".to_string()],
1514                repeatable: true,
1515                option_parameters,
1516            }],
1517            implementation: "output_handler".to_string(),
1518            continue_on_failure: false,
1519            requires_success: false,
1520        }
1521    }
1522
1523    #[test]
1524    fn test_parse_typed_segment_stops_at_boundary_with_zero_arity() {
1525        // create_repeatable_test_definition()'s "export" command has zero
1526        // positional arguments: the very first bare token is already past
1527        // arity and must stop the segment without being consumed or
1528        // erroring.
1529        let definition = create_repeatable_test_definition();
1530        let parser = CliParser::new(&definition);
1531
1532        let args = vec!["solve".to_string()];
1533        let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1534
1535        assert_eq!(consumed, 0);
1536        assert!(!result.contains_key("output"));
1537    }
1538
1539    #[test]
1540    fn test_parse_typed_segment_stops_after_repeatable_occurrence_with_zero_arity() {
1541        // Combines the zero-arity boundary with a completed repeatable
1542        // occurrence beforehand — mirrors DD-026's own chrom-rs-motivated
1543        // example (an "output"-shaped command whose last occurrence is
1544        // immediately followed by the next chained command's name).
1545        let definition = create_repeatable_test_definition();
1546        let parser = CliParser::new(&definition);
1547
1548        let args = vec![
1549            "--output".to_string(),
1550            "csv".to_string(),
1551            "file=result.csv".to_string(),
1552            "solve".to_string(),
1553        ];
1554        let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1555
1556        assert_eq!(consumed, 3, "'solve' at index 3 must not be consumed");
1557        match result.get("output") {
1558            Some(ParsedValue::Repeated(occurrences)) => {
1559                assert_eq!(occurrences.len(), 1);
1560                assert_eq!(occurrences[0].discriminant, "csv");
1561            }
1562            other => panic!("Expected Repeated([csv]), got {:?}", other),
1563        }
1564    }
1565
1566    #[test]
1567    fn test_parse_typed_segment_resumes_positional_counting_after_repeatable_occurrence() {
1568        // "target" is this command's own positional argument (arity still
1569        // open) — it must be read as such, not mistaken for a continuing
1570        // param of the "csv" occurrence that precedes it.
1571        let definition = create_repeatable_and_positional_test_definition();
1572        let parser = CliParser::new(&definition);
1573
1574        let args = vec![
1575            "--format".to_string(),
1576            "csv".to_string(),
1577            "file=out.csv".to_string(),
1578            "primary".to_string(),
1579        ];
1580        let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1581
1582        assert_eq!(consumed, args.len());
1583        assert_eq!(
1584            result.get("target"),
1585            Some(&ParsedValue::Scalar("primary".to_string()))
1586        );
1587    }
1588
1589    #[test]
1590    fn test_parse_repeatable_occurrence_bare_token_no_longer_errors() {
1591        // Companion to the segment test above, exercised directly through
1592        // parse_typed(): the fix to parse_repeatable_occurrence (needed for
1593        // parse_typed_segment's boundary detection to work at all) is
1594        // shared code, so parse_typed benefits from it too. No existing
1595        // test asserted the old error behaviour for this input, so this is
1596        // additive, not a break of "byte-for-byte unchanged".
1597        let definition = create_repeatable_and_positional_test_definition();
1598        let parser = CliParser::new(&definition);
1599
1600        let args = vec![
1601            "--format".to_string(),
1602            "csv".to_string(),
1603            "file=out.csv".to_string(),
1604            "primary".to_string(),
1605        ];
1606        let result = parser.parse_typed(&args).unwrap();
1607
1608        assert_eq!(
1609            result.get("target"),
1610            Some(&ParsedValue::Scalar("primary".to_string()))
1611        );
1612    }
1613
1614    #[test]
1615    fn test_parse_typed_segment_negative_number_heuristic_preserved_at_boundary() {
1616        // create_test_definition() has exactly two positional arguments
1617        // (input, output). Two negative-number tokens fill both; a third
1618        // bare token is past arity and must stop the segment, exactly as
1619        // for any other kind of token.
1620        let definition = create_test_definition();
1621        let parser = CliParser::new(&definition);
1622
1623        let args = vec!["-3".to_string(), "-5".to_string(), "solve".to_string()];
1624        let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1625
1626        assert_eq!(consumed, 2, "'solve' at index 2 must not be consumed");
1627        assert_eq!(
1628            result.get("input"),
1629            Some(&ParsedValue::Scalar("-3".to_string()))
1630        );
1631        assert_eq!(
1632            result.get("output"),
1633            Some(&ParsedValue::Scalar("-5".to_string()))
1634        );
1635    }
1636
1637    #[test]
1638    fn test_parse_typed_segment_end_of_input_matches_parse_typed() {
1639        // Single-command, non-chained case: no leftover boundary token.
1640        // consumed must equal args.len(), and the result must be
1641        // identical to what parse_typed() returns for the same input.
1642        let definition = create_test_definition();
1643        let parser = CliParser::new(&definition);
1644
1645        let args = vec![
1646            "input.txt".to_string(),
1647            "output.txt".to_string(),
1648            "--verbose".to_string(),
1649        ];
1650
1651        let (segment_result, consumed) = parser.parse_typed_segment(&args).unwrap();
1652        let typed_result = parser.parse_typed(&args).unwrap();
1653
1654        assert_eq!(consumed, args.len());
1655        assert_eq!(segment_result, typed_result);
1656    }
1657
1658    #[test]
1659    fn test_parse_typed_segment_too_many_arguments_still_reported_by_dispatch_path() {
1660        // parse_typed_segment() itself never raises too_many_arguments —
1661        // it stops cleanly instead (DD-026's segmentation phase is
1662        // responsible for turning a non-resolving leftover token back into
1663        // that same error). This test only pins down the "never errors on
1664        // overflow" half: the segment boundary is reported via `consumed`,
1665        // not via Err(..).
1666        let definition = create_test_definition();
1667        let parser = CliParser::new(&definition);
1668
1669        let args = vec![
1670            "input.txt".to_string(),
1671            "output.txt".to_string(),
1672            "unexpected_extra".to_string(),
1673        ];
1674        let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1675
1676        assert_eq!(consumed, 2, "'unexpected_extra' must not be consumed");
1677        assert_eq!(
1678            result.get("input"),
1679            Some(&ParsedValue::Scalar("input.txt".to_string()))
1680        );
1681        assert_eq!(
1682            result.get("output"),
1683            Some(&ParsedValue::Scalar("output.txt".to_string()))
1684        );
1685    }
1686}