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