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///     }
212/// ];
213///
214/// validate_argument_types(&args)?;
215/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
216/// ```
217pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
218    // Currently all ArgumentType variants are valid
219    // This function exists for future extensibility
220
221    for arg in args {
222        // Validate that the type is properly defined
223        // (In the current implementation, all enum variants are valid)
224        let _ = arg.arg_type;
225    }
226
227    Ok(())
228}
229
230/// Validate that required arguments come before optional ones
231///
232/// This prevents confusing situations where an optional argument
233/// appears before a required one in the command line.
234///
235/// # Arguments
236///
237/// * `args` - List of argument definitions
238/// * `context` - Context string for error messages (command name)
239fn 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
261/// Validate that argument names are unique
262fn 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
288/// Validate that validation rules are consistent with argument types
289fn 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                    // These rules only make sense for Path arguments
295                    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                    // Range rules only make sense for numeric types
309                    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                    // Validate that min <= max if both are specified
323                    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
343/// Validate option definitions
344fn 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        // Validate name is not empty
349        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        // Check for duplicate names
359        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        // Validate that at least one of short or long is specified
369        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        // Validate choices are consistent with default
382        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        // Validate that boolean options don't have choices
398        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
410/// Validate option flags (short and long forms)
411fn 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        // Check short form
417        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        // Check long form
444        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
471/// Check for name conflicts between arguments and options
472fn 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(), // Duplicate!
519                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()], // Duplicate alias!
556                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(), // Empty name!
572            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, // Required after optional!
601                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(), // Duplicate!
622                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 }, // Wrong for integer!
642            ],
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), // min > max!
659            }],
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, // Neither short nor long!
672            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()), // Not in choices!
692            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()), // Duplicate!
716                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()), // Too long!
734            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(), // Same name as argument!
758            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()], // Boolean can't have choices!
817        }];
818
819        let result = validate_options(&options, "test");
820        assert!(result.is_err());
821    }
822}