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//! };
35//!
36//! let parser = CliParser::new(&definition);
37//! let args = vec!["file.txt".to_string()];
38//! let parsed = parser.parse(&args).unwrap();
39//!
40//! assert_eq!(parsed.get("input"), Some(&"file.txt".to_string()));
41//! ```
42
43#[allow(unused_imports)]
44use crate::config::schema::{ArgumentDefinition, CommandDefinition, OptionDefinition};
45use crate::error::{ParseError, Result};
46use crate::parser::type_parser;
47use std::collections::HashMap;
48
49/// CLI argument parser
50///
51/// Parses command-line arguments according to a [`CommandDefinition`].
52/// The parser handles both positional arguments and named options
53/// with type conversion and validation.
54///
55/// # Lifetime
56///
57/// The parser holds a reference to a [`CommandDefinition`] and therefore
58/// has a lifetime parameter `'a` that must outlive the parser.
59///
60/// # Example
61///
62/// ```
63/// use dynamic_cli::parser::cli_parser::CliParser;
64/// use dynamic_cli::config::schema::{
65///     CommandDefinition, OptionDefinition, ArgumentType
66/// };
67/// use std::collections::HashMap;
68///
69/// let definition = CommandDefinition {
70///     name: "test".to_string(),
71///     aliases: vec![],
72///     description: "Test command".to_string(),
73///     required: false,
74///     arguments: vec![],
75///     options: vec![
76///         OptionDefinition {
77///             name: "verbose".to_string(),
78///             short: Some("v".to_string()),
79///             long: Some("verbose".to_string()),
80///             option_type: ArgumentType::Bool,
81///             required: false,
82///             default: Some("false".to_string()),
83///             description: "Verbose output".to_string(),
84///             choices: vec![],
85///             repeatable: false,
86///             option_parameters: HashMap::new(),
87///         }
88///     ],
89///     implementation: "handler".to_string(),
90/// };
91///
92/// let parser = CliParser::new(&definition);
93/// let args = vec!["-v".to_string()];
94/// let parsed = parser.parse(&args).unwrap();
95///
96/// assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
97/// ```
98pub struct CliParser<'a> {
99    /// The command definition that specifies expected arguments and options
100    definition: &'a CommandDefinition,
101}
102
103/// A single occurrence of a repeatable option
104///
105/// Produced when a `repeatable: true` option is encountered on the
106/// command line: `--output csv file=results.csv resolution=100` becomes
107/// `OptionOccurrence { discriminant: "csv", params: {"file": "results.csv",
108/// "resolution": "100"} }`.
109///
110/// `params` values are stored as strings after type validation against
111/// `option_parameters[discriminant]`, consistent with how scalar option
112/// and argument values are stored (see [`ParsedValue::Scalar`]).
113#[derive(Debug, Clone, PartialEq)]
114pub struct OptionOccurrence {
115    /// The token immediately following the flag, validated against the
116    /// option's `choices`.
117    pub discriminant: String,
118    /// The `key=value` pairs supplied for this occurrence.
119    pub params: HashMap<String, String>,
120}
121
122/// The value parsed for a single positional argument or option
123///
124/// [`CliParser::parse_typed`] returns `HashMap<String, ParsedValue>` so
125/// that repeatable options (which may occur zero or more times, each
126/// with their own sub-parameters) and plain scalar values can coexist in
127/// a single result map. [`CliParser::parse`] remains additive and
128/// unaffected — see its docs for how the two relate.
129#[derive(Debug, Clone, PartialEq)]
130pub enum ParsedValue {
131    /// A plain positional argument or non-repeatable option value.
132    Scalar(String),
133    /// Every occurrence of a repeatable option, in command-line order.
134    Repeated(Vec<OptionOccurrence>),
135}
136
137impl<'a> CliParser<'a> {
138    /// Create a new CLI parser for the given command definition
139    ///
140    /// # Arguments
141    ///
142    /// * `definition` - The command definition specifying expected arguments
143    ///
144    /// # Example
145    ///
146    /// ```
147    /// use dynamic_cli::parser::cli_parser::CliParser;
148    /// use dynamic_cli::config::schema::CommandDefinition;
149    ///
150    /// # let definition = CommandDefinition {
151    /// #     name: "test".to_string(),
152    /// #     aliases: vec![],
153    /// #     description: "".to_string(),
154    /// #     required: false,
155    /// #     arguments: vec![],
156    /// #     options: vec![],
157    /// #     implementation: "".to_string(),
158    /// # };
159    /// let parser = CliParser::new(&definition);
160    /// ```
161    pub fn new(definition: &'a CommandDefinition) -> Self {
162        Self { definition }
163    }
164
165    /// Parse command-line arguments into a HashMap of strings
166    ///
167    /// Thin, non-breaking wrapper around [`Self::parse_typed`] for callers
168    /// that only deal in scalar values. Any [`ParsedValue::Repeated`] entry
169    /// (i.e. any `repeatable: true` option) is silently dropped from the
170    /// result — no command definition predating DD-024 can have one, so
171    /// existing callers see no behaviour change. Once the dispatch layer
172    /// is migrated to consume [`crate::parser::ParsedArgs`] directly
173    /// (#39, in progress — the type exists but `interface/cli.rs` and
174    /// `interface/repl.rs` still call this method, not `parse_typed`),
175    /// this method can be removed.
176    ///
177    /// # Arguments
178    ///
179    /// * `args` - Slice of argument strings (excluding the command name)
180    ///
181    /// # Returns
182    ///
183    /// A HashMap mapping argument/option names to their string values.
184    /// All values are stored as strings after type validation.
185    ///
186    /// # Errors
187    ///
188    /// - [`ParseError::MissingArgument`] if required arguments are missing
189    /// - [`ParseError::MissingOption`] if required options are missing
190    /// - [`ParseError::UnknownOption`] if an unrecognized option is provided
191    /// - [`ParseError::TypeParseError`] if a value cannot be converted to its expected type
192    /// - [`ParseError::TooManyArguments`] if more positional arguments than expected
193    ///
194    /// # Example
195    ///
196    /// ```
197    /// use dynamic_cli::parser::cli_parser::CliParser;
198    /// use dynamic_cli::config::schema::{
199    ///     CommandDefinition, ArgumentDefinition, ArgumentType
200    /// };
201    ///
202    /// let definition = CommandDefinition {
203    ///     name: "greet".to_string(),
204    ///     aliases: vec![],
205    ///     description: "Greet someone".to_string(),
206    ///     required: false,
207    ///     arguments: vec![
208    ///         ArgumentDefinition {
209    ///             name: "name".to_string(),
210    ///             arg_type: ArgumentType::String,
211    ///             required: true,
212    ///             description: "Name".to_string(),
213    ///             validation: vec![],
214    ///             secure: false,
215    ///         }
216    ///     ],
217    ///     options: vec![],
218    ///     implementation: "handler".to_string(),
219    /// };
220    ///
221    /// let parser = CliParser::new(&definition);
222    /// let result = parser.parse(&["Alice".to_string()]).unwrap();
223    /// assert_eq!(result.get("name"), Some(&"Alice".to_string()));
224    /// ```
225    pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
226        let typed = self.parse_typed(args)?;
227
228        Ok(typed
229            .into_iter()
230            .filter_map(|(name, value)| match value {
231                ParsedValue::Scalar(s) => Some((name, s)),
232                ParsedValue::Repeated(_) => None,
233            })
234            .collect())
235    }
236
237    /// Parse command-line arguments into a HashMap of [`ParsedValue`]
238    ///
239    /// Like [`Self::parse`], but preserves repeatable options as
240    /// [`ParsedValue::Repeated`] instead of dropping them. This is the
241    /// method that actually implements DD-024 parsing; `parse()` is a
242    /// filtering wrapper around it.
243    ///
244    /// # Arguments
245    ///
246    /// * `args` - Slice of argument strings (excluding the command name)
247    ///
248    /// # Errors
249    ///
250    /// In addition to the errors documented on [`Self::parse`]:
251    /// - [`ParseError::UnknownDiscriminant`] if the token following a
252    ///   repeatable option's flag is not in that option's `choices`
253    /// - [`ParseError::UnknownOptionParameter`] if a `key=value` pair uses
254    ///   a key not declared in `option_parameters[discriminant]`
255    /// - [`ParseError::MissingRequiredOptionParameter`] if a required key
256    ///   is absent from an occurrence
257    /// - [`ParseError::DuplicateOptionOccurrence`] if the same
258    ///   discriminant is supplied twice with identical `key=value` pairs
259    pub fn parse_typed(&self, args: &[String]) -> Result<HashMap<String, ParsedValue>> {
260        let mut result = HashMap::new();
261        let mut positional_index = 0;
262        let mut i = 0;
263
264        // Parse arguments
265        while i < args.len() {
266            let arg = &args[i];
267
268            if arg.starts_with("--") {
269                // Long option
270                self.parse_long_option(arg, args, &mut i, &mut result)?;
271            } else if arg.starts_with('-') && arg.len() > 1 {
272                // Short option (ensure it's not just a negative number)
273                if arg
274                    .chars()
275                    .nth(1)
276                    .map(|c| c.is_ascii_digit())
277                    .unwrap_or(false)
278                {
279                    // This is a negative number, treat as positional
280                    self.parse_positional_argument(arg, positional_index, &mut result)?;
281                    positional_index += 1;
282                } else {
283                    self.parse_short_option(arg, args, &mut i, &mut result)?;
284                }
285            } else {
286                // Positional argument
287                self.parse_positional_argument(arg, positional_index, &mut result)?;
288                positional_index += 1;
289            }
290
291            i += 1;
292        }
293
294        // Apply defaults for missing optional options
295        self.apply_defaults(&mut result)?;
296
297        // Validate all required arguments are present
298        self.validate_required_arguments(&result)?;
299        self.validate_required_options(&result)?;
300
301        Ok(result)
302    }
303
304    /// Parse a long option (--option or --option=value)
305    fn parse_long_option(
306        &self,
307        arg: &str,
308        args: &[String],
309        index: &mut usize,
310        result: &mut HashMap<String, ParsedValue>,
311    ) -> Result<()> {
312        let arg_without_dashes = &arg[2..];
313
314        // Check for --option=value format
315        if let Some(eq_pos) = arg_without_dashes.find('=') {
316            let option_name = &arg_without_dashes[..eq_pos];
317            let value = &arg_without_dashes[eq_pos + 1..];
318
319            let option = self.find_option_by_long(option_name)?;
320            if option.repeatable {
321                // A repeatable option's discriminant/params are never
322                // attached via `=` — only the space-separated form is
323                // supported (see parse_repeatable_occurrence).
324                return Err(ParseError::InvalidSyntax {
325                    details: format!(
326                        "Option --{} is repeatable and does not support --{}=<value>",
327                        option.name, option.name
328                    ),
329                    hint: Some(format!(
330                        "Usage: --{} <{}> [key=value ...]",
331                        option.name,
332                        option.choices.join("|")
333                    )),
334                }
335                .into());
336            }
337            let parsed_value = type_parser::parse_value(value, option.option_type)?;
338            result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
339        } else {
340            // --option format (value might be next arg)
341            let option = self.find_option_by_long(arg_without_dashes)?;
342
343            if option.repeatable {
344                self.parse_repeatable_occurrence(option, args, index, result)?;
345            } else if matches!(
346                option.option_type,
347                crate::config::schema::ArgumentType::Bool
348            ) {
349                result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
350            } else {
351                // Non-boolean: expect value in next argument
352                *index += 1;
353                if *index >= args.len() {
354                    return Err(ParseError::InvalidSyntax {
355                        details: format!(
356                            "Option --{} requires a value",
357                            option.long.as_ref().unwrap()
358                        ),
359                        hint: Some(format!(
360                            "Usage: --{}=<value> or --{} <value>",
361                            option.long.as_ref().unwrap(),
362                            option.long.as_ref().unwrap()
363                        )),
364                    }
365                    .into());
366                }
367
368                let value = &args[*index];
369                let parsed_value = type_parser::parse_value(value, option.option_type)?;
370                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
371            }
372        }
373
374        Ok(())
375    }
376
377    /// Parse a short option (-o or -o value)
378    fn parse_short_option(
379        &self,
380        arg: &str,
381        args: &[String],
382        index: &mut usize,
383        result: &mut HashMap<String, ParsedValue>,
384    ) -> Result<()> {
385        let short_flag = &arg[1..2];
386        let option = self.find_option_by_short(short_flag)?;
387
388        if option.repeatable {
389            if arg.len() > 2 {
390                return Err(ParseError::InvalidSyntax {
391                    details: format!(
392                        "Option -{} is repeatable and does not support an attached value",
393                        short_flag
394                    ),
395                    hint: Some(format!(
396                        "Usage: -{} <{}> [key=value ...]",
397                        short_flag,
398                        option.choices.join("|")
399                    )),
400                }
401                .into());
402            }
403            self.parse_repeatable_occurrence(option, args, index, result)?;
404        } else if matches!(
405            option.option_type,
406            crate::config::schema::ArgumentType::Bool
407        ) {
408            result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
409        } else {
410            // Check if value is attached (e.g., -ovalue)
411            if arg.len() > 2 {
412                let value = &arg[2..];
413                let parsed_value = type_parser::parse_value(value, option.option_type)?;
414                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
415            } else {
416                // Value is next argument
417                *index += 1;
418                if *index >= args.len() {
419                    return Err(ParseError::InvalidSyntax {
420                        details: format!("Option -{} requires a value", short_flag),
421                        hint: Some(format!(
422                            "Usage: -{}<value> or -{} <value>",
423                            short_flag, short_flag
424                        )),
425                    }
426                    .into());
427                }
428
429                let value = &args[*index];
430                let parsed_value = type_parser::parse_value(value, option.option_type)?;
431                result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
432            }
433        }
434
435        Ok(())
436    }
437
438    /// Parse a positional argument
439    fn parse_positional_argument(
440        &self,
441        value: &str,
442        index: usize,
443        result: &mut HashMap<String, ParsedValue>,
444    ) -> Result<()> {
445        if index >= self.definition.arguments.len() {
446            return Err(ParseError::too_many_arguments(
447                &self.definition.name,
448                self.definition.arguments.len(),
449                index + 1,
450            )
451            .into());
452        }
453
454        let arg_def = &self.definition.arguments[index];
455        let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
456        result.insert(arg_def.name.clone(), ParsedValue::Scalar(parsed_value));
457
458        Ok(())
459    }
460
461    /// Parse one occurrence of a repeatable option
462    ///
463    /// On entry, `index` points at the option's flag token. Reads the
464    /// discriminant token immediately following it, validates it against
465    /// `option.choices`, then consumes `key=value` tokens until the next
466    /// flag (any token starting with `-`) or the end of input — a
467    /// `key=value` pair can never start with `-` itself, so this is
468    /// unambiguous, unlike the top-level positional/negative-number case.
469    ///
470    /// On return, `index` points at the last token consumed (the
471    /// discriminant if no parameters followed, or the last `key=value`
472    /// token), matching the convention already used by
473    /// [`Self::parse_long_option`] / [`Self::parse_short_option`] — the
474    /// caller's own `i += 1` advances past it.
475    fn parse_repeatable_occurrence(
476        &self,
477        option: &OptionDefinition,
478        args: &[String],
479        index: &mut usize,
480        result: &mut HashMap<String, ParsedValue>,
481    ) -> Result<()> {
482        // Read the discriminant token.
483        *index += 1;
484        if *index >= args.len() {
485            return Err(ParseError::InvalidSyntax {
486                details: format!("Option --{} requires a discriminant", option.name),
487                hint: Some(format!(
488                    "Usage: --{} <{}> [key=value ...]",
489                    option.name,
490                    option.choices.join("|")
491                )),
492            }
493            .into());
494        }
495        let discriminant = args[*index].clone();
496        if !option.choices.contains(&discriminant) {
497            return Err(ParseError::UnknownDiscriminant {
498                option: option.name.clone(),
499                value: discriminant,
500                valid_choices: option.choices.clone(),
501                suggestion: Some(format!(
502                    "Run --help {} to see valid --{} kinds.",
503                    self.definition.name, option.name
504                )),
505            }
506            .into());
507        }
508
509        // Guaranteed present by validate_options() (#36) once
510        // repeatable/choices/option_parameters consistency has been
511        // validated at config-load time.
512        let empty: Vec<ArgumentDefinition> = Vec::new();
513        let param_defs = option
514            .option_parameters
515            .get(&discriminant)
516            .unwrap_or(&empty);
517
518        // Consume key=value tokens until the next flag or end of input.
519        let mut params: HashMap<String, String> = HashMap::new();
520        while *index + 1 < args.len() {
521            let next = &args[*index + 1];
522            if next.starts_with('-') {
523                break;
524            }
525
526            let eq_pos = match next.find('=') {
527                Some(pos) => pos,
528                None => {
529                    return Err(ParseError::InvalidSyntax {
530                        details: format!(
531                            "Expected key=value for --{} {}, got: '{}'",
532                            option.name, discriminant, next
533                        ),
534                        hint: Some("Sub-parameters must use the key=value form.".to_string()),
535                    }
536                    .into());
537                }
538            };
539            let key = &next[..eq_pos];
540            let value = &next[eq_pos + 1..];
541
542            let arg_def = param_defs.iter().find(|a| a.name == key).ok_or_else(|| {
543                ParseError::UnknownOptionParameter {
544                    option: option.name.clone(),
545                    discriminant: discriminant.clone(),
546                    key: key.to_string(),
547                    valid_keys: param_defs.iter().map(|a| a.name.clone()).collect(),
548                    suggestion: Some(format!(
549                        "Run --help {} to see valid keys for --{} {}.",
550                        self.definition.name, option.name, discriminant
551                    )),
552                }
553            })?;
554
555            let typed_value = type_parser::parse_value(value, arg_def.arg_type)?;
556            params.insert(key.to_string(), typed_value);
557
558            *index += 1;
559        }
560
561        // Validate required keys are present.
562        for arg_def in param_defs {
563            if arg_def.required && !params.contains_key(&arg_def.name) {
564                return Err(ParseError::MissingRequiredOptionParameter {
565                    option: option.name.clone(),
566                    discriminant: discriminant.clone(),
567                    key: arg_def.name.clone(),
568                    suggestion: Some(format!(
569                        "Run --help {} to see required keys for --{} {}.",
570                        self.definition.name, option.name, discriminant
571                    )),
572                }
573                .into());
574            }
575        }
576
577        let occurrence = OptionOccurrence {
578            discriminant: discriminant.clone(),
579            params,
580        };
581
582        match result
583            .entry(option.name.clone())
584            .or_insert_with(|| ParsedValue::Repeated(Vec::new()))
585        {
586            ParsedValue::Repeated(occurrences) => {
587                if occurrences.contains(&occurrence) {
588                    return Err(ParseError::DuplicateOptionOccurrence {
589                        option: option.name.clone(),
590                        discriminant: occurrence.discriminant.clone(),
591                        params: occurrence.params.clone().into_iter().collect(),
592                        suggestion: Some(format!(
593                            "Remove one of the two identical --{} {} occurrences.",
594                            option.name, discriminant
595                        )),
596                    }
597                    .into());
598                }
599                occurrences.push(occurrence);
600            }
601            ParsedValue::Scalar(_) => unreachable!(
602                "option '{}' marked repeatable cannot already hold a Scalar value",
603                option.name
604            ),
605        }
606
607        Ok(())
608    }
609
610    /// Apply default values for options not provided
611    fn apply_defaults(&self, result: &mut HashMap<String, ParsedValue>) -> Result<()> {
612        for option in &self.definition.options {
613            if !result.contains_key(&option.name) {
614                if let Some(ref default) = option.default {
615                    // Validate the default value
616                    let parsed_default = type_parser::parse_value(default, option.option_type)?;
617                    result.insert(option.name.clone(), ParsedValue::Scalar(parsed_default));
618                }
619            }
620        }
621        Ok(())
622    }
623
624    /// Validate that all required arguments are present
625    fn validate_required_arguments(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
626        for arg in &self.definition.arguments {
627            if arg.required && !result.contains_key(&arg.name) {
628                return Err(ParseError::missing_argument(&arg.name, &self.definition.name).into());
629            }
630        }
631        Ok(())
632    }
633
634    /// Validate that all required options are present
635    fn validate_required_options(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
636        for option in &self.definition.options {
637            if option.required && !result.contains_key(&option.name) {
638                return Err(ParseError::missing_option(
639                    &option
640                        .long
641                        .clone()
642                        .or(option.short.clone())
643                        .unwrap_or_default(),
644                    &self.definition.name,
645                )
646                .into());
647            }
648        }
649        Ok(())
650    }
651
652    /// Find an option by its long form
653    fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
654        self.definition
655            .options
656            .iter()
657            .find(|opt| opt.long.as_deref() == Some(long))
658            .ok_or_else(|| {
659                let available: Vec<String> = self
660                    .definition
661                    .options
662                    .iter()
663                    .filter_map(|o| o.long.clone())
664                    .collect();
665                ParseError::unknown_option_with_suggestions(
666                    &format!("--{}", long),
667                    &self.definition.name,
668                    &available,
669                )
670                .into()
671            })
672    }
673
674    /// Find an option by its short form
675    fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
676        self.definition
677            .options
678            .iter()
679            .find(|opt| opt.short.as_deref() == Some(short))
680            .ok_or_else(|| {
681                let available: Vec<String> = self
682                    .definition
683                    .options
684                    .iter()
685                    .filter_map(|o| o.short.clone())
686                    .collect();
687                ParseError::unknown_option_with_suggestions(
688                    &format!("-{}", short),
689                    &self.definition.name,
690                    &available,
691                )
692                .into()
693            })
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::config::schema::{ArgumentType, OptionDefinition};
701
702    /// Helper to create a test command definition
703    fn create_test_definition() -> CommandDefinition {
704        CommandDefinition {
705            name: "test".to_string(),
706            aliases: vec![],
707            description: "Test command".to_string(),
708            required: false,
709            arguments: vec![
710                ArgumentDefinition {
711                    name: "input".to_string(),
712                    arg_type: ArgumentType::Path,
713                    required: true,
714                    description: "Input file".to_string(),
715                    validation: vec![],
716                    secure: false,
717                },
718                ArgumentDefinition {
719                    name: "output".to_string(),
720                    arg_type: ArgumentType::Path,
721                    required: false,
722                    description: "Output file".to_string(),
723                    validation: vec![],
724                    secure: false,
725                },
726            ],
727            options: vec![
728                OptionDefinition {
729                    name: "verbose".to_string(),
730                    short: Some("v".to_string()),
731                    long: Some("verbose".to_string()),
732                    option_type: ArgumentType::Bool,
733                    required: false,
734                    default: Some("false".to_string()),
735                    description: "Verbose output".to_string(),
736                    choices: vec![],
737                    repeatable: false,
738                    option_parameters: HashMap::new(),
739                },
740                OptionDefinition {
741                    name: "count".to_string(),
742                    short: Some("c".to_string()),
743                    long: Some("count".to_string()),
744                    option_type: ArgumentType::Integer,
745                    required: false,
746                    default: Some("10".to_string()),
747                    description: "Count".to_string(),
748                    choices: vec![],
749                    repeatable: false,
750                    option_parameters: HashMap::new(),
751                },
752            ],
753            implementation: "handler".to_string(),
754        }
755    }
756
757    // ========================================================================
758    // Positional arguments tests
759    // ========================================================================
760
761    #[test]
762    fn test_parse_single_positional_argument() {
763        let definition = create_test_definition();
764        let parser = CliParser::new(&definition);
765
766        let args = vec!["input.txt".to_string()];
767        let result = parser.parse(&args).unwrap();
768
769        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
770    }
771
772    #[test]
773    fn test_parse_multiple_positional_arguments() {
774        let definition = create_test_definition();
775        let parser = CliParser::new(&definition);
776
777        let args = vec!["input.txt".to_string(), "output.txt".to_string()];
778        let result = parser.parse(&args).unwrap();
779
780        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
781        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
782    }
783
784    #[test]
785    fn test_parse_missing_required_argument() {
786        let definition = create_test_definition();
787        let parser = CliParser::new(&definition);
788
789        let args: Vec<String> = vec![];
790        let result = parser.parse(&args);
791
792        assert!(result.is_err());
793        match result.unwrap_err() {
794            crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
795                argument, ..
796            }) => {
797                assert_eq!(argument, "input");
798            }
799            other => panic!("Expected MissingArgument error, got {:?}", other),
800        }
801    }
802
803    #[test]
804    fn test_parse_too_many_positional_arguments() {
805        let definition = create_test_definition();
806        let parser = CliParser::new(&definition);
807
808        let args = vec![
809            "input.txt".to_string(),
810            "output.txt".to_string(),
811            "extra.txt".to_string(),
812        ];
813        let result = parser.parse(&args);
814
815        assert!(result.is_err());
816        match result.unwrap_err() {
817            crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
818            other => panic!("Expected TooManyArguments error, got {:?}", other),
819        }
820    }
821
822    // ========================================================================
823    // Long options tests
824    // ========================================================================
825
826    #[test]
827    fn test_parse_long_boolean_option() {
828        let definition = create_test_definition();
829        let parser = CliParser::new(&definition);
830
831        let args = vec!["input.txt".to_string(), "--verbose".to_string()];
832        let result = parser.parse(&args).unwrap();
833
834        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
835    }
836
837    #[test]
838    fn test_parse_long_option_with_equals() {
839        let definition = create_test_definition();
840        let parser = CliParser::new(&definition);
841
842        let args = vec!["input.txt".to_string(), "--count=42".to_string()];
843        let result = parser.parse(&args).unwrap();
844
845        assert_eq!(result.get("count"), Some(&"42".to_string()));
846    }
847
848    #[test]
849    fn test_parse_long_option_with_space() {
850        let definition = create_test_definition();
851        let parser = CliParser::new(&definition);
852
853        let args = vec![
854            "input.txt".to_string(),
855            "--count".to_string(),
856            "42".to_string(),
857        ];
858        let result = parser.parse(&args).unwrap();
859
860        assert_eq!(result.get("count"), Some(&"42".to_string()));
861    }
862
863    #[test]
864    fn test_parse_unknown_long_option() {
865        let definition = create_test_definition();
866        let parser = CliParser::new(&definition);
867
868        let args = vec!["input.txt".to_string(), "--unknown".to_string()];
869        let result = parser.parse(&args);
870
871        assert!(result.is_err());
872        match result.unwrap_err() {
873            crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
874            other => panic!("Expected UnknownOption error, got {:?}", other),
875        }
876    }
877
878    // ========================================================================
879    // Short options tests
880    // ========================================================================
881
882    #[test]
883    fn test_parse_short_boolean_option() {
884        let definition = create_test_definition();
885        let parser = CliParser::new(&definition);
886
887        let args = vec!["input.txt".to_string(), "-v".to_string()];
888        let result = parser.parse(&args).unwrap();
889
890        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
891    }
892
893    #[test]
894    fn test_parse_short_option_with_space() {
895        let definition = create_test_definition();
896        let parser = CliParser::new(&definition);
897
898        let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
899        let result = parser.parse(&args).unwrap();
900
901        assert_eq!(result.get("count"), Some(&"42".to_string()));
902    }
903
904    #[test]
905    fn test_parse_short_option_attached_value() {
906        let definition = create_test_definition();
907        let parser = CliParser::new(&definition);
908
909        let args = vec!["input.txt".to_string(), "-c42".to_string()];
910        let result = parser.parse(&args).unwrap();
911
912        assert_eq!(result.get("count"), Some(&"42".to_string()));
913    }
914
915    #[test]
916    fn test_parse_negative_number_as_positional() {
917        let definition = create_test_definition();
918        let parser = CliParser::new(&definition);
919
920        // -123 should be treated as a positional argument, not an option
921        let args = vec!["-123".to_string()];
922        let result = parser.parse(&args).unwrap();
923
924        assert_eq!(result.get("input"), Some(&"-123".to_string()));
925    }
926
927    // ========================================================================
928    // Default values tests
929    // ========================================================================
930
931    #[test]
932    fn test_apply_default_values() {
933        let definition = create_test_definition();
934        let parser = CliParser::new(&definition);
935
936        let args = vec!["input.txt".to_string()];
937        let result = parser.parse(&args).unwrap();
938
939        // Default values should be applied
940        assert_eq!(result.get("verbose"), Some(&"false".to_string()));
941        assert_eq!(result.get("count"), Some(&"10".to_string()));
942    }
943
944    #[test]
945    fn test_override_default_values() {
946        let definition = create_test_definition();
947        let parser = CliParser::new(&definition);
948
949        let args = vec![
950            "input.txt".to_string(),
951            "-v".to_string(),
952            "-c".to_string(),
953            "5".to_string(),
954        ];
955        let result = parser.parse(&args).unwrap();
956
957        // Provided values should override defaults
958        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
959        assert_eq!(result.get("count"), Some(&"5".to_string()));
960    }
961
962    // ========================================================================
963    // Type conversion tests
964    // ========================================================================
965
966    #[test]
967    fn test_type_conversion_error() {
968        let definition = create_test_definition();
969        let parser = CliParser::new(&definition);
970
971        // "abc" cannot be parsed as integer
972        let args = vec![
973            "input.txt".to_string(),
974            "--count".to_string(),
975            "abc".to_string(),
976        ];
977        let result = parser.parse(&args);
978
979        assert!(result.is_err());
980    }
981
982    // ========================================================================
983    // Integration tests
984    // ========================================================================
985
986    #[test]
987    fn test_parse_complex_command_line() {
988        let definition = create_test_definition();
989        let parser = CliParser::new(&definition);
990
991        let args = vec![
992            "input.txt".to_string(),
993            "output.txt".to_string(),
994            "--verbose".to_string(),
995            "--count=100".to_string(),
996        ];
997        let result = parser.parse(&args).unwrap();
998
999        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1000        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1001        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1002        assert_eq!(result.get("count"), Some(&"100".to_string()));
1003    }
1004
1005    #[test]
1006    fn test_parse_mixed_options_and_arguments() {
1007        let definition = create_test_definition();
1008        let parser = CliParser::new(&definition);
1009
1010        // Options can be interspersed with positional arguments
1011        let args = vec![
1012            "--verbose".to_string(),
1013            "input.txt".to_string(),
1014            "-c".to_string(),
1015            "50".to_string(),
1016            "output.txt".to_string(),
1017        ];
1018        let result = parser.parse(&args).unwrap();
1019
1020        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1021        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1022        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1023        assert_eq!(result.get("count"), Some(&"50".to_string()));
1024    }
1025
1026    // ========================================================================
1027    // DD-024: repeatable options with option_parameters (#38)
1028    // ========================================================================
1029
1030    /// Helper: a command with a repeatable `--output` option, mirroring
1031    /// the chrom-rs motivating example (csv with an optional resolution,
1032    /// plot with just a file).
1033    fn create_repeatable_test_definition() -> CommandDefinition {
1034        let mut option_parameters = HashMap::new();
1035        option_parameters.insert(
1036            "csv".to_string(),
1037            vec![
1038                ArgumentDefinition {
1039                    name: "file".to_string(),
1040                    arg_type: ArgumentType::Path,
1041                    required: true,
1042                    description: "Destination CSV file".to_string(),
1043                    validation: vec![],
1044                    secure: false,
1045                },
1046                ArgumentDefinition {
1047                    name: "resolution".to_string(),
1048                    arg_type: ArgumentType::Integer,
1049                    required: false,
1050                    description: "Time-step resolution".to_string(),
1051                    validation: vec![],
1052                    secure: false,
1053                },
1054            ],
1055        );
1056        option_parameters.insert(
1057            "plot".to_string(),
1058            vec![ArgumentDefinition {
1059                name: "file".to_string(),
1060                arg_type: ArgumentType::Path,
1061                required: true,
1062                description: "Destination image file".to_string(),
1063                validation: vec![],
1064                secure: false,
1065            }],
1066        );
1067
1068        CommandDefinition {
1069            name: "export".to_string(),
1070            aliases: vec![],
1071            description: "Export simulation results".to_string(),
1072            required: false,
1073            arguments: vec![],
1074            options: vec![OptionDefinition {
1075                name: "output".to_string(),
1076                short: None,
1077                long: Some("output".to_string()),
1078                option_type: ArgumentType::String,
1079                required: false,
1080                default: None,
1081                description: "Write results in one or more output kinds".to_string(),
1082                choices: vec!["csv".to_string(), "plot".to_string()],
1083                repeatable: true,
1084                option_parameters,
1085            }],
1086            implementation: "export_handler".to_string(),
1087        }
1088    }
1089
1090    #[test]
1091    fn test_parse_repeatable_option_single_occurrence() {
1092        let definition = create_repeatable_test_definition();
1093        let parser = CliParser::new(&definition);
1094
1095        let args = vec![
1096            "--output".to_string(),
1097            "csv".to_string(),
1098            "file=results.csv".to_string(),
1099        ];
1100        let result = parser.parse_typed(&args).unwrap();
1101
1102        match result.get("output") {
1103            Some(ParsedValue::Repeated(occurrences)) => {
1104                assert_eq!(occurrences.len(), 1);
1105                assert_eq!(occurrences[0].discriminant, "csv");
1106                assert_eq!(
1107                    occurrences[0].params.get("file"),
1108                    Some(&"results.csv".to_string())
1109                );
1110            }
1111            other => panic!("Expected Repeated([csv]), got {:?}", other),
1112        }
1113    }
1114
1115    #[test]
1116    fn test_parse_repeatable_option_optional_param_can_be_omitted() {
1117        let definition = create_repeatable_test_definition();
1118        let parser = CliParser::new(&definition);
1119
1120        let args = vec![
1121            "--output".to_string(),
1122            "csv".to_string(),
1123            "file=results.csv".to_string(),
1124        ];
1125        let result = parser.parse_typed(&args).unwrap();
1126
1127        match result.get("output") {
1128            Some(ParsedValue::Repeated(occurrences)) => {
1129                assert_eq!(occurrences[0].params.get("resolution"), None);
1130            }
1131            other => panic!("Expected Repeated([csv]), got {:?}", other),
1132        }
1133    }
1134
1135    #[test]
1136    fn test_parse_repeatable_option_with_optional_param_provided() {
1137        let definition = create_repeatable_test_definition();
1138        let parser = CliParser::new(&definition);
1139
1140        let args = vec![
1141            "--output".to_string(),
1142            "csv".to_string(),
1143            "file=results.csv".to_string(),
1144            "resolution=100".to_string(),
1145        ];
1146        let result = parser.parse_typed(&args).unwrap();
1147
1148        match result.get("output") {
1149            Some(ParsedValue::Repeated(occurrences)) => {
1150                assert_eq!(
1151                    occurrences[0].params.get("resolution"),
1152                    Some(&"100".to_string())
1153                );
1154            }
1155            other => panic!("Expected Repeated([csv]), got {:?}", other),
1156        }
1157    }
1158
1159    #[test]
1160    fn test_parse_repeatable_option_multiple_discriminants_both_parse() {
1161        let definition = create_repeatable_test_definition();
1162        let parser = CliParser::new(&definition);
1163
1164        let args = vec![
1165            "--output".to_string(),
1166            "csv".to_string(),
1167            "file=results.csv".to_string(),
1168            "--output".to_string(),
1169            "plot".to_string(),
1170            "file=chart.png".to_string(),
1171        ];
1172        let result = parser.parse_typed(&args).unwrap();
1173
1174        match result.get("output") {
1175            Some(ParsedValue::Repeated(occurrences)) => {
1176                assert_eq!(occurrences.len(), 2);
1177                assert_eq!(occurrences[0].discriminant, "csv");
1178                assert_eq!(occurrences[1].discriminant, "plot");
1179            }
1180            other => panic!("Expected Repeated([csv, plot]), got {:?}", other),
1181        }
1182    }
1183
1184    #[test]
1185    fn test_parse_repeatable_option_same_discriminant_different_params_both_kept() {
1186        let definition = create_repeatable_test_definition();
1187        let parser = CliParser::new(&definition);
1188
1189        let args = vec![
1190            "--output".to_string(),
1191            "csv".to_string(),
1192            "file=a.csv".to_string(),
1193            "--output".to_string(),
1194            "csv".to_string(),
1195            "file=b.csv".to_string(),
1196            "resolution=50".to_string(),
1197        ];
1198        let result = parser.parse_typed(&args).unwrap();
1199
1200        match result.get("output") {
1201            Some(ParsedValue::Repeated(occurrences)) => {
1202                assert_eq!(occurrences.len(), 2);
1203            }
1204            other => panic!("Expected Repeated([csv, csv]), got {:?}", other),
1205        }
1206    }
1207
1208    #[test]
1209    fn test_parse_repeatable_option_duplicate_occurrence_errors() {
1210        let definition = create_repeatable_test_definition();
1211        let parser = CliParser::new(&definition);
1212
1213        let args = vec![
1214            "--output".to_string(),
1215            "csv".to_string(),
1216            "file=a.csv".to_string(),
1217            "--output".to_string(),
1218            "csv".to_string(),
1219            "file=a.csv".to_string(),
1220        ];
1221        let result = parser.parse_typed(&args);
1222
1223        assert!(result.is_err());
1224        match result.unwrap_err() {
1225            crate::error::DynamicCliError::Parse(ParseError::DuplicateOptionOccurrence {
1226                ..
1227            }) => {}
1228            other => panic!("Expected DuplicateOptionOccurrence error, got {:?}", other),
1229        }
1230    }
1231
1232    #[test]
1233    fn test_parse_repeatable_option_missing_required_param_errors() {
1234        let definition = create_repeatable_test_definition();
1235        let parser = CliParser::new(&definition);
1236
1237        // "file" is required for the csv discriminant and is not supplied.
1238        let args = vec!["--output".to_string(), "csv".to_string()];
1239        let result = parser.parse_typed(&args);
1240
1241        assert!(result.is_err());
1242        match result.unwrap_err() {
1243            crate::error::DynamicCliError::Parse(ParseError::MissingRequiredOptionParameter {
1244                key,
1245                ..
1246            }) => {
1247                assert_eq!(key, "file");
1248            }
1249            other => panic!(
1250                "Expected MissingRequiredOptionParameter error, got {:?}",
1251                other
1252            ),
1253        }
1254    }
1255
1256    #[test]
1257    fn test_parse_repeatable_option_unknown_param_key_errors() {
1258        let definition = create_repeatable_test_definition();
1259        let parser = CliParser::new(&definition);
1260
1261        let args = vec![
1262            "--output".to_string(),
1263            "csv".to_string(),
1264            "file=a.csv".to_string(),
1265            "compression=gzip".to_string(),
1266        ];
1267        let result = parser.parse_typed(&args);
1268
1269        assert!(result.is_err());
1270        match result.unwrap_err() {
1271            crate::error::DynamicCliError::Parse(ParseError::UnknownOptionParameter {
1272                key,
1273                ..
1274            }) => {
1275                assert_eq!(key, "compression");
1276            }
1277            other => panic!("Expected UnknownOptionParameter error, got {:?}", other),
1278        }
1279    }
1280
1281    #[test]
1282    fn test_parse_repeatable_option_unknown_discriminant_errors() {
1283        let definition = create_repeatable_test_definition();
1284        let parser = CliParser::new(&definition);
1285
1286        let args = vec![
1287            "--output".to_string(),
1288            "xml".to_string(),
1289            "file=a.xml".to_string(),
1290        ];
1291        let result = parser.parse_typed(&args);
1292
1293        assert!(result.is_err());
1294        match result.unwrap_err() {
1295            crate::error::DynamicCliError::Parse(ParseError::UnknownDiscriminant {
1296                value, ..
1297            }) => {
1298                assert_eq!(value, "xml");
1299            }
1300            other => panic!("Expected UnknownDiscriminant error, got {:?}", other),
1301        }
1302    }
1303
1304    #[test]
1305    fn test_parse_legacy_drops_repeated_values() {
1306        // parse() (Option A design: non-breaking wrapper) must keep
1307        // working for definitions with no repeatable options — and
1308        // silently drop Repeated entries rather than erroring, since no
1309        // pre-DD-024 caller can represent them anyway.
1310        let definition = create_repeatable_test_definition();
1311        let parser = CliParser::new(&definition);
1312
1313        let args = vec![
1314            "--output".to_string(),
1315            "csv".to_string(),
1316            "file=a.csv".to_string(),
1317        ];
1318        let result = parser.parse(&args).unwrap();
1319
1320        assert_eq!(result.get("output"), None);
1321    }
1322}