1use crate::config::directive::{
34 effective_directives, DirectiveDefinition, ReplDirective, DIRECTIVE_PREFIX,
35};
36use crate::config::schema::{
37 ArgumentDefinition, ArgumentType, CommandDefinition, CommandsConfig, OptionDefinition,
38 ValidationRule,
39};
40use crate::error::{ConfigError, Result};
41use std::collections::{HashMap, HashSet};
42
43pub fn validate_config(config: &CommandsConfig) -> Result<()> {
90 let mut seen_names: HashSet<String> = HashSet::new();
92
93 for (idx, command) in config.commands.iter().enumerate() {
94 validate_command(command)?;
96
97 if !seen_names.insert(command.name.clone()) {
99 return Err(ConfigError::DuplicateCommand {
100 name: command.name.clone(),
101 suggestion: None,
102 }
103 .into());
104 }
105
106 for alias in &command.aliases {
108 if !seen_names.insert(alias.clone()) {
109 return Err(ConfigError::DuplicateCommand {
110 name: alias.clone(),
111 suggestion: None,
112 }
113 .into());
114 }
115 }
116
117 if command.name.trim().is_empty() {
119 return Err(ConfigError::InvalidSchema {
120 reason: "Command name cannot be empty".to_string(),
121 path: Some(format!("commands[{}].name", idx)),
122 suggestion: None,
123 }
124 .into());
125 }
126
127 if command.name.starts_with(DIRECTIVE_PREFIX) {
129 return Err(reserved_prefix_error(
130 &command.name,
131 format!("commands[{}].name", idx),
132 ));
133 }
134 for (alias_idx, alias) in command.aliases.iter().enumerate() {
135 if alias.starts_with(DIRECTIVE_PREFIX) {
136 return Err(reserved_prefix_error(
137 alias,
138 format!("commands[{}].aliases[{}]", idx, alias_idx),
139 ));
140 }
141 }
142
143 if command.implementation.trim().is_empty() {
145 return Err(ConfigError::InvalidSchema {
146 reason: "Command implementation cannot be empty".to_string(),
147 path: Some(format!("commands[{}].implementation", idx)),
148 suggestion: None,
149 }
150 .into());
151 }
152 }
153
154 validate_options(&config.global_options, "global_options")?;
156
157 validate_directives(&config.directives)?;
159
160 Ok(())
161}
162
163fn reserved_prefix_error(name: &str, path: String) -> crate::error::DynamicCliError {
165 ConfigError::InvalidSchema {
166 reason: format!(
167 "Command name or alias '{}' starts with '{}', which is reserved for REPL directives",
168 name, DIRECTIVE_PREFIX
169 ),
170 path: Some(path),
171 suggestion: Some(format!(
172 "Remove the leading '{}' from '{}'.",
173 DIRECTIVE_PREFIX, name
174 )),
175 }
176 .into()
177}
178
179fn validate_directives(overrides: &[DirectiveDefinition]) -> Result<()> {
192 let mut overridden: HashMap<ReplDirective, usize> = HashMap::new();
194 for (idx, entry) in overrides.iter().enumerate() {
195 if let Some(first) = overridden.insert(entry.implementation, idx) {
196 return Err(ConfigError::InvalidSchema {
197 reason: format!(
198 "Directive '{}' is overridden more than once (also at directives[{}])",
199 entry.implementation.as_str(),
200 first
201 ),
202 path: Some(format!("directives[{}].implementation", idx)),
203 suggestion: Some(format!(
204 "Merge the entries for '{}' into a single one.",
205 entry.implementation.as_str()
206 )),
207 }
208 .into());
209 }
210
211 validate_directive_token(&entry.name, format!("directives[{}].name", idx))?;
212 for (alias_idx, alias) in entry.aliases.iter().enumerate() {
213 validate_directive_token(alias, format!("directives[{}].aliases[{}]", idx, alias_idx))?;
214 }
215 }
216
217 let mut owners: HashMap<&str, ReplDirective> = HashMap::new();
220 let table = effective_directives(overrides);
221 for def in &table {
222 let tokens =
223 std::iter::once(def.name.as_str()).chain(def.aliases.iter().map(String::as_str));
224 for token in tokens {
225 if let Some(owner) = owners.insert(token, def.implementation) {
226 let culprit = overridden
227 .get(&def.implementation)
228 .or_else(|| overridden.get(&owner))
229 .copied();
230 let detail = if owner == def.implementation {
231 format!(
232 "Directive name or alias '{}' is used twice by '{}'",
233 token,
234 owner.as_str()
235 )
236 } else {
237 format!(
238 "Directive name or alias '{}' is used by both '{}' and '{}'",
239 token,
240 owner.as_str(),
241 def.implementation.as_str()
242 )
243 };
244 return Err(ConfigError::InvalidSchema {
245 reason: detail,
246 path: culprit.map(|idx| format!("directives[{}]", idx)),
247 suggestion: Some(
248 "Give each directive its own names; an override also replaces \
249 the default aliases of its directive."
250 .to_string(),
251 ),
252 }
253 .into());
254 }
255 }
256 }
257
258 Ok(())
259}
260
261fn validate_directive_token(token: &str, path: String) -> Result<()> {
263 let reason = if token.trim().is_empty() {
264 "Directive name or alias cannot be empty".to_string()
265 } else if token.chars().any(char::is_whitespace) {
266 format!("Directive name or alias '{}' contains whitespace", token)
267 } else if token.starts_with(DIRECTIVE_PREFIX) {
268 format!(
269 "Directive name or alias '{}' must not start with '{}'; the REPL adds it",
270 token, DIRECTIVE_PREFIX
271 )
272 } else {
273 return Ok(());
274 };
275
276 Err(ConfigError::InvalidSchema {
277 reason,
278 path: Some(path),
279 suggestion: None,
280 }
281 .into())
282}
283
284pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
325 validate_argument_types(&cmd.arguments)?;
327 validate_argument_ordering(&cmd.arguments, &cmd.name)?;
328 validate_argument_names(&cmd.arguments, &cmd.name)?;
329 validate_argument_validation_rules(&cmd.arguments, &cmd.name)?;
330
331 validate_options(&cmd.options, &cmd.name)?;
333 validate_option_flags(&cmd.options, &cmd.name)?;
334
335 check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
337
338 Ok(())
339}
340
341pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
373 for arg in args {
377 let _ = arg.arg_type;
380 }
381
382 Ok(())
383}
384
385fn validate_argument_ordering(args: &[ArgumentDefinition], context: &str) -> Result<()> {
395 let mut seen_optional = false;
396
397 for (idx, arg) in args.iter().enumerate() {
398 if !arg.required {
399 seen_optional = true;
400 } else if seen_optional {
401 return Err(ConfigError::InvalidSchema {
402 reason: format!(
403 "Required argument '{}' cannot come after optional arguments",
404 arg.name
405 ),
406 path: Some(format!("{}.arguments[{}]", context, idx)),
407 suggestion: None,
408 }
409 .into());
410 }
411 }
412
413 Ok(())
414}
415
416fn validate_argument_names(args: &[ArgumentDefinition], context: &str) -> Result<()> {
418 let mut seen_names: HashSet<String> = HashSet::new();
419
420 for (idx, arg) in args.iter().enumerate() {
421 if arg.name.trim().is_empty() {
422 return Err(ConfigError::InvalidSchema {
423 reason: "Argument name cannot be empty".to_string(),
424 path: Some(format!("{}.arguments[{}]", context, idx)),
425 suggestion: None,
426 }
427 .into());
428 }
429
430 if !seen_names.insert(arg.name.clone()) {
431 return Err(ConfigError::InvalidSchema {
432 reason: format!("Duplicate argument name: '{}'", arg.name),
433 path: Some(format!("{}.arguments", context)),
434 suggestion: None,
435 }
436 .into());
437 }
438 }
439
440 Ok(())
441}
442
443fn validate_argument_validation_rules(args: &[ArgumentDefinition], _context: &str) -> Result<()> {
445 for arg in args.iter() {
446 for rule in arg.validation.iter() {
447 match rule {
448 ValidationRule::MustExist { .. } | ValidationRule::Extensions { .. } => {
449 if arg.arg_type != ArgumentType::Path {
451 return Err(ConfigError::Inconsistency {
452 details: format!(
453 "Validation rule 'must_exist' or 'extensions' can only be used with 'path' type, \
454 but argument '{}' has type '{}'",
455 arg.name,
456 arg.arg_type.as_str()
457 ),
458 suggestion: None,
459 }.into());
460 }
461 }
462 ValidationRule::Range { min, max } => {
463 if !matches!(arg.arg_type, ArgumentType::Integer | ArgumentType::Float) {
465 return Err(ConfigError::Inconsistency {
466 details: format!(
467 "Validation rule 'range' can only be used with numeric types, \
468 but argument '{}' has type '{}'",
469 arg.name,
470 arg.arg_type.as_str()
471 ),
472 suggestion: None,
473 }
474 .into());
475 }
476
477 if let (Some(min_val), Some(max_val)) = (min, max) {
479 if min_val > max_val {
480 return Err(ConfigError::Inconsistency {
481 details: format!(
482 "Invalid range for argument '{}': min ({}) > max ({})",
483 arg.name, min_val, max_val
484 ),
485 suggestion: None,
486 }
487 .into());
488 }
489 }
490 }
491 }
492 }
493 }
494
495 Ok(())
496}
497
498fn validate_options(options: &[OptionDefinition], context: &str) -> Result<()> {
500 let mut seen_names: HashSet<String> = HashSet::new();
501
502 for (idx, opt) in options.iter().enumerate() {
503 if opt.name.trim().is_empty() {
505 return Err(ConfigError::InvalidSchema {
506 reason: "Option name cannot be empty".to_string(),
507 path: Some(format!("{}.options[{}]", context, idx)),
508 suggestion: None,
509 }
510 .into());
511 }
512
513 if !seen_names.insert(opt.name.clone()) {
515 return Err(ConfigError::InvalidSchema {
516 reason: format!("Duplicate option name: '{}'", opt.name),
517 path: Some(format!("{}.options", context)),
518 suggestion: None,
519 }
520 .into());
521 }
522
523 if opt.short.is_none() && opt.long.is_none() {
525 return Err(ConfigError::InvalidSchema {
526 reason: format!(
527 "Option '{}' must have at least a short or long form",
528 opt.name
529 ),
530 path: Some(format!("{}.options[{}]", context, idx)),
531 suggestion: None,
532 }
533 .into());
534 }
535
536 if opt.repeatable {
538 if let Some(ref default) = opt.default {
543 return Err(ConfigError::Inconsistency {
544 details: format!(
545 "Repeatable option '{}' cannot have a default value ('{}')",
546 opt.name, default
547 ),
548 suggestion: Some(
549 "Remove `default` — a repeatable option's absence already \
550 means zero occurrences, not an implicit one."
551 .to_string(),
552 ),
553 }
554 .into());
555 }
556
557 if opt.choices.is_empty() {
560 return Err(ConfigError::InvalidSchema {
561 reason: format!(
562 "Repeatable option '{}' must declare at least one discriminant in choices",
563 opt.name
564 ),
565 path: Some(format!("{}.options[{}].choices", context, idx)),
566 suggestion: Some(
567 "Add `choices: [...]` listing the valid discriminants for \
568 this repeatable option."
569 .to_string(),
570 ),
571 }
572 .into());
573 }
574
575 for discriminant in &opt.choices {
578 if !opt.option_parameters.contains_key(discriminant) {
579 return Err(ConfigError::InvalidSchema {
580 reason: format!(
581 "Discriminant '{}' is declared in choices for repeatable \
582 option '{}' but has no matching entry in option_parameters",
583 discriminant, opt.name
584 ),
585 path: Some(format!("{}.options[{}].option_parameters", context, idx)),
586 suggestion: Some(format!(
587 "Add an `option_parameters.{}` entry describing this \
588 discriminant's key=value parameters.",
589 discriminant
590 )),
591 }
592 .into());
593 }
594 }
595 let choices_set: HashSet<&String> = opt.choices.iter().collect();
596 for key in opt.option_parameters.keys() {
597 if !choices_set.contains(key) {
598 return Err(ConfigError::InvalidSchema {
599 reason: format!(
600 "option_parameters key '{}' on option '{}' is not declared in choices",
601 key, opt.name
602 ),
603 path: Some(format!(
604 "{}.options[{}].option_parameters.{}",
605 context, idx, key
606 )),
607 suggestion: Some(format!(
608 "Add '{}' to choices, or remove this option_parameters entry.",
609 key
610 )),
611 }
612 .into());
613 }
614 }
615
616 for (discriminant, params) in &opt.option_parameters {
621 let sub_context = format!(
622 "{}.options[{}].option_parameters.{}",
623 context, idx, discriminant
624 );
625 validate_argument_names(params, &sub_context)?;
626 validate_argument_types(params)?;
627 }
628 } else if !opt.option_parameters.is_empty() {
629 return Err(ConfigError::Inconsistency {
631 details: format!(
632 "Option '{}' has option_parameters but repeatable is false",
633 opt.name
634 ),
635 suggestion: Some(
636 "Set `repeatable: true`, or remove `option_parameters`.".to_string(),
637 ),
638 }
639 .into());
640 }
641
642 if let Some(ref default) = opt.default {
644 if !opt.choices.is_empty() && !opt.choices.contains(default) {
645 return Err(ConfigError::Inconsistency {
646 details: format!(
647 "Default value '{}' for option '{}' is not in choices: [{}]",
648 default,
649 opt.name,
650 opt.choices.join(", ")
651 ),
652 suggestion: None,
653 }
654 .into());
655 }
656 }
657
658 if opt.option_type == ArgumentType::Bool && !opt.choices.is_empty() {
660 return Err(ConfigError::Inconsistency {
661 details: format!("Boolean option '{}' cannot have choices", opt.name),
662 suggestion: None,
663 }
664 .into());
665 }
666 }
667
668 Ok(())
669}
670
671fn validate_option_flags(options: &[OptionDefinition], context: &str) -> Result<()> {
673 let mut seen_short: HashMap<String, String> = HashMap::new();
674 let mut seen_long: HashMap<String, String> = HashMap::new();
675
676 for opt in options {
677 if let Some(ref short) = opt.short {
679 if short.len() != 1 {
680 return Err(ConfigError::InvalidSchema {
681 reason: format!(
682 "Short option '{}' for '{}' must be a single character",
683 short, opt.name
684 ),
685 path: Some(format!("{}.options", context)),
686 suggestion: None,
687 }
688 .into());
689 }
690
691 if let Some(existing) = seen_short.insert(short.clone(), opt.name.clone()) {
692 return Err(ConfigError::InvalidSchema {
693 reason: format!(
694 "Short option '-{}' is used by both '{}' and '{}'",
695 short, existing, opt.name
696 ),
697 path: Some(format!("{}.options", context)),
698 suggestion: None,
699 }
700 .into());
701 }
702 }
703
704 if let Some(ref long) = opt.long {
706 if long.is_empty() {
707 return Err(ConfigError::InvalidSchema {
708 reason: format!("Long option for '{}' cannot be empty", opt.name),
709 path: Some(format!("{}.options", context)),
710 suggestion: None,
711 }
712 .into());
713 }
714
715 if let Some(existing) = seen_long.insert(long.clone(), opt.name.clone()) {
716 return Err(ConfigError::InvalidSchema {
717 reason: format!(
718 "Long option '--{}' is used by both '{}' and '{}'",
719 long, existing, opt.name
720 ),
721 path: Some(format!("{}.options", context)),
722 suggestion: None,
723 }
724 .into());
725 }
726 }
727 }
728
729 Ok(())
730}
731
732fn check_name_conflicts(
734 args: &[ArgumentDefinition],
735 options: &[OptionDefinition],
736 context: &str,
737) -> Result<()> {
738 let arg_names: HashSet<String> = args.iter().map(|a| a.name.clone()).collect();
739
740 for opt in options {
741 if arg_names.contains(&opt.name) {
742 return Err(ConfigError::InvalidSchema {
743 reason: format!("Option '{}' has the same name as an argument", opt.name),
744 path: Some(format!("{}.options", context)),
745 suggestion: None,
746 }
747 .into());
748 }
749 }
750
751 Ok(())
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use crate::config::schema::CommandsConfig;
758 use std::collections::HashMap;
759
760 #[test]
761 fn test_validate_config_empty() {
762 let config = CommandsConfig::minimal();
763 assert!(validate_config(&config).is_ok());
764 }
765
766 #[test]
767 fn test_validate_config_duplicate_command_name() {
768 let mut config = CommandsConfig::minimal();
769 config.commands = vec![
770 CommandDefinition {
771 name: "test".to_string(),
772 aliases: vec![],
773 description: "Test 1".to_string(),
774 required: false,
775 arguments: vec![],
776 options: vec![],
777 implementation: "handler1".to_string(),
778 continue_on_failure: false,
779 requires_success: false,
780 },
781 CommandDefinition {
782 name: "test".to_string(), aliases: vec![],
784 description: "Test 2".to_string(),
785 required: false,
786 arguments: vec![],
787 options: vec![],
788 implementation: "handler2".to_string(),
789 continue_on_failure: false,
790 requires_success: false,
791 },
792 ];
793
794 let result = validate_config(&config);
795 assert!(result.is_err());
796 match result.unwrap_err() {
797 crate::error::DynamicCliError::Config(ConfigError::DuplicateCommand {
798 name, ..
799 }) => {
800 assert_eq!(name, "test");
801 }
802 other => panic!("Expected DuplicateCommand error, got {:?}", other),
803 }
804 }
805
806 #[test]
807 fn test_validate_config_duplicate_alias() {
808 let mut config = CommandsConfig::minimal();
809 config.commands = vec![
810 CommandDefinition {
811 name: "cmd1".to_string(),
812 aliases: vec!["c".to_string()],
813 description: "Command 1".to_string(),
814 required: false,
815 arguments: vec![],
816 options: vec![],
817 implementation: "handler1".to_string(),
818 continue_on_failure: false,
819 requires_success: false,
820 },
821 CommandDefinition {
822 name: "cmd2".to_string(),
823 aliases: vec!["c".to_string()], description: "Command 2".to_string(),
825 required: false,
826 arguments: vec![],
827 options: vec![],
828 implementation: "handler2".to_string(),
829 continue_on_failure: false,
830 requires_success: false,
831 },
832 ];
833
834 let result = validate_config(&config);
835 assert!(result.is_err());
836 }
837
838 #[test]
839 fn test_validate_command_empty_name() {
840 let cmd = CommandDefinition {
841 name: "".to_string(), aliases: vec![],
843 description: "Test".to_string(),
844 required: false,
845 arguments: vec![],
846 options: vec![],
847 implementation: "handler".to_string(),
848 continue_on_failure: false,
849 requires_success: false,
850 };
851
852 let mut config = CommandsConfig::minimal();
853 config.commands = vec![cmd];
854
855 let result = validate_config(&config);
856 assert!(result.is_err());
857 }
858
859 #[test]
860 fn test_validate_argument_ordering() {
861 let args = vec![
862 ArgumentDefinition {
863 name: "optional".to_string(),
864 arg_type: ArgumentType::String,
865 required: false,
866 description: "Optional".to_string(),
867 validation: vec![],
868 secure: false,
869 },
870 ArgumentDefinition {
871 name: "required".to_string(),
872 arg_type: ArgumentType::String,
873 required: true, description: "Required".to_string(),
875 validation: vec![],
876 secure: false,
877 },
878 ];
879
880 let result = validate_argument_ordering(&args, "test");
881 assert!(result.is_err());
882 }
883
884 #[test]
885 fn test_validate_argument_names_duplicate() {
886 let args = vec![
887 ArgumentDefinition {
888 name: "arg1".to_string(),
889 arg_type: ArgumentType::String,
890 required: true,
891 description: "Arg 1".to_string(),
892 validation: vec![],
893 secure: false,
894 },
895 ArgumentDefinition {
896 name: "arg1".to_string(), arg_type: ArgumentType::Integer,
898 required: true,
899 description: "Arg 1 again".to_string(),
900 validation: vec![],
901 secure: false,
902 },
903 ];
904
905 let result = validate_argument_names(&args, "test");
906 assert!(result.is_err());
907 }
908
909 #[test]
910 fn test_validate_validation_rules_type_mismatch() {
911 let args = vec![ArgumentDefinition {
912 name: "count".to_string(),
913 arg_type: ArgumentType::Integer,
914 required: true,
915 description: "Count".to_string(),
916 validation: vec![
917 ValidationRule::MustExist { must_exist: true }, ],
919 secure: false,
920 }];
921
922 let result = validate_argument_validation_rules(&args, "test");
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn test_validate_validation_rules_invalid_range() {
928 let args = vec![ArgumentDefinition {
929 name: "percentage".to_string(),
930 arg_type: ArgumentType::Float,
931 required: true,
932 description: "Percentage".to_string(),
933 validation: vec![ValidationRule::Range {
934 min: Some(100.0),
935 max: Some(0.0), }],
937 secure: false,
938 }];
939
940 let result = validate_argument_validation_rules(&args, "test");
941 assert!(result.is_err());
942 }
943
944 #[test]
945 fn test_validate_options_no_flags() {
946 let options = vec![OptionDefinition {
947 name: "opt1".to_string(),
948 short: None,
949 long: None, option_type: ArgumentType::String,
951 required: false,
952 default: None,
953 description: "Option".to_string(),
954 choices: vec![],
955 repeatable: false,
956 option_parameters: HashMap::new(),
957 }];
958
959 let result = validate_options(&options, "test");
960 assert!(result.is_err());
961 }
962
963 #[test]
964 fn test_validate_options_default_not_in_choices() {
965 let options = vec![OptionDefinition {
966 name: "mode".to_string(),
967 short: Some("m".to_string()),
968 long: Some("mode".to_string()),
969 option_type: ArgumentType::String,
970 required: false,
971 default: Some("invalid".to_string()), description: "Mode".to_string(),
973 choices: vec!["fast".to_string(), "slow".to_string()],
974 repeatable: false,
975 option_parameters: HashMap::new(),
976 }];
977
978 let result = validate_options(&options, "test");
979 assert!(result.is_err());
980 }
981
982 #[test]
983 fn test_validate_option_flags_duplicate_short() {
984 let options = vec![
985 OptionDefinition {
986 name: "opt1".to_string(),
987 short: Some("o".to_string()),
988 long: None,
989 option_type: ArgumentType::String,
990 required: false,
991 default: None,
992 description: "Option 1".to_string(),
993 choices: vec![],
994 repeatable: false,
995 option_parameters: HashMap::new(),
996 },
997 OptionDefinition {
998 name: "opt2".to_string(),
999 short: Some("o".to_string()), long: None,
1001 option_type: ArgumentType::String,
1002 required: false,
1003 default: None,
1004 description: "Option 2".to_string(),
1005 choices: vec![],
1006 repeatable: false,
1007 option_parameters: HashMap::new(),
1008 },
1009 ];
1010
1011 let result = validate_option_flags(&options, "test");
1012 assert!(result.is_err());
1013 }
1014
1015 #[test]
1016 fn test_validate_option_flags_invalid_short() {
1017 let options = vec![OptionDefinition {
1018 name: "opt1".to_string(),
1019 short: Some("opt".to_string()), long: None,
1021 option_type: ArgumentType::String,
1022 required: false,
1023 default: None,
1024 description: "Option".to_string(),
1025 choices: vec![],
1026 repeatable: false,
1027 option_parameters: HashMap::new(),
1028 }];
1029
1030 let result = validate_option_flags(&options, "test");
1031 assert!(result.is_err());
1032 }
1033
1034 #[test]
1035 fn test_check_name_conflicts() {
1036 let args = vec![ArgumentDefinition {
1037 name: "output".to_string(),
1038 arg_type: ArgumentType::Path,
1039 required: true,
1040 description: "Output".to_string(),
1041 validation: vec![],
1042 secure: false,
1043 }];
1044
1045 let options = vec![OptionDefinition {
1046 name: "output".to_string(), short: Some("o".to_string()),
1048 long: Some("output".to_string()),
1049 option_type: ArgumentType::Path,
1050 required: false,
1051 default: None,
1052 description: "Output".to_string(),
1053 choices: vec![],
1054 repeatable: false,
1055 option_parameters: HashMap::new(),
1056 }];
1057
1058 let result = check_name_conflicts(&args, &options, "test");
1059 assert!(result.is_err());
1060 }
1061
1062 #[test]
1063 fn test_validate_command_valid() {
1064 let cmd = CommandDefinition {
1065 name: "process".to_string(),
1066 aliases: vec!["proc".to_string()],
1067 description: "Process data".to_string(),
1068 required: false,
1069 arguments: vec![ArgumentDefinition {
1070 name: "input".to_string(),
1071 arg_type: ArgumentType::Path,
1072 required: true,
1073 description: "Input file".to_string(),
1074 validation: vec![
1075 ValidationRule::MustExist { must_exist: true },
1076 ValidationRule::Extensions {
1077 extensions: vec!["csv".to_string()],
1078 },
1079 ],
1080 secure: false,
1081 }],
1082 options: vec![OptionDefinition {
1083 name: "output".to_string(),
1084 short: Some("o".to_string()),
1085 long: Some("output".to_string()),
1086 option_type: ArgumentType::Path,
1087 required: false,
1088 default: Some("out.csv".to_string()),
1089 description: "Output file".to_string(),
1090 choices: vec![],
1091 repeatable: false,
1092 option_parameters: HashMap::new(),
1093 }],
1094 implementation: "process_handler".to_string(),
1095 continue_on_failure: false,
1096 requires_success: false,
1097 };
1098
1099 assert!(validate_command(&cmd).is_ok());
1100 }
1101
1102 #[test]
1103 fn test_validate_boolean_with_choices() {
1104 let options = vec![OptionDefinition {
1105 name: "flag".to_string(),
1106 short: Some("f".to_string()),
1107 long: Some("flag".to_string()),
1108 option_type: ArgumentType::Bool,
1109 required: false,
1110 default: None,
1111 description: "A flag".to_string(),
1112 choices: vec!["true".to_string(), "false".to_string()], repeatable: false,
1114 option_parameters: HashMap::new(),
1115 }];
1116
1117 let result = validate_options(&options, "test");
1118 assert!(result.is_err());
1119 }
1120
1121 #[test]
1124 fn test_validate_repeatable_requires_non_empty_choices() {
1125 let options = vec![OptionDefinition {
1126 name: "output".to_string(),
1127 short: None,
1128 long: Some("output".to_string()),
1129 option_type: ArgumentType::String,
1130 required: false,
1131 default: None,
1132 description: "Output".to_string(),
1133 choices: vec![], repeatable: true,
1135 option_parameters: HashMap::new(),
1136 }];
1137
1138 let result = validate_options(&options, "test");
1139 assert!(result.is_err());
1140 }
1141
1142 #[test]
1143 fn test_validate_repeatable_missing_option_parameters_entry() {
1144 let mut option_parameters = HashMap::new();
1145 option_parameters.insert(
1146 "csv".to_string(),
1147 vec![ArgumentDefinition {
1148 name: "file".to_string(),
1149 arg_type: ArgumentType::Path,
1150 required: true,
1151 description: "Destination file".to_string(),
1152 validation: vec![],
1153 secure: false,
1154 }],
1155 );
1156 let options = vec![OptionDefinition {
1158 name: "output".to_string(),
1159 short: None,
1160 long: Some("output".to_string()),
1161 option_type: ArgumentType::String,
1162 required: false,
1163 default: None,
1164 description: "Output".to_string(),
1165 choices: vec!["csv".to_string(), "plot".to_string()],
1166 repeatable: true,
1167 option_parameters,
1168 }];
1169
1170 let result = validate_options(&options, "test");
1171 assert!(result.is_err());
1172 }
1173
1174 #[test]
1175 fn test_validate_repeatable_orphan_option_parameters_key() {
1176 let mut option_parameters = HashMap::new();
1177 option_parameters.insert(
1178 "csv".to_string(),
1179 vec![ArgumentDefinition {
1180 name: "file".to_string(),
1181 arg_type: ArgumentType::Path,
1182 required: true,
1183 description: "Destination file".to_string(),
1184 validation: vec![],
1185 secure: false,
1186 }],
1187 );
1188 option_parameters.insert(
1190 "json".to_string(),
1191 vec![ArgumentDefinition {
1192 name: "file".to_string(),
1193 arg_type: ArgumentType::Path,
1194 required: true,
1195 description: "Destination file".to_string(),
1196 validation: vec![],
1197 secure: false,
1198 }],
1199 );
1200 let options = vec![OptionDefinition {
1201 name: "output".to_string(),
1202 short: None,
1203 long: Some("output".to_string()),
1204 option_type: ArgumentType::String,
1205 required: false,
1206 default: None,
1207 description: "Output".to_string(),
1208 choices: vec!["csv".to_string()],
1209 repeatable: true,
1210 option_parameters,
1211 }];
1212
1213 let result = validate_options(&options, "test");
1214 assert!(result.is_err());
1215 }
1216
1217 #[test]
1218 fn test_validate_repeatable_option_parameters_reuses_argument_validation() {
1219 let mut option_parameters = HashMap::new();
1220 option_parameters.insert(
1221 "csv".to_string(),
1222 vec![ArgumentDefinition {
1223 name: "".to_string(), arg_type: ArgumentType::Path,
1225 required: true,
1226 description: "Destination file".to_string(),
1227 validation: vec![],
1228 secure: false,
1229 }],
1230 );
1231 let options = vec![OptionDefinition {
1232 name: "output".to_string(),
1233 short: None,
1234 long: Some("output".to_string()),
1235 option_type: ArgumentType::String,
1236 required: false,
1237 default: None,
1238 description: "Output".to_string(),
1239 choices: vec!["csv".to_string()],
1240 repeatable: true,
1241 option_parameters,
1242 }];
1243
1244 let result = validate_options(&options, "test");
1245 assert!(result.is_err());
1246 }
1247
1248 #[test]
1249 fn test_validate_non_repeatable_with_option_parameters_is_error() {
1250 let mut option_parameters = HashMap::new();
1251 option_parameters.insert(
1252 "csv".to_string(),
1253 vec![ArgumentDefinition {
1254 name: "file".to_string(),
1255 arg_type: ArgumentType::Path,
1256 required: true,
1257 description: "Destination file".to_string(),
1258 validation: vec![],
1259 secure: false,
1260 }],
1261 );
1262 let options = vec![OptionDefinition {
1263 name: "output".to_string(),
1264 short: None,
1265 long: Some("output".to_string()),
1266 option_type: ArgumentType::String,
1267 required: false,
1268 default: None,
1269 description: "Output".to_string(),
1270 choices: vec!["csv".to_string()],
1271 repeatable: false, option_parameters,
1273 }];
1274
1275 let result = validate_options(&options, "test");
1276 assert!(result.is_err());
1277 }
1278
1279 #[test]
1280 fn test_validate_repeatable_with_default_is_error() {
1281 let mut option_parameters = HashMap::new();
1282 option_parameters.insert(
1283 "csv".to_string(),
1284 vec![ArgumentDefinition {
1285 name: "file".to_string(),
1286 arg_type: ArgumentType::Path,
1287 required: true,
1288 description: "Destination file".to_string(),
1289 validation: vec![],
1290 secure: false,
1291 }],
1292 );
1293 let options = vec![OptionDefinition {
1294 name: "output".to_string(),
1295 short: None,
1296 long: Some("output".to_string()),
1297 option_type: ArgumentType::String,
1298 required: false,
1299 default: Some("csv".to_string()), description: "Output".to_string(),
1301 choices: vec!["csv".to_string()],
1302 repeatable: true,
1303 option_parameters,
1304 }];
1305
1306 let result = validate_options(&options, "test");
1307 assert!(result.is_err());
1308 }
1309
1310 #[test]
1311 fn test_validate_repeatable_valid_config_passes() {
1312 let mut option_parameters = HashMap::new();
1313 option_parameters.insert(
1314 "csv".to_string(),
1315 vec![
1316 ArgumentDefinition {
1317 name: "file".to_string(),
1318 arg_type: ArgumentType::Path,
1319 required: true,
1320 description: "Destination CSV file".to_string(),
1321 validation: vec![],
1322 secure: false,
1323 },
1324 ArgumentDefinition {
1325 name: "resolution".to_string(),
1326 arg_type: ArgumentType::Integer,
1327 required: false,
1328 description: "Time-step resolution".to_string(),
1329 validation: vec![],
1330 secure: false,
1331 },
1332 ],
1333 );
1334 option_parameters.insert(
1335 "plot".to_string(),
1336 vec![ArgumentDefinition {
1337 name: "file".to_string(),
1338 arg_type: ArgumentType::Path,
1339 required: true,
1340 description: "Destination image file".to_string(),
1341 validation: vec![],
1342 secure: false,
1343 }],
1344 );
1345 let options = vec![OptionDefinition {
1346 name: "output".to_string(),
1347 short: None,
1348 long: Some("output".to_string()),
1349 option_type: ArgumentType::String,
1350 required: false,
1351 default: None,
1352 description: "Write simulation results in one or more output kinds".to_string(),
1353 choices: vec!["csv".to_string(), "plot".to_string()],
1354 repeatable: true,
1355 option_parameters,
1356 }];
1357
1358 let result = validate_options(&options, "test");
1359 assert!(result.is_ok());
1360 }
1361
1362 fn command(name: &str, aliases: &[&str]) -> CommandDefinition {
1367 CommandDefinition {
1368 name: name.to_string(),
1369 aliases: aliases.iter().map(|a| a.to_string()).collect(),
1370 description: "Test".to_string(),
1371 required: false,
1372 arguments: vec![],
1373 options: vec![],
1374 implementation: format!("{}_handler", name.trim_start_matches(':')),
1375 continue_on_failure: false,
1376 requires_success: false,
1377 }
1378 }
1379
1380 fn directive(
1381 implementation: ReplDirective,
1382 name: &str,
1383 aliases: &[&str],
1384 ) -> DirectiveDefinition {
1385 DirectiveDefinition {
1386 implementation,
1387 name: name.to_string(),
1388 aliases: aliases.iter().map(|a| a.to_string()).collect(),
1389 description: "Custom".to_string(),
1390 }
1391 }
1392
1393 fn config_with_directives(directives: Vec<DirectiveDefinition>) -> CommandsConfig {
1394 let mut config = CommandsConfig::minimal();
1395 config.directives = directives;
1396 config
1397 }
1398
1399 fn invalid_schema(result: Result<()>) -> (String, Option<String>) {
1401 match result {
1402 Err(crate::error::DynamicCliError::Config(ConfigError::InvalidSchema {
1403 reason,
1404 path,
1405 ..
1406 })) => (reason, path),
1407 other => panic!("Expected InvalidSchema error, got {:?}", other),
1408 }
1409 }
1410
1411 #[test]
1412 fn test_validate_config_from_yaml_without_directives() {
1413 let yaml = r#"
1414metadata:
1415 version: "1.0.0"
1416 prompt: "test"
1417commands:
1418 - name: hello
1419 description: "Say hello"
1420 implementation: "hello_handler"
1421"#;
1422 let config: CommandsConfig = serde_yaml::from_str(yaml).unwrap();
1423 assert!(config.directives.is_empty());
1424 assert!(validate_config(&config).is_ok());
1425 }
1426
1427 #[test]
1428 fn test_validate_config_command_names_may_match_directive_names() {
1429 let mut config = CommandsConfig::minimal();
1432 config.commands = vec![command("help", &["h"]), command("quit", &["q"])];
1433 assert!(validate_config(&config).is_ok());
1434 }
1435
1436 #[test]
1437 fn test_validate_config_command_name_with_colon_prefix() {
1438 let mut config = CommandsConfig::minimal();
1439 config.commands = vec![command(":run", &[])];
1440 let (reason, path) = invalid_schema(validate_config(&config));
1441 assert!(reason.contains("':run'"), "{}", reason);
1442 assert!(
1443 reason.contains("reserved for REPL directives"),
1444 "{}",
1445 reason
1446 );
1447 assert_eq!(path.as_deref(), Some("commands[0].name"));
1448 }
1449
1450 #[test]
1451 fn test_validate_config_command_alias_with_colon_prefix() {
1452 let mut config = CommandsConfig::minimal();
1453 config.commands = vec![command("list", &[]), command("run", &["r", ":r"])];
1454 let (reason, path) = invalid_schema(validate_config(&config));
1455 assert!(reason.contains("':r'"), "{}", reason);
1456 assert_eq!(path.as_deref(), Some("commands[1].aliases[1]"));
1457 }
1458
1459 #[test]
1460 fn test_validate_config_command_colon_inside_name_allowed() {
1461 let mut config = CommandsConfig::minimal();
1462 config.commands = vec![command("db:migrate", &[])];
1463 assert!(validate_config(&config).is_ok());
1464 }
1465
1466 #[test]
1467 fn test_validate_directives_valid_overrides() {
1468 let config = config_with_directives(vec![
1469 directive(ReplDirective::Help, "aide", &["a", "?"]),
1470 directive(ReplDirective::Quit, "quitter", &["q"]),
1471 ]);
1472 assert!(validate_config(&config).is_ok());
1473 }
1474
1475 #[test]
1476 fn test_validate_directives_override_may_reuse_its_own_default_alias() {
1477 let config = config_with_directives(vec![directive(ReplDirective::Help, "aide", &["h"])]);
1480 assert!(validate_config(&config).is_ok());
1481 }
1482
1483 #[test]
1484 fn test_validate_directives_implementation_overridden_twice() {
1485 let config = config_with_directives(vec![
1486 directive(ReplDirective::Quit, "quitter", &[]),
1487 directive(ReplDirective::Help, "aide", &[]),
1488 directive(ReplDirective::Quit, "partir", &[]),
1489 ]);
1490 let (reason, path) = invalid_schema(validate_config(&config));
1491 assert!(reason.contains("'repl_quit'"), "{}", reason);
1492 assert!(reason.contains("directives[0]"), "{}", reason);
1493 assert_eq!(path.as_deref(), Some("directives[2].implementation"));
1494 }
1495
1496 #[test]
1497 fn test_validate_directives_empty_name() {
1498 let config = config_with_directives(vec![directive(ReplDirective::Help, " ", &[])]);
1499 let (reason, path) = invalid_schema(validate_config(&config));
1500 assert!(reason.contains("cannot be empty"), "{}", reason);
1501 assert_eq!(path.as_deref(), Some("directives[0].name"));
1502 }
1503
1504 #[test]
1505 fn test_validate_directives_empty_alias() {
1506 let config = config_with_directives(vec![directive(ReplDirective::Load, "load", &[""])]);
1507 let (reason, path) = invalid_schema(validate_config(&config));
1508 assert!(reason.contains("cannot be empty"), "{}", reason);
1509 assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1510 }
1511
1512 #[test]
1513 fn test_validate_directives_name_with_whitespace() {
1514 let config =
1515 config_with_directives(vec![directive(ReplDirective::Exit, "sortir vite", &[])]);
1516 let (reason, path) = invalid_schema(validate_config(&config));
1517 assert!(reason.contains("whitespace"), "{}", reason);
1518 assert_eq!(path.as_deref(), Some("directives[0].name"));
1519 }
1520
1521 #[test]
1522 fn test_validate_directives_alias_with_whitespace() {
1523 let config = config_with_directives(vec![directive(ReplDirective::Exit, "exit", &["e\t"])]);
1524 let (reason, path) = invalid_schema(validate_config(&config));
1525 assert!(reason.contains("whitespace"), "{}", reason);
1526 assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1527 }
1528
1529 #[test]
1530 fn test_validate_directives_name_with_colon_prefix() {
1531 let config = config_with_directives(vec![directive(ReplDirective::Quit, ":quit", &[])]);
1532 let (reason, path) = invalid_schema(validate_config(&config));
1533 assert!(reason.contains("must not start with ':'"), "{}", reason);
1534 assert_eq!(path.as_deref(), Some("directives[0].name"));
1535 }
1536
1537 #[test]
1538 fn test_validate_directives_alias_with_colon_prefix() {
1539 let config = config_with_directives(vec![directive(ReplDirective::Quit, "quit", &[":q"])]);
1540 let (reason, path) = invalid_schema(validate_config(&config));
1541 assert!(reason.contains("must not start with ':'"), "{}", reason);
1542 assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1543 }
1544
1545 #[test]
1546 fn test_validate_directives_override_name_collides_with_default_alias() {
1547 let config = config_with_directives(vec![directive(ReplDirective::Exit, "q", &[])]);
1549 let (reason, path) = invalid_schema(validate_config(&config));
1550 assert!(reason.contains("'q'"), "{}", reason);
1551 assert!(reason.contains("'repl_quit'"), "{}", reason);
1552 assert!(reason.contains("'repl_exit'"), "{}", reason);
1553 assert_eq!(path.as_deref(), Some("directives[0]"));
1554 }
1555
1556 #[test]
1557 fn test_validate_directives_override_alias_collides_with_default_name() {
1558 let config =
1561 config_with_directives(vec![directive(ReplDirective::Help, "help", &["load"])]);
1562 let (reason, path) = invalid_schema(validate_config(&config));
1563 assert!(reason.contains("'load'"), "{}", reason);
1564 assert_eq!(path.as_deref(), Some("directives[0]"));
1565 }
1566
1567 #[test]
1568 fn test_validate_directives_two_overrides_collide() {
1569 let config = config_with_directives(vec![
1570 directive(ReplDirective::Quit, "partir", &[]),
1571 directive(ReplDirective::Exit, "sortir", &["partir"]),
1572 ]);
1573 let (reason, path) = invalid_schema(validate_config(&config));
1574 assert!(reason.contains("'partir'"), "{}", reason);
1575 assert_eq!(path.as_deref(), Some("directives[1]"));
1576 }
1577
1578 #[test]
1579 fn test_validate_directives_name_repeated_as_own_alias() {
1580 let config =
1581 config_with_directives(vec![directive(ReplDirective::Load, "load", &["load"])]);
1582 let (reason, path) = invalid_schema(validate_config(&config));
1583 assert!(reason.contains("used twice by 'repl_load'"), "{}", reason);
1584 assert_eq!(path.as_deref(), Some("directives[0]"));
1585 }
1586}