1#[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
49pub struct CliParser<'a> {
99 definition: &'a CommandDefinition,
101}
102
103#[derive(Debug, Clone, PartialEq)]
114pub struct OptionOccurrence {
115 pub discriminant: String,
118 pub params: HashMap<String, String>,
120}
121
122#[derive(Debug, Clone, PartialEq)]
130pub enum ParsedValue {
131 Scalar(String),
133 Repeated(Vec<OptionOccurrence>),
135}
136
137impl<'a> CliParser<'a> {
138 pub fn new(definition: &'a CommandDefinition) -> Self {
162 Self { definition }
163 }
164
165 pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
226 let typed = self.parse_typed(args)?;
227
228 Ok(typed
229 .into_iter()
230 .filter_map(|(name, value)| match value {
231 ParsedValue::Scalar(s) => Some((name, s)),
232 ParsedValue::Repeated(_) => None,
233 })
234 .collect())
235 }
236
237 pub fn parse_typed(&self, args: &[String]) -> Result<HashMap<String, ParsedValue>> {
260 let mut result = HashMap::new();
261 let mut positional_index = 0;
262 let mut i = 0;
263
264 while i < args.len() {
266 let arg = &args[i];
267
268 if arg.starts_with("--") {
269 self.parse_long_option(arg, args, &mut i, &mut result)?;
271 } else if arg.starts_with('-') && arg.len() > 1 {
272 if arg
274 .chars()
275 .nth(1)
276 .map(|c| c.is_ascii_digit())
277 .unwrap_or(false)
278 {
279 self.parse_positional_argument(arg, positional_index, &mut result)?;
281 positional_index += 1;
282 } else {
283 self.parse_short_option(arg, args, &mut i, &mut result)?;
284 }
285 } else {
286 self.parse_positional_argument(arg, positional_index, &mut result)?;
288 positional_index += 1;
289 }
290
291 i += 1;
292 }
293
294 self.apply_defaults(&mut result)?;
296
297 self.validate_required_arguments(&result)?;
299 self.validate_required_options(&result)?;
300
301 Ok(result)
302 }
303
304 fn parse_long_option(
306 &self,
307 arg: &str,
308 args: &[String],
309 index: &mut usize,
310 result: &mut HashMap<String, ParsedValue>,
311 ) -> Result<()> {
312 let arg_without_dashes = &arg[2..];
313
314 if let Some(eq_pos) = arg_without_dashes.find('=') {
316 let option_name = &arg_without_dashes[..eq_pos];
317 let value = &arg_without_dashes[eq_pos + 1..];
318
319 let option = self.find_option_by_long(option_name)?;
320 if option.repeatable {
321 return Err(ParseError::InvalidSyntax {
325 details: format!(
326 "Option --{} is repeatable and does not support --{}=<value>",
327 option.name, option.name
328 ),
329 hint: Some(format!(
330 "Usage: --{} <{}> [key=value ...]",
331 option.name,
332 option.choices.join("|")
333 )),
334 }
335 .into());
336 }
337 let parsed_value = type_parser::parse_value(value, option.option_type)?;
338 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
339 } else {
340 let option = self.find_option_by_long(arg_without_dashes)?;
342
343 if option.repeatable {
344 self.parse_repeatable_occurrence(option, args, index, result)?;
345 } else if matches!(
346 option.option_type,
347 crate::config::schema::ArgumentType::Bool
348 ) {
349 result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
350 } else {
351 *index += 1;
353 if *index >= args.len() {
354 return Err(ParseError::InvalidSyntax {
355 details: format!(
356 "Option --{} requires a value",
357 option.long.as_ref().unwrap()
358 ),
359 hint: Some(format!(
360 "Usage: --{}=<value> or --{} <value>",
361 option.long.as_ref().unwrap(),
362 option.long.as_ref().unwrap()
363 )),
364 }
365 .into());
366 }
367
368 let value = &args[*index];
369 let parsed_value = type_parser::parse_value(value, option.option_type)?;
370 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
371 }
372 }
373
374 Ok(())
375 }
376
377 fn parse_short_option(
379 &self,
380 arg: &str,
381 args: &[String],
382 index: &mut usize,
383 result: &mut HashMap<String, ParsedValue>,
384 ) -> Result<()> {
385 let short_flag = &arg[1..2];
386 let option = self.find_option_by_short(short_flag)?;
387
388 if option.repeatable {
389 if arg.len() > 2 {
390 return Err(ParseError::InvalidSyntax {
391 details: format!(
392 "Option -{} is repeatable and does not support an attached value",
393 short_flag
394 ),
395 hint: Some(format!(
396 "Usage: -{} <{}> [key=value ...]",
397 short_flag,
398 option.choices.join("|")
399 )),
400 }
401 .into());
402 }
403 self.parse_repeatable_occurrence(option, args, index, result)?;
404 } else if matches!(
405 option.option_type,
406 crate::config::schema::ArgumentType::Bool
407 ) {
408 result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
409 } else {
410 if arg.len() > 2 {
412 let value = &arg[2..];
413 let parsed_value = type_parser::parse_value(value, option.option_type)?;
414 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
415 } else {
416 *index += 1;
418 if *index >= args.len() {
419 return Err(ParseError::InvalidSyntax {
420 details: format!("Option -{} requires a value", short_flag),
421 hint: Some(format!(
422 "Usage: -{}<value> or -{} <value>",
423 short_flag, short_flag
424 )),
425 }
426 .into());
427 }
428
429 let value = &args[*index];
430 let parsed_value = type_parser::parse_value(value, option.option_type)?;
431 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
432 }
433 }
434
435 Ok(())
436 }
437
438 fn parse_positional_argument(
440 &self,
441 value: &str,
442 index: usize,
443 result: &mut HashMap<String, ParsedValue>,
444 ) -> Result<()> {
445 if index >= self.definition.arguments.len() {
446 return Err(ParseError::too_many_arguments(
447 &self.definition.name,
448 self.definition.arguments.len(),
449 index + 1,
450 )
451 .into());
452 }
453
454 let arg_def = &self.definition.arguments[index];
455 let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
456 result.insert(arg_def.name.clone(), ParsedValue::Scalar(parsed_value));
457
458 Ok(())
459 }
460
461 fn parse_repeatable_occurrence(
476 &self,
477 option: &OptionDefinition,
478 args: &[String],
479 index: &mut usize,
480 result: &mut HashMap<String, ParsedValue>,
481 ) -> Result<()> {
482 *index += 1;
484 if *index >= args.len() {
485 return Err(ParseError::InvalidSyntax {
486 details: format!("Option --{} requires a discriminant", option.name),
487 hint: Some(format!(
488 "Usage: --{} <{}> [key=value ...]",
489 option.name,
490 option.choices.join("|")
491 )),
492 }
493 .into());
494 }
495 let discriminant = args[*index].clone();
496 if !option.choices.contains(&discriminant) {
497 return Err(ParseError::UnknownDiscriminant {
498 option: option.name.clone(),
499 value: discriminant,
500 valid_choices: option.choices.clone(),
501 suggestion: Some(format!(
502 "Run --help {} to see valid --{} kinds.",
503 self.definition.name, option.name
504 )),
505 }
506 .into());
507 }
508
509 let empty: Vec<ArgumentDefinition> = Vec::new();
513 let param_defs = option
514 .option_parameters
515 .get(&discriminant)
516 .unwrap_or(&empty);
517
518 let mut params: HashMap<String, String> = HashMap::new();
520 while *index + 1 < args.len() {
521 let next = &args[*index + 1];
522 if next.starts_with('-') {
523 break;
524 }
525
526 let eq_pos = match next.find('=') {
527 Some(pos) => pos,
528 None => {
529 return Err(ParseError::InvalidSyntax {
530 details: format!(
531 "Expected key=value for --{} {}, got: '{}'",
532 option.name, discriminant, next
533 ),
534 hint: Some("Sub-parameters must use the key=value form.".to_string()),
535 }
536 .into());
537 }
538 };
539 let key = &next[..eq_pos];
540 let value = &next[eq_pos + 1..];
541
542 let arg_def = param_defs.iter().find(|a| a.name == key).ok_or_else(|| {
543 ParseError::UnknownOptionParameter {
544 option: option.name.clone(),
545 discriminant: discriminant.clone(),
546 key: key.to_string(),
547 valid_keys: param_defs.iter().map(|a| a.name.clone()).collect(),
548 suggestion: Some(format!(
549 "Run --help {} to see valid keys for --{} {}.",
550 self.definition.name, option.name, discriminant
551 )),
552 }
553 })?;
554
555 let typed_value = type_parser::parse_value(value, arg_def.arg_type)?;
556 params.insert(key.to_string(), typed_value);
557
558 *index += 1;
559 }
560
561 for arg_def in param_defs {
563 if arg_def.required && !params.contains_key(&arg_def.name) {
564 return Err(ParseError::MissingRequiredOptionParameter {
565 option: option.name.clone(),
566 discriminant: discriminant.clone(),
567 key: arg_def.name.clone(),
568 suggestion: Some(format!(
569 "Run --help {} to see required keys for --{} {}.",
570 self.definition.name, option.name, discriminant
571 )),
572 }
573 .into());
574 }
575 }
576
577 let occurrence = OptionOccurrence {
578 discriminant: discriminant.clone(),
579 params,
580 };
581
582 match result
583 .entry(option.name.clone())
584 .or_insert_with(|| ParsedValue::Repeated(Vec::new()))
585 {
586 ParsedValue::Repeated(occurrences) => {
587 if occurrences.contains(&occurrence) {
588 return Err(ParseError::DuplicateOptionOccurrence {
589 option: option.name.clone(),
590 discriminant: occurrence.discriminant.clone(),
591 params: occurrence.params.clone().into_iter().collect(),
592 suggestion: Some(format!(
593 "Remove one of the two identical --{} {} occurrences.",
594 option.name, discriminant
595 )),
596 }
597 .into());
598 }
599 occurrences.push(occurrence);
600 }
601 ParsedValue::Scalar(_) => unreachable!(
602 "option '{}' marked repeatable cannot already hold a Scalar value",
603 option.name
604 ),
605 }
606
607 Ok(())
608 }
609
610 fn apply_defaults(&self, result: &mut HashMap<String, ParsedValue>) -> Result<()> {
612 for option in &self.definition.options {
613 if !result.contains_key(&option.name) {
614 if let Some(ref default) = option.default {
615 let parsed_default = type_parser::parse_value(default, option.option_type)?;
617 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_default));
618 }
619 }
620 }
621 Ok(())
622 }
623
624 fn validate_required_arguments(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
626 for arg in &self.definition.arguments {
627 if arg.required && !result.contains_key(&arg.name) {
628 return Err(ParseError::missing_argument(&arg.name, &self.definition.name).into());
629 }
630 }
631 Ok(())
632 }
633
634 fn validate_required_options(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
636 for option in &self.definition.options {
637 if option.required && !result.contains_key(&option.name) {
638 return Err(ParseError::missing_option(
639 &option
640 .long
641 .clone()
642 .or(option.short.clone())
643 .unwrap_or_default(),
644 &self.definition.name,
645 )
646 .into());
647 }
648 }
649 Ok(())
650 }
651
652 fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
654 self.definition
655 .options
656 .iter()
657 .find(|opt| opt.long.as_deref() == Some(long))
658 .ok_or_else(|| {
659 let available: Vec<String> = self
660 .definition
661 .options
662 .iter()
663 .filter_map(|o| o.long.clone())
664 .collect();
665 ParseError::unknown_option_with_suggestions(
666 &format!("--{}", long),
667 &self.definition.name,
668 &available,
669 )
670 .into()
671 })
672 }
673
674 fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
676 self.definition
677 .options
678 .iter()
679 .find(|opt| opt.short.as_deref() == Some(short))
680 .ok_or_else(|| {
681 let available: Vec<String> = self
682 .definition
683 .options
684 .iter()
685 .filter_map(|o| o.short.clone())
686 .collect();
687 ParseError::unknown_option_with_suggestions(
688 &format!("-{}", short),
689 &self.definition.name,
690 &available,
691 )
692 .into()
693 })
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::config::schema::{ArgumentType, OptionDefinition};
701
702 fn create_test_definition() -> CommandDefinition {
704 CommandDefinition {
705 name: "test".to_string(),
706 aliases: vec![],
707 description: "Test command".to_string(),
708 required: false,
709 arguments: vec![
710 ArgumentDefinition {
711 name: "input".to_string(),
712 arg_type: ArgumentType::Path,
713 required: true,
714 description: "Input file".to_string(),
715 validation: vec![],
716 secure: false,
717 },
718 ArgumentDefinition {
719 name: "output".to_string(),
720 arg_type: ArgumentType::Path,
721 required: false,
722 description: "Output file".to_string(),
723 validation: vec![],
724 secure: false,
725 },
726 ],
727 options: vec![
728 OptionDefinition {
729 name: "verbose".to_string(),
730 short: Some("v".to_string()),
731 long: Some("verbose".to_string()),
732 option_type: ArgumentType::Bool,
733 required: false,
734 default: Some("false".to_string()),
735 description: "Verbose output".to_string(),
736 choices: vec![],
737 repeatable: false,
738 option_parameters: HashMap::new(),
739 },
740 OptionDefinition {
741 name: "count".to_string(),
742 short: Some("c".to_string()),
743 long: Some("count".to_string()),
744 option_type: ArgumentType::Integer,
745 required: false,
746 default: Some("10".to_string()),
747 description: "Count".to_string(),
748 choices: vec![],
749 repeatable: false,
750 option_parameters: HashMap::new(),
751 },
752 ],
753 implementation: "handler".to_string(),
754 }
755 }
756
757 #[test]
762 fn test_parse_single_positional_argument() {
763 let definition = create_test_definition();
764 let parser = CliParser::new(&definition);
765
766 let args = vec!["input.txt".to_string()];
767 let result = parser.parse(&args).unwrap();
768
769 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
770 }
771
772 #[test]
773 fn test_parse_multiple_positional_arguments() {
774 let definition = create_test_definition();
775 let parser = CliParser::new(&definition);
776
777 let args = vec!["input.txt".to_string(), "output.txt".to_string()];
778 let result = parser.parse(&args).unwrap();
779
780 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
781 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
782 }
783
784 #[test]
785 fn test_parse_missing_required_argument() {
786 let definition = create_test_definition();
787 let parser = CliParser::new(&definition);
788
789 let args: Vec<String> = vec![];
790 let result = parser.parse(&args);
791
792 assert!(result.is_err());
793 match result.unwrap_err() {
794 crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
795 argument, ..
796 }) => {
797 assert_eq!(argument, "input");
798 }
799 other => panic!("Expected MissingArgument error, got {:?}", other),
800 }
801 }
802
803 #[test]
804 fn test_parse_too_many_positional_arguments() {
805 let definition = create_test_definition();
806 let parser = CliParser::new(&definition);
807
808 let args = vec![
809 "input.txt".to_string(),
810 "output.txt".to_string(),
811 "extra.txt".to_string(),
812 ];
813 let result = parser.parse(&args);
814
815 assert!(result.is_err());
816 match result.unwrap_err() {
817 crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
818 other => panic!("Expected TooManyArguments error, got {:?}", other),
819 }
820 }
821
822 #[test]
827 fn test_parse_long_boolean_option() {
828 let definition = create_test_definition();
829 let parser = CliParser::new(&definition);
830
831 let args = vec!["input.txt".to_string(), "--verbose".to_string()];
832 let result = parser.parse(&args).unwrap();
833
834 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
835 }
836
837 #[test]
838 fn test_parse_long_option_with_equals() {
839 let definition = create_test_definition();
840 let parser = CliParser::new(&definition);
841
842 let args = vec!["input.txt".to_string(), "--count=42".to_string()];
843 let result = parser.parse(&args).unwrap();
844
845 assert_eq!(result.get("count"), Some(&"42".to_string()));
846 }
847
848 #[test]
849 fn test_parse_long_option_with_space() {
850 let definition = create_test_definition();
851 let parser = CliParser::new(&definition);
852
853 let args = vec![
854 "input.txt".to_string(),
855 "--count".to_string(),
856 "42".to_string(),
857 ];
858 let result = parser.parse(&args).unwrap();
859
860 assert_eq!(result.get("count"), Some(&"42".to_string()));
861 }
862
863 #[test]
864 fn test_parse_unknown_long_option() {
865 let definition = create_test_definition();
866 let parser = CliParser::new(&definition);
867
868 let args = vec!["input.txt".to_string(), "--unknown".to_string()];
869 let result = parser.parse(&args);
870
871 assert!(result.is_err());
872 match result.unwrap_err() {
873 crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
874 other => panic!("Expected UnknownOption error, got {:?}", other),
875 }
876 }
877
878 #[test]
883 fn test_parse_short_boolean_option() {
884 let definition = create_test_definition();
885 let parser = CliParser::new(&definition);
886
887 let args = vec!["input.txt".to_string(), "-v".to_string()];
888 let result = parser.parse(&args).unwrap();
889
890 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
891 }
892
893 #[test]
894 fn test_parse_short_option_with_space() {
895 let definition = create_test_definition();
896 let parser = CliParser::new(&definition);
897
898 let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
899 let result = parser.parse(&args).unwrap();
900
901 assert_eq!(result.get("count"), Some(&"42".to_string()));
902 }
903
904 #[test]
905 fn test_parse_short_option_attached_value() {
906 let definition = create_test_definition();
907 let parser = CliParser::new(&definition);
908
909 let args = vec!["input.txt".to_string(), "-c42".to_string()];
910 let result = parser.parse(&args).unwrap();
911
912 assert_eq!(result.get("count"), Some(&"42".to_string()));
913 }
914
915 #[test]
916 fn test_parse_negative_number_as_positional() {
917 let definition = create_test_definition();
918 let parser = CliParser::new(&definition);
919
920 let args = vec!["-123".to_string()];
922 let result = parser.parse(&args).unwrap();
923
924 assert_eq!(result.get("input"), Some(&"-123".to_string()));
925 }
926
927 #[test]
932 fn test_apply_default_values() {
933 let definition = create_test_definition();
934 let parser = CliParser::new(&definition);
935
936 let args = vec!["input.txt".to_string()];
937 let result = parser.parse(&args).unwrap();
938
939 assert_eq!(result.get("verbose"), Some(&"false".to_string()));
941 assert_eq!(result.get("count"), Some(&"10".to_string()));
942 }
943
944 #[test]
945 fn test_override_default_values() {
946 let definition = create_test_definition();
947 let parser = CliParser::new(&definition);
948
949 let args = vec![
950 "input.txt".to_string(),
951 "-v".to_string(),
952 "-c".to_string(),
953 "5".to_string(),
954 ];
955 let result = parser.parse(&args).unwrap();
956
957 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
959 assert_eq!(result.get("count"), Some(&"5".to_string()));
960 }
961
962 #[test]
967 fn test_type_conversion_error() {
968 let definition = create_test_definition();
969 let parser = CliParser::new(&definition);
970
971 let args = vec![
973 "input.txt".to_string(),
974 "--count".to_string(),
975 "abc".to_string(),
976 ];
977 let result = parser.parse(&args);
978
979 assert!(result.is_err());
980 }
981
982 #[test]
987 fn test_parse_complex_command_line() {
988 let definition = create_test_definition();
989 let parser = CliParser::new(&definition);
990
991 let args = vec![
992 "input.txt".to_string(),
993 "output.txt".to_string(),
994 "--verbose".to_string(),
995 "--count=100".to_string(),
996 ];
997 let result = parser.parse(&args).unwrap();
998
999 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1000 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1001 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1002 assert_eq!(result.get("count"), Some(&"100".to_string()));
1003 }
1004
1005 #[test]
1006 fn test_parse_mixed_options_and_arguments() {
1007 let definition = create_test_definition();
1008 let parser = CliParser::new(&definition);
1009
1010 let args = vec![
1012 "--verbose".to_string(),
1013 "input.txt".to_string(),
1014 "-c".to_string(),
1015 "50".to_string(),
1016 "output.txt".to_string(),
1017 ];
1018 let result = parser.parse(&args).unwrap();
1019
1020 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1021 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1022 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1023 assert_eq!(result.get("count"), Some(&"50".to_string()));
1024 }
1025
1026 fn create_repeatable_test_definition() -> CommandDefinition {
1034 let mut option_parameters = HashMap::new();
1035 option_parameters.insert(
1036 "csv".to_string(),
1037 vec![
1038 ArgumentDefinition {
1039 name: "file".to_string(),
1040 arg_type: ArgumentType::Path,
1041 required: true,
1042 description: "Destination CSV file".to_string(),
1043 validation: vec![],
1044 secure: false,
1045 },
1046 ArgumentDefinition {
1047 name: "resolution".to_string(),
1048 arg_type: ArgumentType::Integer,
1049 required: false,
1050 description: "Time-step resolution".to_string(),
1051 validation: vec![],
1052 secure: false,
1053 },
1054 ],
1055 );
1056 option_parameters.insert(
1057 "plot".to_string(),
1058 vec![ArgumentDefinition {
1059 name: "file".to_string(),
1060 arg_type: ArgumentType::Path,
1061 required: true,
1062 description: "Destination image file".to_string(),
1063 validation: vec![],
1064 secure: false,
1065 }],
1066 );
1067
1068 CommandDefinition {
1069 name: "export".to_string(),
1070 aliases: vec![],
1071 description: "Export simulation results".to_string(),
1072 required: false,
1073 arguments: vec![],
1074 options: vec![OptionDefinition {
1075 name: "output".to_string(),
1076 short: None,
1077 long: Some("output".to_string()),
1078 option_type: ArgumentType::String,
1079 required: false,
1080 default: None,
1081 description: "Write results in one or more output kinds".to_string(),
1082 choices: vec!["csv".to_string(), "plot".to_string()],
1083 repeatable: true,
1084 option_parameters,
1085 }],
1086 implementation: "export_handler".to_string(),
1087 }
1088 }
1089
1090 #[test]
1091 fn test_parse_repeatable_option_single_occurrence() {
1092 let definition = create_repeatable_test_definition();
1093 let parser = CliParser::new(&definition);
1094
1095 let args = vec![
1096 "--output".to_string(),
1097 "csv".to_string(),
1098 "file=results.csv".to_string(),
1099 ];
1100 let result = parser.parse_typed(&args).unwrap();
1101
1102 match result.get("output") {
1103 Some(ParsedValue::Repeated(occurrences)) => {
1104 assert_eq!(occurrences.len(), 1);
1105 assert_eq!(occurrences[0].discriminant, "csv");
1106 assert_eq!(
1107 occurrences[0].params.get("file"),
1108 Some(&"results.csv".to_string())
1109 );
1110 }
1111 other => panic!("Expected Repeated([csv]), got {:?}", other),
1112 }
1113 }
1114
1115 #[test]
1116 fn test_parse_repeatable_option_optional_param_can_be_omitted() {
1117 let definition = create_repeatable_test_definition();
1118 let parser = CliParser::new(&definition);
1119
1120 let args = vec![
1121 "--output".to_string(),
1122 "csv".to_string(),
1123 "file=results.csv".to_string(),
1124 ];
1125 let result = parser.parse_typed(&args).unwrap();
1126
1127 match result.get("output") {
1128 Some(ParsedValue::Repeated(occurrences)) => {
1129 assert_eq!(occurrences[0].params.get("resolution"), None);
1130 }
1131 other => panic!("Expected Repeated([csv]), got {:?}", other),
1132 }
1133 }
1134
1135 #[test]
1136 fn test_parse_repeatable_option_with_optional_param_provided() {
1137 let definition = create_repeatable_test_definition();
1138 let parser = CliParser::new(&definition);
1139
1140 let args = vec![
1141 "--output".to_string(),
1142 "csv".to_string(),
1143 "file=results.csv".to_string(),
1144 "resolution=100".to_string(),
1145 ];
1146 let result = parser.parse_typed(&args).unwrap();
1147
1148 match result.get("output") {
1149 Some(ParsedValue::Repeated(occurrences)) => {
1150 assert_eq!(
1151 occurrences[0].params.get("resolution"),
1152 Some(&"100".to_string())
1153 );
1154 }
1155 other => panic!("Expected Repeated([csv]), got {:?}", other),
1156 }
1157 }
1158
1159 #[test]
1160 fn test_parse_repeatable_option_multiple_discriminants_both_parse() {
1161 let definition = create_repeatable_test_definition();
1162 let parser = CliParser::new(&definition);
1163
1164 let args = vec![
1165 "--output".to_string(),
1166 "csv".to_string(),
1167 "file=results.csv".to_string(),
1168 "--output".to_string(),
1169 "plot".to_string(),
1170 "file=chart.png".to_string(),
1171 ];
1172 let result = parser.parse_typed(&args).unwrap();
1173
1174 match result.get("output") {
1175 Some(ParsedValue::Repeated(occurrences)) => {
1176 assert_eq!(occurrences.len(), 2);
1177 assert_eq!(occurrences[0].discriminant, "csv");
1178 assert_eq!(occurrences[1].discriminant, "plot");
1179 }
1180 other => panic!("Expected Repeated([csv, plot]), got {:?}", other),
1181 }
1182 }
1183
1184 #[test]
1185 fn test_parse_repeatable_option_same_discriminant_different_params_both_kept() {
1186 let definition = create_repeatable_test_definition();
1187 let parser = CliParser::new(&definition);
1188
1189 let args = vec![
1190 "--output".to_string(),
1191 "csv".to_string(),
1192 "file=a.csv".to_string(),
1193 "--output".to_string(),
1194 "csv".to_string(),
1195 "file=b.csv".to_string(),
1196 "resolution=50".to_string(),
1197 ];
1198 let result = parser.parse_typed(&args).unwrap();
1199
1200 match result.get("output") {
1201 Some(ParsedValue::Repeated(occurrences)) => {
1202 assert_eq!(occurrences.len(), 2);
1203 }
1204 other => panic!("Expected Repeated([csv, csv]), got {:?}", other),
1205 }
1206 }
1207
1208 #[test]
1209 fn test_parse_repeatable_option_duplicate_occurrence_errors() {
1210 let definition = create_repeatable_test_definition();
1211 let parser = CliParser::new(&definition);
1212
1213 let args = vec![
1214 "--output".to_string(),
1215 "csv".to_string(),
1216 "file=a.csv".to_string(),
1217 "--output".to_string(),
1218 "csv".to_string(),
1219 "file=a.csv".to_string(),
1220 ];
1221 let result = parser.parse_typed(&args);
1222
1223 assert!(result.is_err());
1224 match result.unwrap_err() {
1225 crate::error::DynamicCliError::Parse(ParseError::DuplicateOptionOccurrence {
1226 ..
1227 }) => {}
1228 other => panic!("Expected DuplicateOptionOccurrence error, got {:?}", other),
1229 }
1230 }
1231
1232 #[test]
1233 fn test_parse_repeatable_option_missing_required_param_errors() {
1234 let definition = create_repeatable_test_definition();
1235 let parser = CliParser::new(&definition);
1236
1237 let args = vec!["--output".to_string(), "csv".to_string()];
1239 let result = parser.parse_typed(&args);
1240
1241 assert!(result.is_err());
1242 match result.unwrap_err() {
1243 crate::error::DynamicCliError::Parse(ParseError::MissingRequiredOptionParameter {
1244 key,
1245 ..
1246 }) => {
1247 assert_eq!(key, "file");
1248 }
1249 other => panic!(
1250 "Expected MissingRequiredOptionParameter error, got {:?}",
1251 other
1252 ),
1253 }
1254 }
1255
1256 #[test]
1257 fn test_parse_repeatable_option_unknown_param_key_errors() {
1258 let definition = create_repeatable_test_definition();
1259 let parser = CliParser::new(&definition);
1260
1261 let args = vec![
1262 "--output".to_string(),
1263 "csv".to_string(),
1264 "file=a.csv".to_string(),
1265 "compression=gzip".to_string(),
1266 ];
1267 let result = parser.parse_typed(&args);
1268
1269 assert!(result.is_err());
1270 match result.unwrap_err() {
1271 crate::error::DynamicCliError::Parse(ParseError::UnknownOptionParameter {
1272 key,
1273 ..
1274 }) => {
1275 assert_eq!(key, "compression");
1276 }
1277 other => panic!("Expected UnknownOptionParameter error, got {:?}", other),
1278 }
1279 }
1280
1281 #[test]
1282 fn test_parse_repeatable_option_unknown_discriminant_errors() {
1283 let definition = create_repeatable_test_definition();
1284 let parser = CliParser::new(&definition);
1285
1286 let args = vec![
1287 "--output".to_string(),
1288 "xml".to_string(),
1289 "file=a.xml".to_string(),
1290 ];
1291 let result = parser.parse_typed(&args);
1292
1293 assert!(result.is_err());
1294 match result.unwrap_err() {
1295 crate::error::DynamicCliError::Parse(ParseError::UnknownDiscriminant {
1296 value, ..
1297 }) => {
1298 assert_eq!(value, "xml");
1299 }
1300 other => panic!("Expected UnknownDiscriminant error, got {:?}", other),
1301 }
1302 }
1303
1304 #[test]
1305 fn test_parse_legacy_drops_repeated_values() {
1306 let definition = create_repeatable_test_definition();
1311 let parser = CliParser::new(&definition);
1312
1313 let args = vec![
1314 "--output".to_string(),
1315 "csv".to_string(),
1316 "file=a.csv".to_string(),
1317 ];
1318 let result = parser.parse(&args).unwrap();
1319
1320 assert_eq!(result.get("output"), None);
1321 }
1322}