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