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            }
90            .into());
91        }
92
93        // Check for duplicate aliases
94        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        // Validate that command has a non-empty name
104        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        // Validate that implementation is specified
113        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 global options
123    validate_options(&config.global_options, "global_options")?;
124
125    Ok(())
126}
127
128/// Validate a single command definition
129///
130/// Checks:
131/// - Argument types are valid
132/// - No duplicate argument/option names
133/// - Validation rules are consistent with types
134/// - Required arguments come before optional ones
135///
136/// # Arguments
137///
138/// * `cmd` - The command definition to validate
139///
140/// # Errors
141///
142/// - [`ConfigError::InvalidSchema`] for structural issues
143/// - [`ConfigError::Inconsistency`] for logical problems
144///
145/// # Example
146///
147/// ```
148/// use dynamic_cli::config::{
149///     schema::{CommandDefinition, ArgumentType},
150///     validator::validate_command,
151/// };
152///
153/// let cmd = CommandDefinition {
154///     name: "test".to_string(),
155///     aliases: vec![],
156///     description: "Test command".to_string(),
157///     required: false,
158///     arguments: vec![],
159///     options: vec![],
160///     implementation: "test_handler".to_string(),
161/// };
162///
163/// validate_command(&cmd)?;
164/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
165/// ```
166pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
167    // Validate arguments
168    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
174    validate_options(&cmd.options, &cmd.name)?;
175    validate_option_flags(&cmd.options, &cmd.name)?;
176
177    // Check for name conflicts between arguments and options
178    check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
179
180    Ok(())
181}
182
183/// Validate argument types
184///
185/// Currently, all [`ArgumentType`] variants are valid, but this function
186/// exists for future extensibility and to ensure types are properly defined.
187///
188/// # Arguments
189///
190/// * `args` - List of argument definitions to validate
191///
192/// # Example
193///
194/// ```
195/// use dynamic_cli::config::{
196///     schema::{ArgumentDefinition, ArgumentType},
197///     validator::validate_argument_types,
198/// };
199///
200/// let args = vec![
201///     ArgumentDefinition {
202///         name: "count".to_string(),
203///         arg_type: ArgumentType::Integer,
204///         required: true,
205///         description: "Count".to_string(),
206///         validation: vec![],
207///     }
208/// ];
209///
210/// validate_argument_types(&args)?;
211/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
212/// ```
213pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
214    // Currently all ArgumentType variants are valid
215    // This function exists for future extensibility
216
217    for arg in args {
218        // Validate that the type is properly defined
219        // (In the current implementation, all enum variants are valid)
220        let _ = arg.arg_type;
221    }
222
223    Ok(())
224}
225
226/// Validate that required arguments come before optional ones
227///
228/// This prevents confusing situations where an optional argument
229/// appears before a required one in the command line.
230///
231/// # Arguments
232///
233/// * `args` - List of argument definitions
234/// * `context` - Context string for error messages (command name)
235fn 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
256/// Validate that argument names are unique
257fn 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
281/// Validate that validation rules are consistent with argument types
282fn validate_argument_validation_rules(args: &[ArgumentDefinition], _context: &str) -> Result<()> {
283    for (_idx, arg) in args.iter().enumerate() {
284        for (_rule_idx, rule) in arg.validation.iter().enumerate() {
285            match rule {
286                ValidationRule::MustExist { .. } | ValidationRule::Extensions { .. } => {
287                    // These rules only make sense for Path arguments
288                    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                    // Range rules only make sense for numeric types
301                    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                    // Validate that min <= max if both are specified
314                    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
333/// Validate option definitions
334fn 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        // Validate name is not empty
339        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        // Check for duplicate names
348        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        // Validate that at least one of short or long is specified
357        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        // Validate choices are consistent with default
369        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        // Validate that boolean options don't have choices
384        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
395/// Validate option flags (short and long forms)
396fn 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        // Check short form
402        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        // Check long form
427        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
452/// Check for name conflicts between arguments and options
453fn 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(), // Duplicate!
499                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()], // Duplicate alias!
534                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(), // Empty name!
550            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, // Required after optional!
579                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(), // Duplicate!
600                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 }, // Wrong for integer!
620            ],
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), // min > max!
637            }],
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, // Neither short nor long!
650            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()), // Not in choices!
670            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()), // Duplicate!
694                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()), // Too long!
712            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(), // Same name as argument!
736            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()], // Boolean can't have choices!
795        }];
796
797        let result = validate_options(&options, "test");
798        assert!(result.is_err());
799    }
800}