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//!         }
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::too_many_arguments(
335                &self.definition.name,
336                self.definition.arguments.len(),
337                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::missing_argument(&arg.name, &self.definition.name).into());
368            }
369        }
370        Ok(())
371    }
372
373    /// Validate that all required options are present
374    fn validate_required_options(&self, result: &HashMap<String, String>) -> Result<()> {
375        for option in &self.definition.options {
376            if option.required && !result.contains_key(&option.name) {
377                return Err(ParseError::missing_option(
378                    &option
379                        .long
380                        .clone()
381                        .or(option.short.clone())
382                        .unwrap_or_default(),
383                    &self.definition.name,
384                )
385                .into());
386            }
387        }
388        Ok(())
389    }
390
391    /// Find an option by its long form
392    fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
393        self.definition
394            .options
395            .iter()
396            .find(|opt| opt.long.as_deref() == Some(long))
397            .ok_or_else(|| {
398                let available: Vec<String> = self
399                    .definition
400                    .options
401                    .iter()
402                    .filter_map(|o| o.long.clone())
403                    .collect();
404                ParseError::unknown_option_with_suggestions(
405                    &format!("--{}", long),
406                    &self.definition.name,
407                    &available,
408                )
409                .into()
410            })
411    }
412
413    /// Find an option by its short form
414    fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
415        self.definition
416            .options
417            .iter()
418            .find(|opt| opt.short.as_deref() == Some(short))
419            .ok_or_else(|| {
420                let available: Vec<String> = self
421                    .definition
422                    .options
423                    .iter()
424                    .filter_map(|o| o.short.clone())
425                    .collect();
426                ParseError::unknown_option_with_suggestions(
427                    &format!("-{}", short),
428                    &self.definition.name,
429                    &available,
430                )
431                .into()
432            })
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::config::schema::{ArgumentType, OptionDefinition};
440
441    /// Helper to create a test command definition
442    fn create_test_definition() -> CommandDefinition {
443        CommandDefinition {
444            name: "test".to_string(),
445            aliases: vec![],
446            description: "Test command".to_string(),
447            required: false,
448            arguments: vec![
449                ArgumentDefinition {
450                    name: "input".to_string(),
451                    arg_type: ArgumentType::Path,
452                    required: true,
453                    description: "Input file".to_string(),
454                    validation: vec![],
455                },
456                ArgumentDefinition {
457                    name: "output".to_string(),
458                    arg_type: ArgumentType::Path,
459                    required: false,
460                    description: "Output file".to_string(),
461                    validation: vec![],
462                },
463            ],
464            options: vec![
465                OptionDefinition {
466                    name: "verbose".to_string(),
467                    short: Some("v".to_string()),
468                    long: Some("verbose".to_string()),
469                    option_type: ArgumentType::Bool,
470                    required: false,
471                    default: Some("false".to_string()),
472                    description: "Verbose output".to_string(),
473                    choices: vec![],
474                },
475                OptionDefinition {
476                    name: "count".to_string(),
477                    short: Some("c".to_string()),
478                    long: Some("count".to_string()),
479                    option_type: ArgumentType::Integer,
480                    required: false,
481                    default: Some("10".to_string()),
482                    description: "Count".to_string(),
483                    choices: vec![],
484                },
485            ],
486            implementation: "handler".to_string(),
487        }
488    }
489
490    // ========================================================================
491    // Positional arguments tests
492    // ========================================================================
493
494    #[test]
495    fn test_parse_single_positional_argument() {
496        let definition = create_test_definition();
497        let parser = CliParser::new(&definition);
498
499        let args = vec!["input.txt".to_string()];
500        let result = parser.parse(&args).unwrap();
501
502        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
503    }
504
505    #[test]
506    fn test_parse_multiple_positional_arguments() {
507        let definition = create_test_definition();
508        let parser = CliParser::new(&definition);
509
510        let args = vec!["input.txt".to_string(), "output.txt".to_string()];
511        let result = parser.parse(&args).unwrap();
512
513        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
514        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
515    }
516
517    #[test]
518    fn test_parse_missing_required_argument() {
519        let definition = create_test_definition();
520        let parser = CliParser::new(&definition);
521
522        let args: Vec<String> = vec![];
523        let result = parser.parse(&args);
524
525        assert!(result.is_err());
526        match result.unwrap_err() {
527            crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
528                argument, ..
529            }) => {
530                assert_eq!(argument, "input");
531            }
532            other => panic!("Expected MissingArgument error, got {:?}", other),
533        }
534    }
535
536    #[test]
537    fn test_parse_too_many_positional_arguments() {
538        let definition = create_test_definition();
539        let parser = CliParser::new(&definition);
540
541        let args = vec![
542            "input.txt".to_string(),
543            "output.txt".to_string(),
544            "extra.txt".to_string(),
545        ];
546        let result = parser.parse(&args);
547
548        assert!(result.is_err());
549        match result.unwrap_err() {
550            crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
551            other => panic!("Expected TooManyArguments error, got {:?}", other),
552        }
553    }
554
555    // ========================================================================
556    // Long options tests
557    // ========================================================================
558
559    #[test]
560    fn test_parse_long_boolean_option() {
561        let definition = create_test_definition();
562        let parser = CliParser::new(&definition);
563
564        let args = vec!["input.txt".to_string(), "--verbose".to_string()];
565        let result = parser.parse(&args).unwrap();
566
567        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
568    }
569
570    #[test]
571    fn test_parse_long_option_with_equals() {
572        let definition = create_test_definition();
573        let parser = CliParser::new(&definition);
574
575        let args = vec!["input.txt".to_string(), "--count=42".to_string()];
576        let result = parser.parse(&args).unwrap();
577
578        assert_eq!(result.get("count"), Some(&"42".to_string()));
579    }
580
581    #[test]
582    fn test_parse_long_option_with_space() {
583        let definition = create_test_definition();
584        let parser = CliParser::new(&definition);
585
586        let args = vec![
587            "input.txt".to_string(),
588            "--count".to_string(),
589            "42".to_string(),
590        ];
591        let result = parser.parse(&args).unwrap();
592
593        assert_eq!(result.get("count"), Some(&"42".to_string()));
594    }
595
596    #[test]
597    fn test_parse_unknown_long_option() {
598        let definition = create_test_definition();
599        let parser = CliParser::new(&definition);
600
601        let args = vec!["input.txt".to_string(), "--unknown".to_string()];
602        let result = parser.parse(&args);
603
604        assert!(result.is_err());
605        match result.unwrap_err() {
606            crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
607            other => panic!("Expected UnknownOption error, got {:?}", other),
608        }
609    }
610
611    // ========================================================================
612    // Short options tests
613    // ========================================================================
614
615    #[test]
616    fn test_parse_short_boolean_option() {
617        let definition = create_test_definition();
618        let parser = CliParser::new(&definition);
619
620        let args = vec!["input.txt".to_string(), "-v".to_string()];
621        let result = parser.parse(&args).unwrap();
622
623        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
624    }
625
626    #[test]
627    fn test_parse_short_option_with_space() {
628        let definition = create_test_definition();
629        let parser = CliParser::new(&definition);
630
631        let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
632        let result = parser.parse(&args).unwrap();
633
634        assert_eq!(result.get("count"), Some(&"42".to_string()));
635    }
636
637    #[test]
638    fn test_parse_short_option_attached_value() {
639        let definition = create_test_definition();
640        let parser = CliParser::new(&definition);
641
642        let args = vec!["input.txt".to_string(), "-c42".to_string()];
643        let result = parser.parse(&args).unwrap();
644
645        assert_eq!(result.get("count"), Some(&"42".to_string()));
646    }
647
648    #[test]
649    fn test_parse_negative_number_as_positional() {
650        let definition = create_test_definition();
651        let parser = CliParser::new(&definition);
652
653        // -123 should be treated as a positional argument, not an option
654        let args = vec!["-123".to_string()];
655        let result = parser.parse(&args).unwrap();
656
657        assert_eq!(result.get("input"), Some(&"-123".to_string()));
658    }
659
660    // ========================================================================
661    // Default values tests
662    // ========================================================================
663
664    #[test]
665    fn test_apply_default_values() {
666        let definition = create_test_definition();
667        let parser = CliParser::new(&definition);
668
669        let args = vec!["input.txt".to_string()];
670        let result = parser.parse(&args).unwrap();
671
672        // Default values should be applied
673        assert_eq!(result.get("verbose"), Some(&"false".to_string()));
674        assert_eq!(result.get("count"), Some(&"10".to_string()));
675    }
676
677    #[test]
678    fn test_override_default_values() {
679        let definition = create_test_definition();
680        let parser = CliParser::new(&definition);
681
682        let args = vec![
683            "input.txt".to_string(),
684            "-v".to_string(),
685            "-c".to_string(),
686            "5".to_string(),
687        ];
688        let result = parser.parse(&args).unwrap();
689
690        // Provided values should override defaults
691        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
692        assert_eq!(result.get("count"), Some(&"5".to_string()));
693    }
694
695    // ========================================================================
696    // Type conversion tests
697    // ========================================================================
698
699    #[test]
700    fn test_type_conversion_error() {
701        let definition = create_test_definition();
702        let parser = CliParser::new(&definition);
703
704        // "abc" cannot be parsed as integer
705        let args = vec![
706            "input.txt".to_string(),
707            "--count".to_string(),
708            "abc".to_string(),
709        ];
710        let result = parser.parse(&args);
711
712        assert!(result.is_err());
713    }
714
715    // ========================================================================
716    // Integration tests
717    // ========================================================================
718
719    #[test]
720    fn test_parse_complex_command_line() {
721        let definition = create_test_definition();
722        let parser = CliParser::new(&definition);
723
724        let args = vec![
725            "input.txt".to_string(),
726            "output.txt".to_string(),
727            "--verbose".to_string(),
728            "--count=100".to_string(),
729        ];
730        let result = parser.parse(&args).unwrap();
731
732        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
733        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
734        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
735        assert_eq!(result.get("count"), Some(&"100".to_string()));
736    }
737
738    #[test]
739    fn test_parse_mixed_options_and_arguments() {
740        let definition = create_test_definition();
741        let parser = CliParser::new(&definition);
742
743        // Options can be interspersed with positional arguments
744        let args = vec![
745            "--verbose".to_string(),
746            "input.txt".to_string(),
747            "-c".to_string(),
748            "50".to_string(),
749            "output.txt".to_string(),
750        ];
751        let result = parser.parse(&args).unwrap();
752
753        assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
754        assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
755        assert_eq!(result.get("verbose"), Some(&"true".to_string()));
756        assert_eq!(result.get("count"), Some(&"50".to_string()));
757    }
758}