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