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//!         }
30//!     ],
31//!     options: vec![],
32//!     implementation: "handler".to_string(),
33//! };
34//!
35//! let parser = CliParser::new(&definition);
36//! let args = vec!["file.txt".to_string()];
37//! let parsed = parser.parse(&args).unwrap();
38//!
39//! assert_eq!(parsed.get("input"), Some(&"file.txt".to_string()));
40//! ```
41
42#[allow(unused_imports)]
43use crate::config::schema::{ArgumentDefinition, CommandDefinition, OptionDefinition};
44use crate::error::{ParseError, Result};
45use crate::parser::type_parser;
46use std::collections::HashMap;
47
48/// CLI argument parser
49///
50/// Parses command-line arguments according to a [`CommandDefinition`].
51/// The parser handles both positional arguments and named options
52/// with type conversion and validation.
53///
54/// # Lifetime
55///
56/// The parser holds a reference to a [`CommandDefinition`] and therefore
57/// has a lifetime parameter `'a` that must outlive the parser.
58///
59/// # Example
60///
61/// ```
62/// use dynamic_cli::parser::cli_parser::CliParser;
63/// use dynamic_cli::config::schema::{
64///     CommandDefinition, OptionDefinition, ArgumentType
65/// };
66///
67/// let definition = CommandDefinition {
68///     name: "test".to_string(),
69///     aliases: vec![],
70///     description: "Test command".to_string(),
71///     required: false,
72///     arguments: vec![],
73///     options: vec![
74///         OptionDefinition {
75///             name: "verbose".to_string(),
76///             short: Some("v".to_string()),
77///             long: Some("verbose".to_string()),
78///             option_type: ArgumentType::Bool,
79///             required: false,
80///             default: Some("false".to_string()),
81///             description: "Verbose output".to_string(),
82///             choices: vec![],
83///         }
84///     ],
85///     implementation: "handler".to_string(),
86/// };
87///
88/// let parser = CliParser::new(&definition);
89/// let args = vec!["-v".to_string()];
90/// let parsed = parser.parse(&args).unwrap();
91///
92/// assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
93/// ```
94pub struct CliParser<'a> {
95    /// The command definition that specifies expected arguments and options
96    definition: &'a CommandDefinition,
97}
98
99impl<'a> CliParser<'a> {
100    /// Create a new CLI parser for the given command definition
101    ///
102    /// # Arguments
103    ///
104    /// * `definition` - The command definition specifying expected arguments
105    ///
106    /// # Example
107    ///
108    /// ```
109    /// use dynamic_cli::parser::cli_parser::CliParser;
110    /// use dynamic_cli::config::schema::CommandDefinition;
111    ///
112    /// # let definition = CommandDefinition {
113    /// #     name: "test".to_string(),
114    /// #     aliases: vec![],
115    /// #     description: "".to_string(),
116    /// #     required: false,
117    /// #     arguments: vec![],
118    /// #     options: vec![],
119    /// #     implementation: "".to_string(),
120    /// # };
121    /// let parser = CliParser::new(&definition);
122    /// ```
123    pub fn new(definition: &'a CommandDefinition) -> Self {
124        Self { definition }
125    }
126
127    /// Parse command-line arguments into a HashMap
128    ///
129    /// Parses the provided arguments according to the command definition.
130    /// Positional arguments are matched in order, and options are matched
131    /// by their short or long forms.
132    ///
133    /// # Arguments
134    ///
135    /// * `args` - Slice of argument strings (excluding the command name)
136    ///
137    /// # Returns
138    ///
139    /// A HashMap mapping argument/option names to their string values.
140    /// All values are stored as strings after type validation.
141    ///
142    /// # Errors
143    ///
144    /// - [`ParseError::MissingArgument`] if required arguments are missing
145    /// - [`ParseError::MissingOption`] if required options are missing
146    /// - [`ParseError::UnknownOption`] if an unrecognized option is provided
147    /// - [`ParseError::TypeParseError`] if a value cannot be converted to its expected type
148    /// - [`ParseError::TooManyArguments`] if more positional arguments than expected
149    ///
150    /// # Example
151    ///
152    /// ```
153    /// use dynamic_cli::parser::cli_parser::CliParser;
154    /// use dynamic_cli::config::schema::{
155    ///     CommandDefinition, ArgumentDefinition, ArgumentType
156    /// };
157    ///
158    /// let definition = CommandDefinition {
159    ///     name: "greet".to_string(),
160    ///     aliases: vec![],
161    ///     description: "Greet someone".to_string(),
162    ///     required: false,
163    ///     arguments: vec![
164    ///         ArgumentDefinition {
165    ///             name: "name".to_string(),
166    ///             arg_type: ArgumentType::String,
167    ///             required: true,
168    ///             description: "Name".to_string(),
169    ///             validation: vec![],
170    ///         }
171    ///     ],
172    ///     options: vec![],
173    ///     implementation: "handler".to_string(),
174    /// };
175    ///
176    /// let parser = CliParser::new(&definition);
177    /// let result = parser.parse(&["Alice".to_string()]).unwrap();
178    /// assert_eq!(result.get("name"), Some(&"Alice".to_string()));
179    /// ```
180    pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
181        let mut result = HashMap::new();
182        let mut positional_index = 0;
183        let mut i = 0;
184
185        // Parse arguments
186        while i < args.len() {
187            let arg = &args[i];
188
189            if arg.starts_with("--") {
190                // Long option
191                self.parse_long_option(arg, args, &mut i, &mut result)?;
192            } else if arg.starts_with('-') && arg.len() > 1 {
193                // Short option (ensure it's not just a negative number)
194                if arg
195                    .chars()
196                    .nth(1)
197                    .map(|c| c.is_ascii_digit())
198                    .unwrap_or(false)
199                {
200                    // This is a negative number, treat as positional
201                    self.parse_positional_argument(arg, positional_index, &mut result)?;
202                    positional_index += 1;
203                } else {
204                    self.parse_short_option(arg, args, &mut i, &mut result)?;
205                }
206            } else {
207                // Positional argument
208                self.parse_positional_argument(arg, positional_index, &mut result)?;
209                positional_index += 1;
210            }
211
212            i += 1;
213        }
214
215        // Apply defaults for missing optional options
216        self.apply_defaults(&mut result)?;
217
218        // Validate all required arguments are present
219        self.validate_required_arguments(&result)?;
220        self.validate_required_options(&result)?;
221
222        Ok(result)
223    }
224
225    /// Parse a long option (--option or --option=value)
226    fn parse_long_option(
227        &self,
228        arg: &str,
229        args: &[String],
230        index: &mut usize,
231        result: &mut HashMap<String, String>,
232    ) -> Result<()> {
233        let arg_without_dashes = &arg[2..];
234
235        // Check for --option=value format
236        if let Some(eq_pos) = arg_without_dashes.find('=') {
237            let option_name = &arg_without_dashes[..eq_pos];
238            let value = &arg_without_dashes[eq_pos + 1..];
239
240            let option = self.find_option_by_long(option_name)?;
241            let parsed_value = type_parser::parse_value(value, option.option_type)?;
242            result.insert(option.name.clone(), parsed_value);
243        } else {
244            // --option format (value might be next arg)
245            let option = self.find_option_by_long(arg_without_dashes)?;
246
247            // For boolean options, presence means true
248            if matches!(
249                option.option_type,
250                crate::config::schema::ArgumentType::Bool
251            ) {
252                result.insert(option.name.clone(), "true".to_string());
253            } else {
254                // Non-boolean: expect value in next argument
255                *index += 1;
256                if *index >= args.len() {
257                    return Err(ParseError::InvalidSyntax {
258                        details: format!(
259                            "Option --{} requires a value",
260                            option.long.as_ref().unwrap()
261                        ),
262                        hint: Some(format!(
263                            "Usage: --{}=<value> or --{} <value>",
264                            option.long.as_ref().unwrap(),
265                            option.long.as_ref().unwrap()
266                        )),
267                    }
268                    .into());
269                }
270
271                let value = &args[*index];
272                let parsed_value = type_parser::parse_value(value, option.option_type)?;
273                result.insert(option.name.clone(), parsed_value);
274            }
275        }
276
277        Ok(())
278    }
279
280    /// Parse a short option (-o or -o value)
281    fn parse_short_option(
282        &self,
283        arg: &str,
284        args: &[String],
285        index: &mut usize,
286        result: &mut HashMap<String, String>,
287    ) -> Result<()> {
288        let short_flag = &arg[1..2];
289        let option = self.find_option_by_short(short_flag)?;
290
291        // For boolean options, presence means true
292        if matches!(
293            option.option_type,
294            crate::config::schema::ArgumentType::Bool
295        ) {
296            result.insert(option.name.clone(), "true".to_string());
297        } else {
298            // Check if value is attached (e.g., -ovalue)
299            if arg.len() > 2 {
300                let value = &arg[2..];
301                let parsed_value = type_parser::parse_value(value, option.option_type)?;
302                result.insert(option.name.clone(), parsed_value);
303            } else {
304                // Value is next argument
305                *index += 1;
306                if *index >= args.len() {
307                    return Err(ParseError::InvalidSyntax {
308                        details: format!("Option -{} requires a value", short_flag),
309                        hint: Some(format!(
310                            "Usage: -{}<value> or -{} <value>",
311                            short_flag, short_flag
312                        )),
313                    }
314                    .into());
315                }
316
317                let value = &args[*index];
318                let parsed_value = type_parser::parse_value(value, option.option_type)?;
319                result.insert(option.name.clone(), parsed_value);
320            }
321        }
322
323        Ok(())
324    }
325
326    /// Parse a positional argument
327    fn parse_positional_argument(
328        &self,
329        value: &str,
330        index: usize,
331        result: &mut HashMap<String, String>,
332    ) -> Result<()> {
333        if index >= self.definition.arguments.len() {
334            return Err(ParseError::TooManyArguments {
335                command: self.definition.name.clone(),
336                expected: self.definition.arguments.len(),
337                got: index + 1,
338            }
339            .into());
340        }
341
342        let arg_def = &self.definition.arguments[index];
343        let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
344        result.insert(arg_def.name.clone(), parsed_value);
345
346        Ok(())
347    }
348
349    /// Apply default values for options not provided
350    fn apply_defaults(&self, result: &mut HashMap<String, String>) -> Result<()> {
351        for option in &self.definition.options {
352            if !result.contains_key(&option.name) {
353                if let Some(ref default) = option.default {
354                    // Validate the default value
355                    let parsed_default = type_parser::parse_value(default, option.option_type)?;
356                    result.insert(option.name.clone(), parsed_default);
357                }
358            }
359        }
360        Ok(())
361    }
362
363    /// Validate that all required arguments are present
364    fn validate_required_arguments(&self, result: &HashMap<String, String>) -> Result<()> {
365        for arg in &self.definition.arguments {
366            if arg.required && !result.contains_key(&arg.name) {
367                return Err(ParseError::MissingArgument {
368                    argument: arg.name.clone(),
369                    command: self.definition.name.clone(),
370                }
371                .into());
372            }
373        }
374        Ok(())
375    }
376
377    /// Validate that all required options are present
378    fn validate_required_options(&self, result: &HashMap<String, String>) -> Result<()> {
379        for option in &self.definition.options {
380            if option.required && !result.contains_key(&option.name) {
381                return Err(ParseError::MissingOption {
382                    option: option
383                        .long
384                        .clone()
385                        .or(option.short.clone())
386                        .unwrap_or_default(),
387                    command: self.definition.name.clone(),
388                }
389                .into());
390            }
391        }
392        Ok(())
393    }
394
395    /// Find an option by its long form
396    fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
397        self.definition
398            .options
399            .iter()
400            .find(|opt| opt.long.as_deref() == Some(long))
401            .ok_or_else(|| {
402                let available: Vec<String> = self
403                    .definition
404                    .options
405                    .iter()
406                    .filter_map(|o| o.long.clone())
407                    .collect();
408                ParseError::unknown_option_with_suggestions(
409                    &format!("--{}", long),
410                    &self.definition.name,
411                    &available,
412                )
413                .into()
414            })
415    }
416
417    /// Find an option by its short form
418    fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
419        self.definition
420            .options
421            .iter()
422            .find(|opt| opt.short.as_deref() == Some(short))
423            .ok_or_else(|| {
424                let available: Vec<String> = self
425                    .definition
426                    .options
427                    .iter()
428                    .filter_map(|o| o.short.clone())
429                    .collect();
430                ParseError::unknown_option_with_suggestions(
431                    &format!("-{}", short),
432                    &self.definition.name,
433                    &available,
434                )
435                .into()
436            })
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::config::schema::{ArgumentType, OptionDefinition};
444
445    /// Helper to create a test command definition
446    fn create_test_definition() -> CommandDefinition {
447        CommandDefinition {
448            name: "test".to_string(),
449            aliases: vec![],
450            description: "Test command".to_string(),
451            required: false,
452            arguments: vec![
453                ArgumentDefinition {
454                    name: "input".to_string(),
455                    arg_type: ArgumentType::Path,
456                    required: true,
457                    description: "Input file".to_string(),
458                    validation: vec![],
459                },
460                ArgumentDefinition {
461                    name: "output".to_string(),
462                    arg_type: ArgumentType::Path,
463                    required: false,
464                    description: "Output file".to_string(),
465                    validation: vec![],
466                },
467            ],
468            options: vec![
469                OptionDefinition {
470                    name: "verbose".to_string(),
471                    short: Some("v".to_string()),
472                    long: Some("verbose".to_string()),
473                    option_type: ArgumentType::Bool,
474                    required: false,
475                    default: Some("false".to_string()),
476                    description: "Verbose output".to_string(),
477                    choices: vec![],
478                },
479                OptionDefinition {
480                    name: "count".to_string(),
481                    short: Some("c".to_string()),
482                    long: Some("count".to_string()),
483                    option_type: ArgumentType::Integer,
484                    required: false,
485                    default: Some("10".to_string()),
486                    description: "Count".to_string(),
487                    choices: vec![],
488                },
489            ],
490            implementation: "handler".to_string(),
491        }
492    }
493
494    // ========================================================================
495    // Positional arguments tests
496    // ========================================================================
497
498    #[test]
499    fn test_parse_single_positional_argument() {
500        let definition = create_test_definition();
501        let parser = CliParser::new(&definition);
502
503        let args = vec!["input.txt".to_string()];
504        let result = parser.parse(&args).unwrap();
505
506        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
507    }
508
509    #[test]
510    fn test_parse_multiple_positional_arguments() {
511        let definition = create_test_definition();
512        let parser = CliParser::new(&definition);
513
514        let args = vec!["input.txt".to_string(), "output.txt".to_string()];
515        let result = parser.parse(&args).unwrap();
516
517        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
518        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
519    }
520
521    #[test]
522    fn test_parse_missing_required_argument() {
523        let definition = create_test_definition();
524        let parser = CliParser::new(&definition);
525
526        let args: Vec<String> = vec![];
527        let result = parser.parse(&args);
528
529        assert!(result.is_err());
530        match result.unwrap_err() {
531            crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
532                argument, ..
533            }) => {
534                assert_eq!(argument, "input");
535            }
536            other => panic!("Expected MissingArgument error, got {:?}", other),
537        }
538    }
539
540    #[test]
541    fn test_parse_too_many_positional_arguments() {
542        let definition = create_test_definition();
543        let parser = CliParser::new(&definition);
544
545        let args = vec![
546            "input.txt".to_string(),
547            "output.txt".to_string(),
548            "extra.txt".to_string(),
549        ];
550        let result = parser.parse(&args);
551
552        assert!(result.is_err());
553        match result.unwrap_err() {
554            crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
555            other => panic!("Expected TooManyArguments error, got {:?}", other),
556        }
557    }
558
559    // ========================================================================
560    // Long options tests
561    // ========================================================================
562
563    #[test]
564    fn test_parse_long_boolean_option() {
565        let definition = create_test_definition();
566        let parser = CliParser::new(&definition);
567
568        let args = vec!["input.txt".to_string(), "--verbose".to_string()];
569        let result = parser.parse(&args).unwrap();
570
571        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
572    }
573
574    #[test]
575    fn test_parse_long_option_with_equals() {
576        let definition = create_test_definition();
577        let parser = CliParser::new(&definition);
578
579        let args = vec!["input.txt".to_string(), "--count=42".to_string()];
580        let result = parser.parse(&args).unwrap();
581
582        assert_eq!(result.get("count"), Some(&"42".to_string()));
583    }
584
585    #[test]
586    fn test_parse_long_option_with_space() {
587        let definition = create_test_definition();
588        let parser = CliParser::new(&definition);
589
590        let args = vec![
591            "input.txt".to_string(),
592            "--count".to_string(),
593            "42".to_string(),
594        ];
595        let result = parser.parse(&args).unwrap();
596
597        assert_eq!(result.get("count"), Some(&"42".to_string()));
598    }
599
600    #[test]
601    fn test_parse_unknown_long_option() {
602        let definition = create_test_definition();
603        let parser = CliParser::new(&definition);
604
605        let args = vec!["input.txt".to_string(), "--unknown".to_string()];
606        let result = parser.parse(&args);
607
608        assert!(result.is_err());
609        match result.unwrap_err() {
610            crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
611            other => panic!("Expected UnknownOption error, got {:?}", other),
612        }
613    }
614
615    // ========================================================================
616    // Short options tests
617    // ========================================================================
618
619    #[test]
620    fn test_parse_short_boolean_option() {
621        let definition = create_test_definition();
622        let parser = CliParser::new(&definition);
623
624        let args = vec!["input.txt".to_string(), "-v".to_string()];
625        let result = parser.parse(&args).unwrap();
626
627        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
628    }
629
630    #[test]
631    fn test_parse_short_option_with_space() {
632        let definition = create_test_definition();
633        let parser = CliParser::new(&definition);
634
635        let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
636        let result = parser.parse(&args).unwrap();
637
638        assert_eq!(result.get("count"), Some(&"42".to_string()));
639    }
640
641    #[test]
642    fn test_parse_short_option_attached_value() {
643        let definition = create_test_definition();
644        let parser = CliParser::new(&definition);
645
646        let args = vec!["input.txt".to_string(), "-c42".to_string()];
647        let result = parser.parse(&args).unwrap();
648
649        assert_eq!(result.get("count"), Some(&"42".to_string()));
650    }
651
652    #[test]
653    fn test_parse_negative_number_as_positional() {
654        let definition = create_test_definition();
655        let parser = CliParser::new(&definition);
656
657        // -123 should be treated as a positional argument, not an option
658        let args = vec!["-123".to_string()];
659        let result = parser.parse(&args).unwrap();
660
661        assert_eq!(result.get("input"), Some(&"-123".to_string()));
662    }
663
664    // ========================================================================
665    // Default values tests
666    // ========================================================================
667
668    #[test]
669    fn test_apply_default_values() {
670        let definition = create_test_definition();
671        let parser = CliParser::new(&definition);
672
673        let args = vec!["input.txt".to_string()];
674        let result = parser.parse(&args).unwrap();
675
676        // Default values should be applied
677        assert_eq!(result.get("verbose"), Some(&"false".to_string()));
678        assert_eq!(result.get("count"), Some(&"10".to_string()));
679    }
680
681    #[test]
682    fn test_override_default_values() {
683        let definition = create_test_definition();
684        let parser = CliParser::new(&definition);
685
686        let args = vec![
687            "input.txt".to_string(),
688            "-v".to_string(),
689            "-c".to_string(),
690            "5".to_string(),
691        ];
692        let result = parser.parse(&args).unwrap();
693
694        // Provided values should override defaults
695        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
696        assert_eq!(result.get("count"), Some(&"5".to_string()));
697    }
698
699    // ========================================================================
700    // Type conversion tests
701    // ========================================================================
702
703    #[test]
704    fn test_type_conversion_error() {
705        let definition = create_test_definition();
706        let parser = CliParser::new(&definition);
707
708        // "abc" cannot be parsed as integer
709        let args = vec![
710            "input.txt".to_string(),
711            "--count".to_string(),
712            "abc".to_string(),
713        ];
714        let result = parser.parse(&args);
715
716        assert!(result.is_err());
717    }
718
719    // ========================================================================
720    // Integration tests
721    // ========================================================================
722
723    #[test]
724    fn test_parse_complex_command_line() {
725        let definition = create_test_definition();
726        let parser = CliParser::new(&definition);
727
728        let args = vec![
729            "input.txt".to_string(),
730            "output.txt".to_string(),
731            "--verbose".to_string(),
732            "--count=100".to_string(),
733        ];
734        let result = parser.parse(&args).unwrap();
735
736        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
737        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
738        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
739        assert_eq!(result.get("count"), Some(&"100".to_string()));
740    }
741
742    #[test]
743    fn test_parse_mixed_options_and_arguments() {
744        let definition = create_test_definition();
745        let parser = CliParser::new(&definition);
746
747        // Options can be interspersed with positional arguments
748        let args = vec![
749            "--verbose".to_string(),
750            "input.txt".to_string(),
751            "-c".to_string(),
752            "50".to_string(),
753            "output.txt".to_string(),
754        ];
755        let result = parser.parse(&args).unwrap();
756
757        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
758        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
759        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
760        assert_eq!(result.get("count"), Some(&"50".to_string()));
761    }
762}