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///
68/// let definition = CommandDefinition {
69///     name: "test".to_string(),
70///     aliases: vec![],
71///     description: "Test command".to_string(),
72///     required: false,
73///     arguments: vec![],
74///     options: vec![
75///         OptionDefinition {
76///             name: "verbose".to_string(),
77///             short: Some("v".to_string()),
78///             long: Some("verbose".to_string()),
79///             option_type: ArgumentType::Bool,
80///             required: false,
81///             default: Some("false".to_string()),
82///             description: "Verbose output".to_string(),
83///             choices: vec![],
84///         }
85///     ],
86///     implementation: "handler".to_string(),
87/// };
88///
89/// let parser = CliParser::new(&definition);
90/// let args = vec!["-v".to_string()];
91/// let parsed = parser.parse(&args).unwrap();
92///
93/// assert_eq!(parsed.get("verbose"), Some(&"true".to_string()));
94/// ```
95pub struct CliParser<'a> {
96    /// The command definition that specifies expected arguments and options
97    definition: &'a CommandDefinition,
98}
99
100impl<'a> CliParser<'a> {
101    /// Create a new CLI parser for the given command definition
102    ///
103    /// # Arguments
104    ///
105    /// * `definition` - The command definition specifying expected arguments
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use dynamic_cli::parser::cli_parser::CliParser;
111    /// use dynamic_cli::config::schema::CommandDefinition;
112    ///
113    /// # let definition = CommandDefinition {
114    /// #     name: "test".to_string(),
115    /// #     aliases: vec![],
116    /// #     description: "".to_string(),
117    /// #     required: false,
118    /// #     arguments: vec![],
119    /// #     options: vec![],
120    /// #     implementation: "".to_string(),
121    /// # };
122    /// let parser = CliParser::new(&definition);
123    /// ```
124    pub fn new(definition: &'a CommandDefinition) -> Self {
125        Self { definition }
126    }
127
128    /// Parse command-line arguments into a HashMap
129    ///
130    /// Parses the provided arguments according to the command definition.
131    /// Positional arguments are matched in order, and options are matched
132    /// by their short or long forms.
133    ///
134    /// # Arguments
135    ///
136    /// * `args` - Slice of argument strings (excluding the command name)
137    ///
138    /// # Returns
139    ///
140    /// A HashMap mapping argument/option names to their string values.
141    /// All values are stored as strings after type validation.
142    ///
143    /// # Errors
144    ///
145    /// - [`ParseError::MissingArgument`] if required arguments are missing
146    /// - [`ParseError::MissingOption`] if required options are missing
147    /// - [`ParseError::UnknownOption`] if an unrecognized option is provided
148    /// - [`ParseError::TypeParseError`] if a value cannot be converted to its expected type
149    /// - [`ParseError::TooManyArguments`] if more positional arguments than expected
150    ///
151    /// # Example
152    ///
153    /// ```
154    /// use dynamic_cli::parser::cli_parser::CliParser;
155    /// use dynamic_cli::config::schema::{
156    ///     CommandDefinition, ArgumentDefinition, ArgumentType
157    /// };
158    ///
159    /// let definition = CommandDefinition {
160    ///     name: "greet".to_string(),
161    ///     aliases: vec![],
162    ///     description: "Greet someone".to_string(),
163    ///     required: false,
164    ///     arguments: vec![
165    ///         ArgumentDefinition {
166    ///             name: "name".to_string(),
167    ///             arg_type: ArgumentType::String,
168    ///             required: true,
169    ///             description: "Name".to_string(),
170    ///             validation: vec![],
171    ///             secure: false,
172    ///         }
173    ///     ],
174    ///     options: vec![],
175    ///     implementation: "handler".to_string(),
176    /// };
177    ///
178    /// let parser = CliParser::new(&definition);
179    /// let result = parser.parse(&["Alice".to_string()]).unwrap();
180    /// assert_eq!(result.get("name"), Some(&"Alice".to_string()));
181    /// ```
182    pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
183        let mut result = HashMap::new();
184        let mut positional_index = 0;
185        let mut i = 0;
186
187        // Parse arguments
188        while i < args.len() {
189            let arg = &args[i];
190
191            if arg.starts_with("--") {
192                // Long option
193                self.parse_long_option(arg, args, &mut i, &mut result)?;
194            } else if arg.starts_with('-') && arg.len() > 1 {
195                // Short option (ensure it's not just a negative number)
196                if arg
197                    .chars()
198                    .nth(1)
199                    .map(|c| c.is_ascii_digit())
200                    .unwrap_or(false)
201                {
202                    // This is a negative number, treat as positional
203                    self.parse_positional_argument(arg, positional_index, &mut result)?;
204                    positional_index += 1;
205                } else {
206                    self.parse_short_option(arg, args, &mut i, &mut result)?;
207                }
208            } else {
209                // Positional argument
210                self.parse_positional_argument(arg, positional_index, &mut result)?;
211                positional_index += 1;
212            }
213
214            i += 1;
215        }
216
217        // Apply defaults for missing optional options
218        self.apply_defaults(&mut result)?;
219
220        // Validate all required arguments are present
221        self.validate_required_arguments(&result)?;
222        self.validate_required_options(&result)?;
223
224        Ok(result)
225    }
226
227    /// Parse a long option (--option or --option=value)
228    fn parse_long_option(
229        &self,
230        arg: &str,
231        args: &[String],
232        index: &mut usize,
233        result: &mut HashMap<String, String>,
234    ) -> Result<()> {
235        let arg_without_dashes = &arg[2..];
236
237        // Check for --option=value format
238        if let Some(eq_pos) = arg_without_dashes.find('=') {
239            let option_name = &arg_without_dashes[..eq_pos];
240            let value = &arg_without_dashes[eq_pos + 1..];
241
242            let option = self.find_option_by_long(option_name)?;
243            let parsed_value = type_parser::parse_value(value, option.option_type)?;
244            result.insert(option.name.clone(), parsed_value);
245        } else {
246            // --option format (value might be next arg)
247            let option = self.find_option_by_long(arg_without_dashes)?;
248
249            // For boolean options, presence means true
250            if matches!(
251                option.option_type,
252                crate::config::schema::ArgumentType::Bool
253            ) {
254                result.insert(option.name.clone(), "true".to_string());
255            } else {
256                // Non-boolean: expect value in next argument
257                *index += 1;
258                if *index >= args.len() {
259                    return Err(ParseError::InvalidSyntax {
260                        details: format!(
261                            "Option --{} requires a value",
262                            option.long.as_ref().unwrap()
263                        ),
264                        hint: Some(format!(
265                            "Usage: --{}=<value> or --{} <value>",
266                            option.long.as_ref().unwrap(),
267                            option.long.as_ref().unwrap()
268                        )),
269                    }
270                    .into());
271                }
272
273                let value = &args[*index];
274                let parsed_value = type_parser::parse_value(value, option.option_type)?;
275                result.insert(option.name.clone(), parsed_value);
276            }
277        }
278
279        Ok(())
280    }
281
282    /// Parse a short option (-o or -o value)
283    fn parse_short_option(
284        &self,
285        arg: &str,
286        args: &[String],
287        index: &mut usize,
288        result: &mut HashMap<String, String>,
289    ) -> Result<()> {
290        let short_flag = &arg[1..2];
291        let option = self.find_option_by_short(short_flag)?;
292
293        // For boolean options, presence means true
294        if matches!(
295            option.option_type,
296            crate::config::schema::ArgumentType::Bool
297        ) {
298            result.insert(option.name.clone(), "true".to_string());
299        } else {
300            // Check if value is attached (e.g., -ovalue)
301            if arg.len() > 2 {
302                let value = &arg[2..];
303                let parsed_value = type_parser::parse_value(value, option.option_type)?;
304                result.insert(option.name.clone(), parsed_value);
305            } else {
306                // Value is next argument
307                *index += 1;
308                if *index >= args.len() {
309                    return Err(ParseError::InvalidSyntax {
310                        details: format!("Option -{} requires a value", short_flag),
311                        hint: Some(format!(
312                            "Usage: -{}<value> or -{} <value>",
313                            short_flag, short_flag
314                        )),
315                    }
316                    .into());
317                }
318
319                let value = &args[*index];
320                let parsed_value = type_parser::parse_value(value, option.option_type)?;
321                result.insert(option.name.clone(), parsed_value);
322            }
323        }
324
325        Ok(())
326    }
327
328    /// Parse a positional argument
329    fn parse_positional_argument(
330        &self,
331        value: &str,
332        index: usize,
333        result: &mut HashMap<String, String>,
334    ) -> Result<()> {
335        if index >= self.definition.arguments.len() {
336            return Err(ParseError::too_many_arguments(
337                &self.definition.name,
338                self.definition.arguments.len(),
339                index + 1,
340            )
341            .into());
342        }
343
344        let arg_def = &self.definition.arguments[index];
345        let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
346        result.insert(arg_def.name.clone(), parsed_value);
347
348        Ok(())
349    }
350
351    /// Apply default values for options not provided
352    fn apply_defaults(&self, result: &mut HashMap<String, String>) -> Result<()> {
353        for option in &self.definition.options {
354            if !result.contains_key(&option.name) {
355                if let Some(ref default) = option.default {
356                    // Validate the default value
357                    let parsed_default = type_parser::parse_value(default, option.option_type)?;
358                    result.insert(option.name.clone(), parsed_default);
359                }
360            }
361        }
362        Ok(())
363    }
364
365    /// Validate that all required arguments are present
366    fn validate_required_arguments(&self, result: &HashMap<String, String>) -> Result<()> {
367        for arg in &self.definition.arguments {
368            if arg.required && !result.contains_key(&arg.name) {
369                return Err(ParseError::missing_argument(&arg.name, &self.definition.name).into());
370            }
371        }
372        Ok(())
373    }
374
375    /// Validate that all required options are present
376    fn validate_required_options(&self, result: &HashMap<String, String>) -> Result<()> {
377        for option in &self.definition.options {
378            if option.required && !result.contains_key(&option.name) {
379                return Err(ParseError::missing_option(
380                    &option
381                        .long
382                        .clone()
383                        .or(option.short.clone())
384                        .unwrap_or_default(),
385                    &self.definition.name,
386                )
387                .into());
388            }
389        }
390        Ok(())
391    }
392
393    /// Find an option by its long form
394    fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
395        self.definition
396            .options
397            .iter()
398            .find(|opt| opt.long.as_deref() == Some(long))
399            .ok_or_else(|| {
400                let available: Vec<String> = self
401                    .definition
402                    .options
403                    .iter()
404                    .filter_map(|o| o.long.clone())
405                    .collect();
406                ParseError::unknown_option_with_suggestions(
407                    &format!("--{}", long),
408                    &self.definition.name,
409                    &available,
410                )
411                .into()
412            })
413    }
414
415    /// Find an option by its short form
416    fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
417        self.definition
418            .options
419            .iter()
420            .find(|opt| opt.short.as_deref() == Some(short))
421            .ok_or_else(|| {
422                let available: Vec<String> = self
423                    .definition
424                    .options
425                    .iter()
426                    .filter_map(|o| o.short.clone())
427                    .collect();
428                ParseError::unknown_option_with_suggestions(
429                    &format!("-{}", short),
430                    &self.definition.name,
431                    &available,
432                )
433                .into()
434            })
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::config::schema::{ArgumentType, OptionDefinition};
442
443    /// Helper to create a test command definition
444    fn create_test_definition() -> CommandDefinition {
445        CommandDefinition {
446            name: "test".to_string(),
447            aliases: vec![],
448            description: "Test command".to_string(),
449            required: false,
450            arguments: vec![
451                ArgumentDefinition {
452                    name: "input".to_string(),
453                    arg_type: ArgumentType::Path,
454                    required: true,
455                    description: "Input file".to_string(),
456                    validation: vec![],
457                    secure: false,
458                },
459                ArgumentDefinition {
460                    name: "output".to_string(),
461                    arg_type: ArgumentType::Path,
462                    required: false,
463                    description: "Output file".to_string(),
464                    validation: vec![],
465                    secure: false,
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}