1use crate::config::schema::{
33 ArgumentDefinition, ArgumentType, CommandDefinition, CommandsConfig, OptionDefinition,
34 ValidationRule,
35};
36use crate::error::{ConfigError, Result};
37use std::collections::{HashMap, HashSet};
38
39pub fn validate_config(config: &CommandsConfig) -> Result<()> {
78 let mut seen_names: HashSet<String> = HashSet::new();
80
81 for (idx, command) in config.commands.iter().enumerate() {
82 validate_command(command)?;
84
85 if !seen_names.insert(command.name.clone()) {
87 return Err(ConfigError::DuplicateCommand {
88 name: command.name.clone(),
89 suggestion: None,
90 }
91 .into());
92 }
93
94 for alias in &command.aliases {
96 if !seen_names.insert(alias.clone()) {
97 return Err(ConfigError::DuplicateCommand {
98 name: alias.clone(),
99 suggestion: None,
100 }
101 .into());
102 }
103 }
104
105 if command.name.trim().is_empty() {
107 return Err(ConfigError::InvalidSchema {
108 reason: "Command name cannot be empty".to_string(),
109 path: Some(format!("commands[{}].name", idx)),
110 suggestion: None,
111 }
112 .into());
113 }
114
115 if command.implementation.trim().is_empty() {
117 return Err(ConfigError::InvalidSchema {
118 reason: "Command implementation cannot be empty".to_string(),
119 path: Some(format!("commands[{}].implementation", idx)),
120 suggestion: None,
121 }
122 .into());
123 }
124 }
125
126 validate_options(&config.global_options, "global_options")?;
128
129 Ok(())
130}
131
132pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
171 validate_argument_types(&cmd.arguments)?;
173 validate_argument_ordering(&cmd.arguments, &cmd.name)?;
174 validate_argument_names(&cmd.arguments, &cmd.name)?;
175 validate_argument_validation_rules(&cmd.arguments, &cmd.name)?;
176
177 validate_options(&cmd.options, &cmd.name)?;
179 validate_option_flags(&cmd.options, &cmd.name)?;
180
181 check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
183
184 Ok(())
185}
186
187pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
219 for arg in args {
223 let _ = arg.arg_type;
226 }
227
228 Ok(())
229}
230
231fn validate_argument_ordering(args: &[ArgumentDefinition], context: &str) -> Result<()> {
241 let mut seen_optional = false;
242
243 for (idx, arg) in args.iter().enumerate() {
244 if !arg.required {
245 seen_optional = true;
246 } else if seen_optional {
247 return Err(ConfigError::InvalidSchema {
248 reason: format!(
249 "Required argument '{}' cannot come after optional arguments",
250 arg.name
251 ),
252 path: Some(format!("{}.arguments[{}]", context, idx)),
253 suggestion: None,
254 }
255 .into());
256 }
257 }
258
259 Ok(())
260}
261
262fn validate_argument_names(args: &[ArgumentDefinition], context: &str) -> Result<()> {
264 let mut seen_names: HashSet<String> = HashSet::new();
265
266 for (idx, arg) in args.iter().enumerate() {
267 if arg.name.trim().is_empty() {
268 return Err(ConfigError::InvalidSchema {
269 reason: "Argument name cannot be empty".to_string(),
270 path: Some(format!("{}.arguments[{}]", context, idx)),
271 suggestion: None,
272 }
273 .into());
274 }
275
276 if !seen_names.insert(arg.name.clone()) {
277 return Err(ConfigError::InvalidSchema {
278 reason: format!("Duplicate argument name: '{}'", arg.name),
279 path: Some(format!("{}.arguments", context)),
280 suggestion: None,
281 }
282 .into());
283 }
284 }
285
286 Ok(())
287}
288
289fn validate_argument_validation_rules(args: &[ArgumentDefinition], _context: &str) -> Result<()> {
291 for arg in args.iter() {
292 for rule in arg.validation.iter() {
293 match rule {
294 ValidationRule::MustExist { .. } | ValidationRule::Extensions { .. } => {
295 if arg.arg_type != ArgumentType::Path {
297 return Err(ConfigError::Inconsistency {
298 details: format!(
299 "Validation rule 'must_exist' or 'extensions' can only be used with 'path' type, \
300 but argument '{}' has type '{}'",
301 arg.name,
302 arg.arg_type.as_str()
303 ),
304 suggestion: None,
305 }.into());
306 }
307 }
308 ValidationRule::Range { min, max } => {
309 if !matches!(arg.arg_type, ArgumentType::Integer | ArgumentType::Float) {
311 return Err(ConfigError::Inconsistency {
312 details: format!(
313 "Validation rule 'range' can only be used with numeric types, \
314 but argument '{}' has type '{}'",
315 arg.name,
316 arg.arg_type.as_str()
317 ),
318 suggestion: None,
319 }
320 .into());
321 }
322
323 if let (Some(min_val), Some(max_val)) = (min, max) {
325 if min_val > max_val {
326 return Err(ConfigError::Inconsistency {
327 details: format!(
328 "Invalid range for argument '{}': min ({}) > max ({})",
329 arg.name, min_val, max_val
330 ),
331 suggestion: None,
332 }
333 .into());
334 }
335 }
336 }
337 }
338 }
339 }
340
341 Ok(())
342}
343
344fn validate_options(options: &[OptionDefinition], context: &str) -> Result<()> {
346 let mut seen_names: HashSet<String> = HashSet::new();
347
348 for (idx, opt) in options.iter().enumerate() {
349 if opt.name.trim().is_empty() {
351 return Err(ConfigError::InvalidSchema {
352 reason: "Option name cannot be empty".to_string(),
353 path: Some(format!("{}.options[{}]", context, idx)),
354 suggestion: None,
355 }
356 .into());
357 }
358
359 if !seen_names.insert(opt.name.clone()) {
361 return Err(ConfigError::InvalidSchema {
362 reason: format!("Duplicate option name: '{}'", opt.name),
363 path: Some(format!("{}.options", context)),
364 suggestion: None,
365 }
366 .into());
367 }
368
369 if opt.short.is_none() && opt.long.is_none() {
371 return Err(ConfigError::InvalidSchema {
372 reason: format!(
373 "Option '{}' must have at least a short or long form",
374 opt.name
375 ),
376 path: Some(format!("{}.options[{}]", context, idx)),
377 suggestion: None,
378 }
379 .into());
380 }
381
382 if let Some(ref default) = opt.default {
384 if !opt.choices.is_empty() && !opt.choices.contains(default) {
385 return Err(ConfigError::Inconsistency {
386 details: format!(
387 "Default value '{}' for option '{}' is not in choices: [{}]",
388 default,
389 opt.name,
390 opt.choices.join(", ")
391 ),
392 suggestion: None,
393 }
394 .into());
395 }
396 }
397
398 if opt.option_type == ArgumentType::Bool && !opt.choices.is_empty() {
400 return Err(ConfigError::Inconsistency {
401 details: format!("Boolean option '{}' cannot have choices", opt.name),
402 suggestion: None,
403 }
404 .into());
405 }
406 }
407
408 Ok(())
409}
410
411fn validate_option_flags(options: &[OptionDefinition], context: &str) -> Result<()> {
413 let mut seen_short: HashMap<String, String> = HashMap::new();
414 let mut seen_long: HashMap<String, String> = HashMap::new();
415
416 for opt in options {
417 if let Some(ref short) = opt.short {
419 if short.len() != 1 {
420 return Err(ConfigError::InvalidSchema {
421 reason: format!(
422 "Short option '{}' for '{}' must be a single character",
423 short, opt.name
424 ),
425 path: Some(format!("{}.options", context)),
426 suggestion: None,
427 }
428 .into());
429 }
430
431 if let Some(existing) = seen_short.insert(short.clone(), opt.name.clone()) {
432 return Err(ConfigError::InvalidSchema {
433 reason: format!(
434 "Short option '-{}' is used by both '{}' and '{}'",
435 short, existing, opt.name
436 ),
437 path: Some(format!("{}.options", context)),
438 suggestion: None,
439 }
440 .into());
441 }
442 }
443
444 if let Some(ref long) = opt.long {
446 if long.is_empty() {
447 return Err(ConfigError::InvalidSchema {
448 reason: format!("Long option for '{}' cannot be empty", opt.name),
449 path: Some(format!("{}.options", context)),
450 suggestion: None,
451 }
452 .into());
453 }
454
455 if let Some(existing) = seen_long.insert(long.clone(), opt.name.clone()) {
456 return Err(ConfigError::InvalidSchema {
457 reason: format!(
458 "Long option '--{}' is used by both '{}' and '{}'",
459 long, existing, opt.name
460 ),
461 path: Some(format!("{}.options", context)),
462 suggestion: None,
463 }
464 .into());
465 }
466 }
467 }
468
469 Ok(())
470}
471
472fn check_name_conflicts(
474 args: &[ArgumentDefinition],
475 options: &[OptionDefinition],
476 context: &str,
477) -> Result<()> {
478 let arg_names: HashSet<String> = args.iter().map(|a| a.name.clone()).collect();
479
480 for opt in options {
481 if arg_names.contains(&opt.name) {
482 return Err(ConfigError::InvalidSchema {
483 reason: format!("Option '{}' has the same name as an argument", opt.name),
484 path: Some(format!("{}.options", context)),
485 suggestion: None,
486 }
487 .into());
488 }
489 }
490
491 Ok(())
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::config::schema::CommandsConfig;
498
499 #[test]
500 fn test_validate_config_empty() {
501 let config = CommandsConfig::minimal();
502 assert!(validate_config(&config).is_ok());
503 }
504
505 #[test]
506 fn test_validate_config_duplicate_command_name() {
507 let mut config = CommandsConfig::minimal();
508 config.commands = vec![
509 CommandDefinition {
510 name: "test".to_string(),
511 aliases: vec![],
512 description: "Test 1".to_string(),
513 required: false,
514 arguments: vec![],
515 options: vec![],
516 implementation: "handler1".to_string(),
517 },
518 CommandDefinition {
519 name: "test".to_string(), aliases: vec![],
521 description: "Test 2".to_string(),
522 required: false,
523 arguments: vec![],
524 options: vec![],
525 implementation: "handler2".to_string(),
526 },
527 ];
528
529 let result = validate_config(&config);
530 assert!(result.is_err());
531 match result.unwrap_err() {
532 crate::error::DynamicCliError::Config(ConfigError::DuplicateCommand {
533 name, ..
534 }) => {
535 assert_eq!(name, "test");
536 }
537 other => panic!("Expected DuplicateCommand error, got {:?}", other),
538 }
539 }
540
541 #[test]
542 fn test_validate_config_duplicate_alias() {
543 let mut config = CommandsConfig::minimal();
544 config.commands = vec![
545 CommandDefinition {
546 name: "cmd1".to_string(),
547 aliases: vec!["c".to_string()],
548 description: "Command 1".to_string(),
549 required: false,
550 arguments: vec![],
551 options: vec![],
552 implementation: "handler1".to_string(),
553 },
554 CommandDefinition {
555 name: "cmd2".to_string(),
556 aliases: vec!["c".to_string()], description: "Command 2".to_string(),
558 required: false,
559 arguments: vec![],
560 options: vec![],
561 implementation: "handler2".to_string(),
562 },
563 ];
564
565 let result = validate_config(&config);
566 assert!(result.is_err());
567 }
568
569 #[test]
570 fn test_validate_command_empty_name() {
571 let cmd = CommandDefinition {
572 name: "".to_string(), aliases: vec![],
574 description: "Test".to_string(),
575 required: false,
576 arguments: vec![],
577 options: vec![],
578 implementation: "handler".to_string(),
579 };
580
581 let mut config = CommandsConfig::minimal();
582 config.commands = vec![cmd];
583
584 let result = validate_config(&config);
585 assert!(result.is_err());
586 }
587
588 #[test]
589 fn test_validate_argument_ordering() {
590 let args = vec![
591 ArgumentDefinition {
592 name: "optional".to_string(),
593 arg_type: ArgumentType::String,
594 required: false,
595 description: "Optional".to_string(),
596 validation: vec![],
597 secure: false,
598 },
599 ArgumentDefinition {
600 name: "required".to_string(),
601 arg_type: ArgumentType::String,
602 required: true, description: "Required".to_string(),
604 validation: vec![],
605 secure: false,
606 },
607 ];
608
609 let result = validate_argument_ordering(&args, "test");
610 assert!(result.is_err());
611 }
612
613 #[test]
614 fn test_validate_argument_names_duplicate() {
615 let args = vec![
616 ArgumentDefinition {
617 name: "arg1".to_string(),
618 arg_type: ArgumentType::String,
619 required: true,
620 description: "Arg 1".to_string(),
621 validation: vec![],
622 secure: false,
623 },
624 ArgumentDefinition {
625 name: "arg1".to_string(), arg_type: ArgumentType::Integer,
627 required: true,
628 description: "Arg 1 again".to_string(),
629 validation: vec![],
630 secure: false,
631 },
632 ];
633
634 let result = validate_argument_names(&args, "test");
635 assert!(result.is_err());
636 }
637
638 #[test]
639 fn test_validate_validation_rules_type_mismatch() {
640 let args = vec![ArgumentDefinition {
641 name: "count".to_string(),
642 arg_type: ArgumentType::Integer,
643 required: true,
644 description: "Count".to_string(),
645 validation: vec![
646 ValidationRule::MustExist { must_exist: true }, ],
648 secure: false,
649 }];
650
651 let result = validate_argument_validation_rules(&args, "test");
652 assert!(result.is_err());
653 }
654
655 #[test]
656 fn test_validate_validation_rules_invalid_range() {
657 let args = vec![ArgumentDefinition {
658 name: "percentage".to_string(),
659 arg_type: ArgumentType::Float,
660 required: true,
661 description: "Percentage".to_string(),
662 validation: vec![ValidationRule::Range {
663 min: Some(100.0),
664 max: Some(0.0), }],
666 secure: false,
667 }];
668
669 let result = validate_argument_validation_rules(&args, "test");
670 assert!(result.is_err());
671 }
672
673 #[test]
674 fn test_validate_options_no_flags() {
675 let options = vec![OptionDefinition {
676 name: "opt1".to_string(),
677 short: None,
678 long: None, option_type: ArgumentType::String,
680 required: false,
681 default: None,
682 description: "Option".to_string(),
683 choices: vec![],
684 }];
685
686 let result = validate_options(&options, "test");
687 assert!(result.is_err());
688 }
689
690 #[test]
691 fn test_validate_options_default_not_in_choices() {
692 let options = vec![OptionDefinition {
693 name: "mode".to_string(),
694 short: Some("m".to_string()),
695 long: Some("mode".to_string()),
696 option_type: ArgumentType::String,
697 required: false,
698 default: Some("invalid".to_string()), description: "Mode".to_string(),
700 choices: vec!["fast".to_string(), "slow".to_string()],
701 }];
702
703 let result = validate_options(&options, "test");
704 assert!(result.is_err());
705 }
706
707 #[test]
708 fn test_validate_option_flags_duplicate_short() {
709 let options = vec![
710 OptionDefinition {
711 name: "opt1".to_string(),
712 short: Some("o".to_string()),
713 long: None,
714 option_type: ArgumentType::String,
715 required: false,
716 default: None,
717 description: "Option 1".to_string(),
718 choices: vec![],
719 },
720 OptionDefinition {
721 name: "opt2".to_string(),
722 short: Some("o".to_string()), long: None,
724 option_type: ArgumentType::String,
725 required: false,
726 default: None,
727 description: "Option 2".to_string(),
728 choices: vec![],
729 },
730 ];
731
732 let result = validate_option_flags(&options, "test");
733 assert!(result.is_err());
734 }
735
736 #[test]
737 fn test_validate_option_flags_invalid_short() {
738 let options = vec![OptionDefinition {
739 name: "opt1".to_string(),
740 short: Some("opt".to_string()), long: None,
742 option_type: ArgumentType::String,
743 required: false,
744 default: None,
745 description: "Option".to_string(),
746 choices: vec![],
747 }];
748
749 let result = validate_option_flags(&options, "test");
750 assert!(result.is_err());
751 }
752
753 #[test]
754 fn test_check_name_conflicts() {
755 let args = vec![ArgumentDefinition {
756 name: "output".to_string(),
757 arg_type: ArgumentType::Path,
758 required: true,
759 description: "Output".to_string(),
760 validation: vec![],
761 secure: false,
762 }];
763
764 let options = vec![OptionDefinition {
765 name: "output".to_string(), short: Some("o".to_string()),
767 long: Some("output".to_string()),
768 option_type: ArgumentType::Path,
769 required: false,
770 default: None,
771 description: "Output".to_string(),
772 choices: vec![],
773 }];
774
775 let result = check_name_conflicts(&args, &options, "test");
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn test_validate_command_valid() {
781 let cmd = CommandDefinition {
782 name: "process".to_string(),
783 aliases: vec!["proc".to_string()],
784 description: "Process data".to_string(),
785 required: false,
786 arguments: vec![ArgumentDefinition {
787 name: "input".to_string(),
788 arg_type: ArgumentType::Path,
789 required: true,
790 description: "Input file".to_string(),
791 validation: vec![
792 ValidationRule::MustExist { must_exist: true },
793 ValidationRule::Extensions {
794 extensions: vec!["csv".to_string()],
795 },
796 ],
797 secure: false,
798 }],
799 options: vec![OptionDefinition {
800 name: "output".to_string(),
801 short: Some("o".to_string()),
802 long: Some("output".to_string()),
803 option_type: ArgumentType::Path,
804 required: false,
805 default: Some("out.csv".to_string()),
806 description: "Output file".to_string(),
807 choices: vec![],
808 }],
809 implementation: "process_handler".to_string(),
810 };
811
812 assert!(validate_command(&cmd).is_ok());
813 }
814
815 #[test]
816 fn test_validate_boolean_with_choices() {
817 let options = vec![OptionDefinition {
818 name: "flag".to_string(),
819 short: Some("f".to_string()),
820 long: Some("flag".to_string()),
821 option_type: ArgumentType::Bool,
822 required: false,
823 default: None,
824 description: "A flag".to_string(),
825 choices: vec!["true".to_string(), "false".to_string()], }];
827
828 let result = validate_options(&options, "test");
829 assert!(result.is_err());
830 }
831}