Skip to main content

dynamic_cli/config/
validator.rs

1//! Configuration validation
2//!
3//! This module validates the consistency and correctness of
4//! configuration after it has been loaded and parsed.
5//!
6//! # Validation Levels
7//!
8//! 1. **Structural validation** - Ensures required fields are present
9//! 2. **Semantic validation** - Checks for logical inconsistencies
10//! 3. **Uniqueness validation** - Prevents duplicate names/aliases
11//!
12//! # Example
13//!
14//! ```
15//! use dynamic_cli::config::schema::{CommandsConfig, Metadata};
16//! use dynamic_cli::config::validator::validate_config;
17//!
18//! # let config = CommandsConfig {
19//!       metadata: Metadata {
20//!         version: "1.0.0".to_string(),
21//!         prompt: "test".to_string(),
22//!         prompt_suffix: " >".to_string()
23//!         },
24//!       commands: vec![],
25//!       global_options: vec![]
26//! };
27//! // After loading configuration
28//! validate_config(&config)?;
29//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
30//! ```
31
32use crate::config::schema::{
33    ArgumentDefinition, ArgumentType, CommandDefinition, CommandsConfig, OptionDefinition,
34    ValidationRule,
35};
36use crate::error::{ConfigError, Result};
37use std::collections::{HashMap, HashSet};
38
39/// Validate the entire configuration
40///
41/// Performs comprehensive validation of the configuration structure,
42/// checking for:
43/// - Duplicate command names and aliases
44/// - Valid argument types
45/// - Consistent validation rules
46/// - Option/argument naming conflicts
47///
48/// # Arguments
49///
50/// * `config` - The configuration to validate
51///
52/// # Errors
53///
54/// - [`ConfigError::DuplicateCommand`] if command names/aliases conflict
55/// - [`ConfigError::InvalidSchema`] if structural issues are found
56/// - [`ConfigError::Inconsistency`] if logical inconsistencies are detected
57///
58/// # Example
59///
60/// ```
61/// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
62/// use dynamic_cli::config::validator::validate_config;
63///
64/// # let config = CommandsConfig {
65///       metadata: Metadata {
66///         version: "1.0.0".to_string(),
67///         prompt: "test".to_string(),
68///         prompt_suffix: " >".to_string()
69///         },
70///       commands: vec![],
71///       global_options: vec![]
72/// };
73/// // After loading configuration
74/// validate_config(&config)?;
75/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
76/// ```
77pub fn validate_config(config: &CommandsConfig) -> Result<()> {
78    // Track all command names and aliases to detect duplicates
79    let mut seen_names: HashSet<String> = HashSet::new();
80
81    for (idx, command) in config.commands.iter().enumerate() {
82        // Validate the command itself
83        validate_command(command)?;
84
85        // Check for duplicate command name
86        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        // Check for duplicate aliases
95        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        // Validate that command has a non-empty name
106        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        // Validate that implementation is specified
116        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 global options
127    validate_options(&config.global_options, "global_options")?;
128
129    Ok(())
130}
131
132/// Validate a single command definition
133///
134/// Checks:
135/// - Argument types are valid
136/// - No duplicate argument/option names
137/// - Validation rules are consistent with types
138/// - Required arguments come before optional ones
139///
140/// # Arguments
141///
142/// * `cmd` - The command definition to validate
143///
144/// # Errors
145///
146/// - [`ConfigError::InvalidSchema`] for structural issues
147/// - [`ConfigError::Inconsistency`] for logical problems
148///
149/// # Example
150///
151/// ```
152/// use dynamic_cli::config::{
153///     schema::{CommandDefinition, ArgumentType},
154///     validator::validate_command,
155/// };
156///
157/// let cmd = CommandDefinition {
158///     name: "test".to_string(),
159///     aliases: vec![],
160///     description: "Test command".to_string(),
161///     required: false,
162///     arguments: vec![],
163///     options: vec![],
164///     implementation: "test_handler".to_string(),
165/// };
166///
167/// validate_command(&cmd)?;
168/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
169/// ```
170pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
171    // Validate arguments
172    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
178    validate_options(&cmd.options, &cmd.name)?;
179    validate_option_flags(&cmd.options, &cmd.name)?;
180
181    // Check for name conflicts between arguments and options
182    check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
183
184    Ok(())
185}
186
187/// Validate argument types
188///
189/// Currently, all [`ArgumentType`] variants are valid, but this function
190/// exists for future extensibility and to ensure types are properly defined.
191///
192/// # Arguments
193///
194/// * `args` - List of argument definitions to validate
195///
196/// # Example
197///
198/// ```
199/// use dynamic_cli::config::{
200///     schema::{ArgumentDefinition, ArgumentType},
201///     validator::validate_argument_types,
202/// };
203///
204/// let args = vec![
205///     ArgumentDefinition {
206///         name: "count".to_string(),
207///         arg_type: ArgumentType::Integer,
208///         required: true,
209///         description: "Count".to_string(),
210///         validation: vec![],
211///         secure: false,
212///     }
213/// ];
214///
215/// validate_argument_types(&args)?;
216/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
217/// ```
218pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
219    // Currently all ArgumentType variants are valid
220    // This function exists for future extensibility
221
222    for arg in args {
223        // Validate that the type is properly defined
224        // (In the current implementation, all enum variants are valid)
225        let _ = arg.arg_type;
226    }
227
228    Ok(())
229}
230
231/// Validate that required arguments come before optional ones
232///
233/// This prevents confusing situations where an optional argument
234/// appears before a required one in the command line.
235///
236/// # Arguments
237///
238/// * `args` - List of argument definitions
239/// * `context` - Context string for error messages (command name)
240fn 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
262/// Validate that argument names are unique
263fn 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
289/// Validate that validation rules are consistent with argument types
290fn 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                    // These rules only make sense for Path arguments
296                    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                    // Range rules only make sense for numeric types
310                    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                    // Validate that min <= max if both are specified
324                    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
344/// Validate option definitions
345fn 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        // Validate name is not empty
350        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        // Check for duplicate names
360        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        // Validate that at least one of short or long is specified
370        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        // Validate choices are consistent with default
383        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        // Validate that boolean options don't have choices
399        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
411/// Validate option flags (short and long forms)
412fn 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        // Check short form
418        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        // Check long form
445        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
472/// Check for name conflicts between arguments and options
473fn 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(), // Duplicate!
520                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()], // Duplicate alias!
557                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(), // Empty name!
573            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, // Required after optional!
603                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(), // Duplicate!
626                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 }, // Wrong for integer!
647            ],
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), // min > max!
665            }],
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, // Neither short nor long!
679            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()), // Not in choices!
699            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()), // Duplicate!
723                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()), // Too long!
741            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(), // Same name as argument!
766            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()], // Boolean can't have choices!
826        }];
827
828        let result = validate_options(&options, "test");
829        assert!(result.is_err());
830    }
831}