1#[allow(unused_imports)]
46use crate::config::schema::{ArgumentDefinition, CommandDefinition, OptionDefinition};
47use crate::error::{ParseError, Result};
48use crate::parser::type_parser;
49use std::collections::HashMap;
50
51pub struct CliParser<'a> {
103 definition: &'a CommandDefinition,
105}
106
107#[derive(Debug, Clone, PartialEq)]
118pub struct OptionOccurrence {
119 pub discriminant: String,
122 pub params: HashMap<String, String>,
124}
125
126#[derive(Debug, Clone, PartialEq)]
134pub enum ParsedValue {
135 Scalar(String),
137 Repeated(Vec<OptionOccurrence>),
139}
140
141impl<'a> CliParser<'a> {
142 pub fn new(definition: &'a CommandDefinition) -> Self {
168 Self { definition }
169 }
170
171 pub fn parse(&self, args: &[String]) -> Result<HashMap<String, String>> {
234 let typed = self.parse_typed(args)?;
235
236 Ok(typed
237 .into_iter()
238 .filter_map(|(name, value)| match value {
239 ParsedValue::Scalar(s) => Some((name, s)),
240 ParsedValue::Repeated(_) => None,
241 })
242 .collect())
243 }
244
245 pub fn parse_typed(&self, args: &[String]) -> Result<HashMap<String, ParsedValue>> {
268 let mut result = HashMap::new();
269 let mut positional_index = 0;
270 let mut i = 0;
271
272 while i < args.len() {
274 let arg = &args[i];
275
276 if arg.starts_with("--") {
277 self.parse_long_option(arg, args, &mut i, &mut result)?;
279 } else if arg.starts_with('-') && arg.len() > 1 {
280 if arg
282 .chars()
283 .nth(1)
284 .map(|c| c.is_ascii_digit())
285 .unwrap_or(false)
286 {
287 self.parse_positional_argument(arg, positional_index, &mut result)?;
289 positional_index += 1;
290 } else {
291 self.parse_short_option(arg, args, &mut i, &mut result)?;
292 }
293 } else {
294 self.parse_positional_argument(arg, positional_index, &mut result)?;
296 positional_index += 1;
297 }
298
299 i += 1;
300 }
301
302 self.apply_defaults(&mut result)?;
304
305 self.validate_required_arguments(&result)?;
307 self.validate_required_options(&result)?;
308
309 Ok(result)
310 }
311
312 pub fn parse_typed_segment(
392 &self,
393 args: &[String],
394 ) -> Result<(HashMap<String, ParsedValue>, usize)> {
395 let mut result = HashMap::new();
396 let mut positional_index = 0;
397 let mut i = 0;
398
399 while i < args.len() {
400 let arg = &args[i];
401
402 if arg.starts_with("--") {
403 self.parse_long_option(arg, args, &mut i, &mut result)?;
405 } else if arg.starts_with('-') && arg.len() > 1 {
406 if arg
408 .chars()
409 .nth(1)
410 .map(|c| c.is_ascii_digit())
411 .unwrap_or(false)
412 {
413 if positional_index >= self.definition.arguments.len() {
416 break;
417 }
418 self.parse_positional_argument(arg, positional_index, &mut result)?;
419 positional_index += 1;
420 } else {
421 self.parse_short_option(arg, args, &mut i, &mut result)?;
422 }
423 } else {
424 if positional_index >= self.definition.arguments.len() {
429 break;
430 }
431 self.parse_positional_argument(arg, positional_index, &mut result)?;
432 positional_index += 1;
433 }
434
435 i += 1;
436 }
437
438 self.apply_defaults(&mut result)?;
440
441 self.validate_required_arguments(&result)?;
443 self.validate_required_options(&result)?;
444
445 Ok((result, i))
446 }
447
448 fn parse_long_option(
450 &self,
451 arg: &str,
452 args: &[String],
453 index: &mut usize,
454 result: &mut HashMap<String, ParsedValue>,
455 ) -> Result<()> {
456 let arg_without_dashes = &arg[2..];
457
458 if let Some(eq_pos) = arg_without_dashes.find('=') {
460 let option_name = &arg_without_dashes[..eq_pos];
461 let value = &arg_without_dashes[eq_pos + 1..];
462
463 let option = self.find_option_by_long(option_name)?;
464 if option.repeatable {
465 return Err(ParseError::InvalidSyntax {
469 details: format!(
470 "Option --{} is repeatable and does not support --{}=<value>",
471 option.name, option.name
472 ),
473 hint: Some(format!(
474 "Usage: --{} <{}> [key=value ...]",
475 option.name,
476 option.choices.join("|")
477 )),
478 }
479 .into());
480 }
481 let parsed_value = type_parser::parse_value(value, option.option_type)?;
482 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
483 } else {
484 let option = self.find_option_by_long(arg_without_dashes)?;
486
487 if option.repeatable {
488 self.parse_repeatable_occurrence(option, args, index, result)?;
489 } else if matches!(
490 option.option_type,
491 crate::config::schema::ArgumentType::Bool
492 ) {
493 result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
494 } else {
495 *index += 1;
497 if *index >= args.len() {
498 return Err(ParseError::InvalidSyntax {
499 details: format!(
500 "Option --{} requires a value",
501 option.long.as_ref().unwrap()
502 ),
503 hint: Some(format!(
504 "Usage: --{}=<value> or --{} <value>",
505 option.long.as_ref().unwrap(),
506 option.long.as_ref().unwrap()
507 )),
508 }
509 .into());
510 }
511
512 let value = &args[*index];
513 let parsed_value = type_parser::parse_value(value, option.option_type)?;
514 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
515 }
516 }
517
518 Ok(())
519 }
520
521 fn parse_short_option(
523 &self,
524 arg: &str,
525 args: &[String],
526 index: &mut usize,
527 result: &mut HashMap<String, ParsedValue>,
528 ) -> Result<()> {
529 let short_flag = &arg[1..2];
530 let option = self.find_option_by_short(short_flag)?;
531
532 if option.repeatable {
533 if arg.len() > 2 {
534 return Err(ParseError::InvalidSyntax {
535 details: format!(
536 "Option -{} is repeatable and does not support an attached value",
537 short_flag
538 ),
539 hint: Some(format!(
540 "Usage: -{} <{}> [key=value ...]",
541 short_flag,
542 option.choices.join("|")
543 )),
544 }
545 .into());
546 }
547 self.parse_repeatable_occurrence(option, args, index, result)?;
548 } else if matches!(
549 option.option_type,
550 crate::config::schema::ArgumentType::Bool
551 ) {
552 result.insert(option.name.clone(), ParsedValue::Scalar("true".to_string()));
553 } else {
554 if arg.len() > 2 {
556 let value = &arg[2..];
557 let parsed_value = type_parser::parse_value(value, option.option_type)?;
558 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
559 } else {
560 *index += 1;
562 if *index >= args.len() {
563 return Err(ParseError::InvalidSyntax {
564 details: format!("Option -{} requires a value", short_flag),
565 hint: Some(format!(
566 "Usage: -{}<value> or -{} <value>",
567 short_flag, short_flag
568 )),
569 }
570 .into());
571 }
572
573 let value = &args[*index];
574 let parsed_value = type_parser::parse_value(value, option.option_type)?;
575 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_value));
576 }
577 }
578
579 Ok(())
580 }
581
582 fn parse_positional_argument(
584 &self,
585 value: &str,
586 index: usize,
587 result: &mut HashMap<String, ParsedValue>,
588 ) -> Result<()> {
589 if index >= self.definition.arguments.len() {
590 return Err(ParseError::too_many_arguments(
591 &self.definition.name,
592 self.definition.arguments.len(),
593 index + 1,
594 )
595 .into());
596 }
597
598 let arg_def = &self.definition.arguments[index];
599 let parsed_value = type_parser::parse_value(value, arg_def.arg_type)?;
600 result.insert(arg_def.name.clone(), ParsedValue::Scalar(parsed_value));
601
602 Ok(())
603 }
604
605 fn parse_repeatable_occurrence(
625 &self,
626 option: &OptionDefinition,
627 args: &[String],
628 index: &mut usize,
629 result: &mut HashMap<String, ParsedValue>,
630 ) -> Result<()> {
631 *index += 1;
633 if *index >= args.len() {
634 return Err(ParseError::InvalidSyntax {
635 details: format!("Option --{} requires a discriminant", option.name),
636 hint: Some(format!(
637 "Usage: --{} <{}> [key=value ...]",
638 option.name,
639 option.choices.join("|")
640 )),
641 }
642 .into());
643 }
644 let discriminant = args[*index].clone();
645 if !option.choices.contains(&discriminant) {
646 return Err(ParseError::UnknownDiscriminant {
647 option: option.name.clone(),
648 value: discriminant,
649 valid_choices: option.choices.clone(),
650 suggestion: Some(format!(
651 "Run --help {} to see valid --{} kinds.",
652 self.definition.name, option.name
653 )),
654 }
655 .into());
656 }
657
658 let empty: Vec<ArgumentDefinition> = Vec::new();
662 let param_defs = option
663 .option_parameters
664 .get(&discriminant)
665 .unwrap_or(&empty);
666
667 let mut params: HashMap<String, String> = HashMap::new();
670 while *index + 1 < args.len() {
671 let next = &args[*index + 1];
672 if next.starts_with('-') {
673 break;
674 }
675
676 let eq_pos = match next.find('=') {
677 Some(pos) => pos,
678 None => break,
681 };
682 let key = &next[..eq_pos];
683 let value = &next[eq_pos + 1..];
684
685 let arg_def = param_defs.iter().find(|a| a.name == key).ok_or_else(|| {
686 ParseError::UnknownOptionParameter {
687 option: option.name.clone(),
688 discriminant: discriminant.clone(),
689 key: key.to_string(),
690 valid_keys: param_defs.iter().map(|a| a.name.clone()).collect(),
691 suggestion: Some(format!(
692 "Run --help {} to see valid keys for --{} {}.",
693 self.definition.name, option.name, discriminant
694 )),
695 }
696 })?;
697
698 let typed_value = type_parser::parse_value(value, arg_def.arg_type)?;
699 params.insert(key.to_string(), typed_value);
700
701 *index += 1;
702 }
703
704 for arg_def in param_defs {
706 if arg_def.required && !params.contains_key(&arg_def.name) {
707 return Err(ParseError::MissingRequiredOptionParameter {
708 option: option.name.clone(),
709 discriminant: discriminant.clone(),
710 key: arg_def.name.clone(),
711 suggestion: Some(format!(
712 "Run --help {} to see required keys for --{} {}.",
713 self.definition.name, option.name, discriminant
714 )),
715 }
716 .into());
717 }
718 }
719
720 let occurrence = OptionOccurrence {
721 discriminant: discriminant.clone(),
722 params,
723 };
724
725 match result
726 .entry(option.name.clone())
727 .or_insert_with(|| ParsedValue::Repeated(Vec::new()))
728 {
729 ParsedValue::Repeated(occurrences) => {
730 if occurrences.contains(&occurrence) {
731 return Err(ParseError::DuplicateOptionOccurrence {
732 option: option.name.clone(),
733 discriminant: occurrence.discriminant.clone(),
734 params: occurrence.params.clone().into_iter().collect(),
735 suggestion: Some(format!(
736 "Remove one of the two identical --{} {} occurrences.",
737 option.name, discriminant
738 )),
739 }
740 .into());
741 }
742 occurrences.push(occurrence);
743 }
744 ParsedValue::Scalar(_) => unreachable!(
745 "option '{}' marked repeatable cannot already hold a Scalar value",
746 option.name
747 ),
748 }
749
750 Ok(())
751 }
752
753 fn apply_defaults(&self, result: &mut HashMap<String, ParsedValue>) -> Result<()> {
755 for option in &self.definition.options {
756 if !result.contains_key(&option.name) {
757 if let Some(ref default) = option.default {
758 let parsed_default = type_parser::parse_value(default, option.option_type)?;
760 result.insert(option.name.clone(), ParsedValue::Scalar(parsed_default));
761 }
762 }
763 }
764 Ok(())
765 }
766
767 fn validate_required_arguments(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
769 for arg in &self.definition.arguments {
770 if arg.required && !result.contains_key(&arg.name) {
771 return Err(ParseError::missing_argument(&arg.name, &self.definition.name).into());
772 }
773 }
774 Ok(())
775 }
776
777 fn validate_required_options(&self, result: &HashMap<String, ParsedValue>) -> Result<()> {
779 for option in &self.definition.options {
780 if option.required && !result.contains_key(&option.name) {
781 return Err(ParseError::missing_option(
782 &option
783 .long
784 .clone()
785 .or(option.short.clone())
786 .unwrap_or_default(),
787 &self.definition.name,
788 )
789 .into());
790 }
791 }
792 Ok(())
793 }
794
795 fn find_option_by_long(&self, long: &str) -> Result<&OptionDefinition> {
797 self.definition
798 .options
799 .iter()
800 .find(|opt| opt.long.as_deref() == Some(long))
801 .ok_or_else(|| {
802 let available: Vec<String> = self
803 .definition
804 .options
805 .iter()
806 .filter_map(|o| o.long.clone())
807 .collect();
808 ParseError::unknown_option_with_suggestions(
809 &format!("--{}", long),
810 &self.definition.name,
811 &available,
812 )
813 .into()
814 })
815 }
816
817 fn find_option_by_short(&self, short: &str) -> Result<&OptionDefinition> {
819 self.definition
820 .options
821 .iter()
822 .find(|opt| opt.short.as_deref() == Some(short))
823 .ok_or_else(|| {
824 let available: Vec<String> = self
825 .definition
826 .options
827 .iter()
828 .filter_map(|o| o.short.clone())
829 .collect();
830 ParseError::unknown_option_with_suggestions(
831 &format!("-{}", short),
832 &self.definition.name,
833 &available,
834 )
835 .into()
836 })
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use crate::config::schema::{ArgumentType, OptionDefinition};
844
845 fn create_test_definition() -> CommandDefinition {
847 CommandDefinition {
848 name: "test".to_string(),
849 aliases: vec![],
850 description: "Test command".to_string(),
851 required: false,
852 arguments: vec![
853 ArgumentDefinition {
854 name: "input".to_string(),
855 arg_type: ArgumentType::Path,
856 required: true,
857 description: "Input file".to_string(),
858 validation: vec![],
859 secure: false,
860 },
861 ArgumentDefinition {
862 name: "output".to_string(),
863 arg_type: ArgumentType::Path,
864 required: false,
865 description: "Output file".to_string(),
866 validation: vec![],
867 secure: false,
868 },
869 ],
870 options: vec![
871 OptionDefinition {
872 name: "verbose".to_string(),
873 short: Some("v".to_string()),
874 long: Some("verbose".to_string()),
875 option_type: ArgumentType::Bool,
876 required: false,
877 default: Some("false".to_string()),
878 description: "Verbose output".to_string(),
879 choices: vec![],
880 repeatable: false,
881 option_parameters: HashMap::new(),
882 },
883 OptionDefinition {
884 name: "count".to_string(),
885 short: Some("c".to_string()),
886 long: Some("count".to_string()),
887 option_type: ArgumentType::Integer,
888 required: false,
889 default: Some("10".to_string()),
890 description: "Count".to_string(),
891 choices: vec![],
892 repeatable: false,
893 option_parameters: HashMap::new(),
894 },
895 ],
896 implementation: "handler".to_string(),
897 continue_on_failure: false,
898 requires_success: false,
899 }
900 }
901
902 #[test]
907 fn test_parse_single_positional_argument() {
908 let definition = create_test_definition();
909 let parser = CliParser::new(&definition);
910
911 let args = vec!["input.txt".to_string()];
912 let result = parser.parse(&args).unwrap();
913
914 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
915 }
916
917 #[test]
918 fn test_parse_multiple_positional_arguments() {
919 let definition = create_test_definition();
920 let parser = CliParser::new(&definition);
921
922 let args = vec!["input.txt".to_string(), "output.txt".to_string()];
923 let result = parser.parse(&args).unwrap();
924
925 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
926 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
927 }
928
929 #[test]
930 fn test_parse_missing_required_argument() {
931 let definition = create_test_definition();
932 let parser = CliParser::new(&definition);
933
934 let args: Vec<String> = vec![];
935 let result = parser.parse(&args);
936
937 assert!(result.is_err());
938 match result.unwrap_err() {
939 crate::error::DynamicCliError::Parse(ParseError::MissingArgument {
940 argument, ..
941 }) => {
942 assert_eq!(argument, "input");
943 }
944 other => panic!("Expected MissingArgument error, got {:?}", other),
945 }
946 }
947
948 #[test]
949 fn test_parse_too_many_positional_arguments() {
950 let definition = create_test_definition();
951 let parser = CliParser::new(&definition);
952
953 let args = vec![
954 "input.txt".to_string(),
955 "output.txt".to_string(),
956 "extra.txt".to_string(),
957 ];
958 let result = parser.parse(&args);
959
960 assert!(result.is_err());
961 match result.unwrap_err() {
962 crate::error::DynamicCliError::Parse(ParseError::TooManyArguments { .. }) => {}
963 other => panic!("Expected TooManyArguments error, got {:?}", other),
964 }
965 }
966
967 #[test]
972 fn test_parse_long_boolean_option() {
973 let definition = create_test_definition();
974 let parser = CliParser::new(&definition);
975
976 let args = vec!["input.txt".to_string(), "--verbose".to_string()];
977 let result = parser.parse(&args).unwrap();
978
979 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
980 }
981
982 #[test]
983 fn test_parse_long_option_with_equals() {
984 let definition = create_test_definition();
985 let parser = CliParser::new(&definition);
986
987 let args = vec!["input.txt".to_string(), "--count=42".to_string()];
988 let result = parser.parse(&args).unwrap();
989
990 assert_eq!(result.get("count"), Some(&"42".to_string()));
991 }
992
993 #[test]
994 fn test_parse_long_option_with_space() {
995 let definition = create_test_definition();
996 let parser = CliParser::new(&definition);
997
998 let args = vec![
999 "input.txt".to_string(),
1000 "--count".to_string(),
1001 "42".to_string(),
1002 ];
1003 let result = parser.parse(&args).unwrap();
1004
1005 assert_eq!(result.get("count"), Some(&"42".to_string()));
1006 }
1007
1008 #[test]
1009 fn test_parse_unknown_long_option() {
1010 let definition = create_test_definition();
1011 let parser = CliParser::new(&definition);
1012
1013 let args = vec!["input.txt".to_string(), "--unknown".to_string()];
1014 let result = parser.parse(&args);
1015
1016 assert!(result.is_err());
1017 match result.unwrap_err() {
1018 crate::error::DynamicCliError::Parse(ParseError::UnknownOption { .. }) => {}
1019 other => panic!("Expected UnknownOption error, got {:?}", other),
1020 }
1021 }
1022
1023 #[test]
1028 fn test_parse_short_boolean_option() {
1029 let definition = create_test_definition();
1030 let parser = CliParser::new(&definition);
1031
1032 let args = vec!["input.txt".to_string(), "-v".to_string()];
1033 let result = parser.parse(&args).unwrap();
1034
1035 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1036 }
1037
1038 #[test]
1039 fn test_parse_short_option_with_space() {
1040 let definition = create_test_definition();
1041 let parser = CliParser::new(&definition);
1042
1043 let args = vec!["input.txt".to_string(), "-c".to_string(), "42".to_string()];
1044 let result = parser.parse(&args).unwrap();
1045
1046 assert_eq!(result.get("count"), Some(&"42".to_string()));
1047 }
1048
1049 #[test]
1050 fn test_parse_short_option_attached_value() {
1051 let definition = create_test_definition();
1052 let parser = CliParser::new(&definition);
1053
1054 let args = vec!["input.txt".to_string(), "-c42".to_string()];
1055 let result = parser.parse(&args).unwrap();
1056
1057 assert_eq!(result.get("count"), Some(&"42".to_string()));
1058 }
1059
1060 #[test]
1061 fn test_parse_negative_number_as_positional() {
1062 let definition = create_test_definition();
1063 let parser = CliParser::new(&definition);
1064
1065 let args = vec!["-123".to_string()];
1067 let result = parser.parse(&args).unwrap();
1068
1069 assert_eq!(result.get("input"), Some(&"-123".to_string()));
1070 }
1071
1072 #[test]
1077 fn test_apply_default_values() {
1078 let definition = create_test_definition();
1079 let parser = CliParser::new(&definition);
1080
1081 let args = vec!["input.txt".to_string()];
1082 let result = parser.parse(&args).unwrap();
1083
1084 assert_eq!(result.get("verbose"), Some(&"false".to_string()));
1086 assert_eq!(result.get("count"), Some(&"10".to_string()));
1087 }
1088
1089 #[test]
1090 fn test_override_default_values() {
1091 let definition = create_test_definition();
1092 let parser = CliParser::new(&definition);
1093
1094 let args = vec![
1095 "input.txt".to_string(),
1096 "-v".to_string(),
1097 "-c".to_string(),
1098 "5".to_string(),
1099 ];
1100 let result = parser.parse(&args).unwrap();
1101
1102 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1104 assert_eq!(result.get("count"), Some(&"5".to_string()));
1105 }
1106
1107 #[test]
1112 fn test_type_conversion_error() {
1113 let definition = create_test_definition();
1114 let parser = CliParser::new(&definition);
1115
1116 let args = vec![
1118 "input.txt".to_string(),
1119 "--count".to_string(),
1120 "abc".to_string(),
1121 ];
1122 let result = parser.parse(&args);
1123
1124 assert!(result.is_err());
1125 }
1126
1127 #[test]
1132 fn test_parse_complex_command_line() {
1133 let definition = create_test_definition();
1134 let parser = CliParser::new(&definition);
1135
1136 let args = vec![
1137 "input.txt".to_string(),
1138 "output.txt".to_string(),
1139 "--verbose".to_string(),
1140 "--count=100".to_string(),
1141 ];
1142 let result = parser.parse(&args).unwrap();
1143
1144 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1145 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1146 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1147 assert_eq!(result.get("count"), Some(&"100".to_string()));
1148 }
1149
1150 #[test]
1151 fn test_parse_mixed_options_and_arguments() {
1152 let definition = create_test_definition();
1153 let parser = CliParser::new(&definition);
1154
1155 let args = vec![
1157 "--verbose".to_string(),
1158 "input.txt".to_string(),
1159 "-c".to_string(),
1160 "50".to_string(),
1161 "output.txt".to_string(),
1162 ];
1163 let result = parser.parse(&args).unwrap();
1164
1165 assert_eq!(result.get("input"), Some(&"input.txt".to_string()));
1166 assert_eq!(result.get("output"), Some(&"output.txt".to_string()));
1167 assert_eq!(result.get("verbose"), Some(&"true".to_string()));
1168 assert_eq!(result.get("count"), Some(&"50".to_string()));
1169 }
1170
1171 fn create_repeatable_test_definition() -> CommandDefinition {
1179 let mut option_parameters = HashMap::new();
1180 option_parameters.insert(
1181 "csv".to_string(),
1182 vec![
1183 ArgumentDefinition {
1184 name: "file".to_string(),
1185 arg_type: ArgumentType::Path,
1186 required: true,
1187 description: "Destination CSV file".to_string(),
1188 validation: vec![],
1189 secure: false,
1190 },
1191 ArgumentDefinition {
1192 name: "resolution".to_string(),
1193 arg_type: ArgumentType::Integer,
1194 required: false,
1195 description: "Time-step resolution".to_string(),
1196 validation: vec![],
1197 secure: false,
1198 },
1199 ],
1200 );
1201 option_parameters.insert(
1202 "plot".to_string(),
1203 vec![ArgumentDefinition {
1204 name: "file".to_string(),
1205 arg_type: ArgumentType::Path,
1206 required: true,
1207 description: "Destination image file".to_string(),
1208 validation: vec![],
1209 secure: false,
1210 }],
1211 );
1212
1213 CommandDefinition {
1214 name: "export".to_string(),
1215 aliases: vec![],
1216 description: "Export simulation results".to_string(),
1217 required: false,
1218 arguments: vec![],
1219 options: vec![OptionDefinition {
1220 name: "output".to_string(),
1221 short: None,
1222 long: Some("output".to_string()),
1223 option_type: ArgumentType::String,
1224 required: false,
1225 default: None,
1226 description: "Write results in one or more output kinds".to_string(),
1227 choices: vec!["csv".to_string(), "plot".to_string()],
1228 repeatable: true,
1229 option_parameters,
1230 }],
1231 implementation: "export_handler".to_string(),
1232 continue_on_failure: false,
1233 requires_success: false,
1234 }
1235 }
1236
1237 #[test]
1238 fn test_parse_repeatable_option_single_occurrence() {
1239 let definition = create_repeatable_test_definition();
1240 let parser = CliParser::new(&definition);
1241
1242 let args = vec![
1243 "--output".to_string(),
1244 "csv".to_string(),
1245 "file=results.csv".to_string(),
1246 ];
1247 let result = parser.parse_typed(&args).unwrap();
1248
1249 match result.get("output") {
1250 Some(ParsedValue::Repeated(occurrences)) => {
1251 assert_eq!(occurrences.len(), 1);
1252 assert_eq!(occurrences[0].discriminant, "csv");
1253 assert_eq!(
1254 occurrences[0].params.get("file"),
1255 Some(&"results.csv".to_string())
1256 );
1257 }
1258 other => panic!("Expected Repeated([csv]), got {:?}", other),
1259 }
1260 }
1261
1262 #[test]
1263 fn test_parse_repeatable_option_optional_param_can_be_omitted() {
1264 let definition = create_repeatable_test_definition();
1265 let parser = CliParser::new(&definition);
1266
1267 let args = vec![
1268 "--output".to_string(),
1269 "csv".to_string(),
1270 "file=results.csv".to_string(),
1271 ];
1272 let result = parser.parse_typed(&args).unwrap();
1273
1274 match result.get("output") {
1275 Some(ParsedValue::Repeated(occurrences)) => {
1276 assert_eq!(occurrences[0].params.get("resolution"), None);
1277 }
1278 other => panic!("Expected Repeated([csv]), got {:?}", other),
1279 }
1280 }
1281
1282 #[test]
1283 fn test_parse_repeatable_option_with_optional_param_provided() {
1284 let definition = create_repeatable_test_definition();
1285 let parser = CliParser::new(&definition);
1286
1287 let args = vec![
1288 "--output".to_string(),
1289 "csv".to_string(),
1290 "file=results.csv".to_string(),
1291 "resolution=100".to_string(),
1292 ];
1293 let result = parser.parse_typed(&args).unwrap();
1294
1295 match result.get("output") {
1296 Some(ParsedValue::Repeated(occurrences)) => {
1297 assert_eq!(
1298 occurrences[0].params.get("resolution"),
1299 Some(&"100".to_string())
1300 );
1301 }
1302 other => panic!("Expected Repeated([csv]), got {:?}", other),
1303 }
1304 }
1305
1306 #[test]
1307 fn test_parse_repeatable_option_multiple_discriminants_both_parse() {
1308 let definition = create_repeatable_test_definition();
1309 let parser = CliParser::new(&definition);
1310
1311 let args = vec![
1312 "--output".to_string(),
1313 "csv".to_string(),
1314 "file=results.csv".to_string(),
1315 "--output".to_string(),
1316 "plot".to_string(),
1317 "file=chart.png".to_string(),
1318 ];
1319 let result = parser.parse_typed(&args).unwrap();
1320
1321 match result.get("output") {
1322 Some(ParsedValue::Repeated(occurrences)) => {
1323 assert_eq!(occurrences.len(), 2);
1324 assert_eq!(occurrences[0].discriminant, "csv");
1325 assert_eq!(occurrences[1].discriminant, "plot");
1326 }
1327 other => panic!("Expected Repeated([csv, plot]), got {:?}", other),
1328 }
1329 }
1330
1331 #[test]
1332 fn test_parse_repeatable_option_same_discriminant_different_params_both_kept() {
1333 let definition = create_repeatable_test_definition();
1334 let parser = CliParser::new(&definition);
1335
1336 let args = vec![
1337 "--output".to_string(),
1338 "csv".to_string(),
1339 "file=a.csv".to_string(),
1340 "--output".to_string(),
1341 "csv".to_string(),
1342 "file=b.csv".to_string(),
1343 "resolution=50".to_string(),
1344 ];
1345 let result = parser.parse_typed(&args).unwrap();
1346
1347 match result.get("output") {
1348 Some(ParsedValue::Repeated(occurrences)) => {
1349 assert_eq!(occurrences.len(), 2);
1350 }
1351 other => panic!("Expected Repeated([csv, csv]), got {:?}", other),
1352 }
1353 }
1354
1355 #[test]
1356 fn test_parse_repeatable_option_duplicate_occurrence_errors() {
1357 let definition = create_repeatable_test_definition();
1358 let parser = CliParser::new(&definition);
1359
1360 let args = vec![
1361 "--output".to_string(),
1362 "csv".to_string(),
1363 "file=a.csv".to_string(),
1364 "--output".to_string(),
1365 "csv".to_string(),
1366 "file=a.csv".to_string(),
1367 ];
1368 let result = parser.parse_typed(&args);
1369
1370 assert!(result.is_err());
1371 match result.unwrap_err() {
1372 crate::error::DynamicCliError::Parse(ParseError::DuplicateOptionOccurrence {
1373 ..
1374 }) => {}
1375 other => panic!("Expected DuplicateOptionOccurrence error, got {:?}", other),
1376 }
1377 }
1378
1379 #[test]
1380 fn test_parse_repeatable_option_missing_required_param_errors() {
1381 let definition = create_repeatable_test_definition();
1382 let parser = CliParser::new(&definition);
1383
1384 let args = vec!["--output".to_string(), "csv".to_string()];
1386 let result = parser.parse_typed(&args);
1387
1388 assert!(result.is_err());
1389 match result.unwrap_err() {
1390 crate::error::DynamicCliError::Parse(ParseError::MissingRequiredOptionParameter {
1391 key,
1392 ..
1393 }) => {
1394 assert_eq!(key, "file");
1395 }
1396 other => panic!(
1397 "Expected MissingRequiredOptionParameter error, got {:?}",
1398 other
1399 ),
1400 }
1401 }
1402
1403 #[test]
1404 fn test_parse_repeatable_option_unknown_param_key_errors() {
1405 let definition = create_repeatable_test_definition();
1406 let parser = CliParser::new(&definition);
1407
1408 let args = vec![
1409 "--output".to_string(),
1410 "csv".to_string(),
1411 "file=a.csv".to_string(),
1412 "compression=gzip".to_string(),
1413 ];
1414 let result = parser.parse_typed(&args);
1415
1416 assert!(result.is_err());
1417 match result.unwrap_err() {
1418 crate::error::DynamicCliError::Parse(ParseError::UnknownOptionParameter {
1419 key,
1420 ..
1421 }) => {
1422 assert_eq!(key, "compression");
1423 }
1424 other => panic!("Expected UnknownOptionParameter error, got {:?}", other),
1425 }
1426 }
1427
1428 #[test]
1429 fn test_parse_repeatable_option_unknown_discriminant_errors() {
1430 let definition = create_repeatable_test_definition();
1431 let parser = CliParser::new(&definition);
1432
1433 let args = vec![
1434 "--output".to_string(),
1435 "xml".to_string(),
1436 "file=a.xml".to_string(),
1437 ];
1438 let result = parser.parse_typed(&args);
1439
1440 assert!(result.is_err());
1441 match result.unwrap_err() {
1442 crate::error::DynamicCliError::Parse(ParseError::UnknownDiscriminant {
1443 value, ..
1444 }) => {
1445 assert_eq!(value, "xml");
1446 }
1447 other => panic!("Expected UnknownDiscriminant error, got {:?}", other),
1448 }
1449 }
1450
1451 #[test]
1452 fn test_parse_legacy_drops_repeated_values() {
1453 let definition = create_repeatable_test_definition();
1458 let parser = CliParser::new(&definition);
1459
1460 let args = vec![
1461 "--output".to_string(),
1462 "csv".to_string(),
1463 "file=a.csv".to_string(),
1464 ];
1465 let result = parser.parse(&args).unwrap();
1466
1467 assert_eq!(result.get("output"), None);
1468 }
1469
1470 fn create_repeatable_and_positional_test_definition() -> CommandDefinition {
1479 let mut option_parameters = HashMap::new();
1480 option_parameters.insert(
1481 "csv".to_string(),
1482 vec![ArgumentDefinition {
1483 name: "file".to_string(),
1484 arg_type: ArgumentType::Path,
1485 required: true,
1486 description: "Destination CSV file".to_string(),
1487 validation: vec![],
1488 secure: false,
1489 }],
1490 );
1491
1492 CommandDefinition {
1493 name: "output".to_string(),
1494 aliases: vec![],
1495 description: "Configure output and a target label".to_string(),
1496 required: false,
1497 arguments: vec![ArgumentDefinition {
1498 name: "target".to_string(),
1499 arg_type: ArgumentType::String,
1500 required: false,
1501 description: "Target label".to_string(),
1502 validation: vec![],
1503 secure: false,
1504 }],
1505 options: vec![OptionDefinition {
1506 name: "format".to_string(),
1507 short: None,
1508 long: Some("format".to_string()),
1509 option_type: ArgumentType::String,
1510 required: false,
1511 default: None,
1512 description: "Output format".to_string(),
1513 choices: vec!["csv".to_string()],
1514 repeatable: true,
1515 option_parameters,
1516 }],
1517 implementation: "output_handler".to_string(),
1518 continue_on_failure: false,
1519 requires_success: false,
1520 }
1521 }
1522
1523 #[test]
1524 fn test_parse_typed_segment_stops_at_boundary_with_zero_arity() {
1525 let definition = create_repeatable_test_definition();
1530 let parser = CliParser::new(&definition);
1531
1532 let args = vec!["solve".to_string()];
1533 let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1534
1535 assert_eq!(consumed, 0);
1536 assert!(!result.contains_key("output"));
1537 }
1538
1539 #[test]
1540 fn test_parse_typed_segment_stops_after_repeatable_occurrence_with_zero_arity() {
1541 let definition = create_repeatable_test_definition();
1546 let parser = CliParser::new(&definition);
1547
1548 let args = vec![
1549 "--output".to_string(),
1550 "csv".to_string(),
1551 "file=result.csv".to_string(),
1552 "solve".to_string(),
1553 ];
1554 let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1555
1556 assert_eq!(consumed, 3, "'solve' at index 3 must not be consumed");
1557 match result.get("output") {
1558 Some(ParsedValue::Repeated(occurrences)) => {
1559 assert_eq!(occurrences.len(), 1);
1560 assert_eq!(occurrences[0].discriminant, "csv");
1561 }
1562 other => panic!("Expected Repeated([csv]), got {:?}", other),
1563 }
1564 }
1565
1566 #[test]
1567 fn test_parse_typed_segment_resumes_positional_counting_after_repeatable_occurrence() {
1568 let definition = create_repeatable_and_positional_test_definition();
1572 let parser = CliParser::new(&definition);
1573
1574 let args = vec![
1575 "--format".to_string(),
1576 "csv".to_string(),
1577 "file=out.csv".to_string(),
1578 "primary".to_string(),
1579 ];
1580 let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1581
1582 assert_eq!(consumed, args.len());
1583 assert_eq!(
1584 result.get("target"),
1585 Some(&ParsedValue::Scalar("primary".to_string()))
1586 );
1587 }
1588
1589 #[test]
1590 fn test_parse_repeatable_occurrence_bare_token_no_longer_errors() {
1591 let definition = create_repeatable_and_positional_test_definition();
1598 let parser = CliParser::new(&definition);
1599
1600 let args = vec![
1601 "--format".to_string(),
1602 "csv".to_string(),
1603 "file=out.csv".to_string(),
1604 "primary".to_string(),
1605 ];
1606 let result = parser.parse_typed(&args).unwrap();
1607
1608 assert_eq!(
1609 result.get("target"),
1610 Some(&ParsedValue::Scalar("primary".to_string()))
1611 );
1612 }
1613
1614 #[test]
1615 fn test_parse_typed_segment_negative_number_heuristic_preserved_at_boundary() {
1616 let definition = create_test_definition();
1621 let parser = CliParser::new(&definition);
1622
1623 let args = vec!["-3".to_string(), "-5".to_string(), "solve".to_string()];
1624 let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1625
1626 assert_eq!(consumed, 2, "'solve' at index 2 must not be consumed");
1627 assert_eq!(
1628 result.get("input"),
1629 Some(&ParsedValue::Scalar("-3".to_string()))
1630 );
1631 assert_eq!(
1632 result.get("output"),
1633 Some(&ParsedValue::Scalar("-5".to_string()))
1634 );
1635 }
1636
1637 #[test]
1638 fn test_parse_typed_segment_end_of_input_matches_parse_typed() {
1639 let definition = create_test_definition();
1643 let parser = CliParser::new(&definition);
1644
1645 let args = vec![
1646 "input.txt".to_string(),
1647 "output.txt".to_string(),
1648 "--verbose".to_string(),
1649 ];
1650
1651 let (segment_result, consumed) = parser.parse_typed_segment(&args).unwrap();
1652 let typed_result = parser.parse_typed(&args).unwrap();
1653
1654 assert_eq!(consumed, args.len());
1655 assert_eq!(segment_result, typed_result);
1656 }
1657
1658 #[test]
1659 fn test_parse_typed_segment_too_many_arguments_still_reported_by_dispatch_path() {
1660 let definition = create_test_definition();
1667 let parser = CliParser::new(&definition);
1668
1669 let args = vec![
1670 "input.txt".to_string(),
1671 "output.txt".to_string(),
1672 "unexpected_extra".to_string(),
1673 ];
1674 let (result, consumed) = parser.parse_typed_segment(&args).unwrap();
1675
1676 assert_eq!(consumed, 2, "'unexpected_extra' must not be consumed");
1677 assert_eq!(
1678 result.get("input"),
1679 Some(&ParsedValue::Scalar("input.txt".to_string()))
1680 );
1681 assert_eq!(
1682 result.get("output"),
1683 Some(&ParsedValue::Scalar("output.txt".to_string()))
1684 );
1685 }
1686}