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        // --- DD-024: repeatable options and their option_parameters shapes ---
383        if opt.repeatable {
384            // Rule: a repeatable option's absence already means zero
385            // occurrences, so a default value would be ambiguous — reject
386            // it before the generic default/choices check below, so the
387            // repeatable-specific message takes priority.
388            if let Some(ref default) = opt.default {
389                return Err(ConfigError::Inconsistency {
390                    details: format!(
391                        "Repeatable option '{}' cannot have a default value ('{}')",
392                        opt.name, default
393                    ),
394                    suggestion: Some(
395                        "Remove `default` — a repeatable option's absence already \
396                         means zero occurrences, not an implicit one."
397                            .to_string(),
398                    ),
399                }
400                .into());
401            }
402
403            // Rule: choices doubles as the discriminant list, so it must
404            // be non-empty for a repeatable option.
405            if opt.choices.is_empty() {
406                return Err(ConfigError::InvalidSchema {
407                    reason: format!(
408                        "Repeatable option '{}' must declare at least one discriminant in choices",
409                        opt.name
410                    ),
411                    path: Some(format!("{}.options[{}].choices", context, idx)),
412                    suggestion: Some(
413                        "Add `choices: [...]` listing the valid discriminants for \
414                         this repeatable option."
415                            .to_string(),
416                    ),
417                }
418                .into());
419            }
420
421            // Rule: option_parameters keys must equal choices exactly —
422            // no discriminant left undeclared, no orphan key.
423            for discriminant in &opt.choices {
424                if !opt.option_parameters.contains_key(discriminant) {
425                    return Err(ConfigError::InvalidSchema {
426                        reason: format!(
427                            "Discriminant '{}' is declared in choices for repeatable \
428                             option '{}' but has no matching entry in option_parameters",
429                            discriminant, opt.name
430                        ),
431                        path: Some(format!("{}.options[{}].option_parameters", context, idx)),
432                        suggestion: Some(format!(
433                            "Add an `option_parameters.{}` entry describing this \
434                             discriminant's key=value parameters.",
435                            discriminant
436                        )),
437                    }
438                    .into());
439                }
440            }
441            let choices_set: HashSet<&String> = opt.choices.iter().collect();
442            for key in opt.option_parameters.keys() {
443                if !choices_set.contains(key) {
444                    return Err(ConfigError::InvalidSchema {
445                        reason: format!(
446                            "option_parameters key '{}' on option '{}' is not declared in choices",
447                            key, opt.name
448                        ),
449                        path: Some(format!(
450                            "{}.options[{}].option_parameters.{}",
451                            context, idx, key
452                        )),
453                        suggestion: Some(format!(
454                            "Add '{}' to choices, or remove this option_parameters entry.",
455                            key
456                        )),
457                    }
458                    .into());
459                }
460            }
461
462            // Rule: each discriminant's key=value shape reuses the
463            // existing argument validation — names and types, but
464            // explicitly not ordering, which is meaningless for named
465            // key=value pairs rather than positional arguments.
466            for (discriminant, params) in &opt.option_parameters {
467                let sub_context = format!(
468                    "{}.options[{}].option_parameters.{}",
469                    context, idx, discriminant
470                );
471                validate_argument_names(params, &sub_context)?;
472                validate_argument_types(params)?;
473            }
474        } else if !opt.option_parameters.is_empty() {
475            // Rule: option_parameters is meaningless without repeatable.
476            return Err(ConfigError::Inconsistency {
477                details: format!(
478                    "Option '{}' has option_parameters but repeatable is false",
479                    opt.name
480                ),
481                suggestion: Some(
482                    "Set `repeatable: true`, or remove `option_parameters`.".to_string(),
483                ),
484            }
485            .into());
486        }
487
488        // Validate choices are consistent with default
489        if let Some(ref default) = opt.default {
490            if !opt.choices.is_empty() && !opt.choices.contains(default) {
491                return Err(ConfigError::Inconsistency {
492                    details: format!(
493                        "Default value '{}' for option '{}' is not in choices: [{}]",
494                        default,
495                        opt.name,
496                        opt.choices.join(", ")
497                    ),
498                    suggestion: None,
499                }
500                .into());
501            }
502        }
503
504        // Validate that boolean options don't have choices
505        if opt.option_type == ArgumentType::Bool && !opt.choices.is_empty() {
506            return Err(ConfigError::Inconsistency {
507                details: format!("Boolean option '{}' cannot have choices", opt.name),
508                suggestion: None,
509            }
510            .into());
511        }
512    }
513
514    Ok(())
515}
516
517/// Validate option flags (short and long forms)
518fn validate_option_flags(options: &[OptionDefinition], context: &str) -> Result<()> {
519    let mut seen_short: HashMap<String, String> = HashMap::new();
520    let mut seen_long: HashMap<String, String> = HashMap::new();
521
522    for opt in options {
523        // Check short form
524        if let Some(ref short) = opt.short {
525            if short.len() != 1 {
526                return Err(ConfigError::InvalidSchema {
527                    reason: format!(
528                        "Short option '{}' for '{}' must be a single character",
529                        short, opt.name
530                    ),
531                    path: Some(format!("{}.options", context)),
532                    suggestion: None,
533                }
534                .into());
535            }
536
537            if let Some(existing) = seen_short.insert(short.clone(), opt.name.clone()) {
538                return Err(ConfigError::InvalidSchema {
539                    reason: format!(
540                        "Short option '-{}' is used by both '{}' and '{}'",
541                        short, existing, opt.name
542                    ),
543                    path: Some(format!("{}.options", context)),
544                    suggestion: None,
545                }
546                .into());
547            }
548        }
549
550        // Check long form
551        if let Some(ref long) = opt.long {
552            if long.is_empty() {
553                return Err(ConfigError::InvalidSchema {
554                    reason: format!("Long option for '{}' cannot be empty", opt.name),
555                    path: Some(format!("{}.options", context)),
556                    suggestion: None,
557                }
558                .into());
559            }
560
561            if let Some(existing) = seen_long.insert(long.clone(), opt.name.clone()) {
562                return Err(ConfigError::InvalidSchema {
563                    reason: format!(
564                        "Long option '--{}' is used by both '{}' and '{}'",
565                        long, existing, opt.name
566                    ),
567                    path: Some(format!("{}.options", context)),
568                    suggestion: None,
569                }
570                .into());
571            }
572        }
573    }
574
575    Ok(())
576}
577
578/// Check for name conflicts between arguments and options
579fn check_name_conflicts(
580    args: &[ArgumentDefinition],
581    options: &[OptionDefinition],
582    context: &str,
583) -> Result<()> {
584    let arg_names: HashSet<String> = args.iter().map(|a| a.name.clone()).collect();
585
586    for opt in options {
587        if arg_names.contains(&opt.name) {
588            return Err(ConfigError::InvalidSchema {
589                reason: format!("Option '{}' has the same name as an argument", opt.name),
590                path: Some(format!("{}.options", context)),
591                suggestion: None,
592            }
593            .into());
594        }
595    }
596
597    Ok(())
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::config::schema::CommandsConfig;
604    use std::collections::HashMap;
605
606    #[test]
607    fn test_validate_config_empty() {
608        let config = CommandsConfig::minimal();
609        assert!(validate_config(&config).is_ok());
610    }
611
612    #[test]
613    fn test_validate_config_duplicate_command_name() {
614        let mut config = CommandsConfig::minimal();
615        config.commands = vec![
616            CommandDefinition {
617                name: "test".to_string(),
618                aliases: vec![],
619                description: "Test 1".to_string(),
620                required: false,
621                arguments: vec![],
622                options: vec![],
623                implementation: "handler1".to_string(),
624            },
625            CommandDefinition {
626                name: "test".to_string(), // Duplicate!
627                aliases: vec![],
628                description: "Test 2".to_string(),
629                required: false,
630                arguments: vec![],
631                options: vec![],
632                implementation: "handler2".to_string(),
633            },
634        ];
635
636        let result = validate_config(&config);
637        assert!(result.is_err());
638        match result.unwrap_err() {
639            crate::error::DynamicCliError::Config(ConfigError::DuplicateCommand {
640                name, ..
641            }) => {
642                assert_eq!(name, "test");
643            }
644            other => panic!("Expected DuplicateCommand error, got {:?}", other),
645        }
646    }
647
648    #[test]
649    fn test_validate_config_duplicate_alias() {
650        let mut config = CommandsConfig::minimal();
651        config.commands = vec![
652            CommandDefinition {
653                name: "cmd1".to_string(),
654                aliases: vec!["c".to_string()],
655                description: "Command 1".to_string(),
656                required: false,
657                arguments: vec![],
658                options: vec![],
659                implementation: "handler1".to_string(),
660            },
661            CommandDefinition {
662                name: "cmd2".to_string(),
663                aliases: vec!["c".to_string()], // Duplicate alias!
664                description: "Command 2".to_string(),
665                required: false,
666                arguments: vec![],
667                options: vec![],
668                implementation: "handler2".to_string(),
669            },
670        ];
671
672        let result = validate_config(&config);
673        assert!(result.is_err());
674    }
675
676    #[test]
677    fn test_validate_command_empty_name() {
678        let cmd = CommandDefinition {
679            name: "".to_string(), // Empty name!
680            aliases: vec![],
681            description: "Test".to_string(),
682            required: false,
683            arguments: vec![],
684            options: vec![],
685            implementation: "handler".to_string(),
686        };
687
688        let mut config = CommandsConfig::minimal();
689        config.commands = vec![cmd];
690
691        let result = validate_config(&config);
692        assert!(result.is_err());
693    }
694
695    #[test]
696    fn test_validate_argument_ordering() {
697        let args = vec![
698            ArgumentDefinition {
699                name: "optional".to_string(),
700                arg_type: ArgumentType::String,
701                required: false,
702                description: "Optional".to_string(),
703                validation: vec![],
704                secure: false,
705            },
706            ArgumentDefinition {
707                name: "required".to_string(),
708                arg_type: ArgumentType::String,
709                required: true, // Required after optional!
710                description: "Required".to_string(),
711                validation: vec![],
712                secure: false,
713            },
714        ];
715
716        let result = validate_argument_ordering(&args, "test");
717        assert!(result.is_err());
718    }
719
720    #[test]
721    fn test_validate_argument_names_duplicate() {
722        let args = vec![
723            ArgumentDefinition {
724                name: "arg1".to_string(),
725                arg_type: ArgumentType::String,
726                required: true,
727                description: "Arg 1".to_string(),
728                validation: vec![],
729                secure: false,
730            },
731            ArgumentDefinition {
732                name: "arg1".to_string(), // Duplicate!
733                arg_type: ArgumentType::Integer,
734                required: true,
735                description: "Arg 1 again".to_string(),
736                validation: vec![],
737                secure: false,
738            },
739        ];
740
741        let result = validate_argument_names(&args, "test");
742        assert!(result.is_err());
743    }
744
745    #[test]
746    fn test_validate_validation_rules_type_mismatch() {
747        let args = vec![ArgumentDefinition {
748            name: "count".to_string(),
749            arg_type: ArgumentType::Integer,
750            required: true,
751            description: "Count".to_string(),
752            validation: vec![
753                ValidationRule::MustExist { must_exist: true }, // Wrong for integer!
754            ],
755            secure: false,
756        }];
757
758        let result = validate_argument_validation_rules(&args, "test");
759        assert!(result.is_err());
760    }
761
762    #[test]
763    fn test_validate_validation_rules_invalid_range() {
764        let args = vec![ArgumentDefinition {
765            name: "percentage".to_string(),
766            arg_type: ArgumentType::Float,
767            required: true,
768            description: "Percentage".to_string(),
769            validation: vec![ValidationRule::Range {
770                min: Some(100.0),
771                max: Some(0.0), // min > max!
772            }],
773            secure: false,
774        }];
775
776        let result = validate_argument_validation_rules(&args, "test");
777        assert!(result.is_err());
778    }
779
780    #[test]
781    fn test_validate_options_no_flags() {
782        let options = vec![OptionDefinition {
783            name: "opt1".to_string(),
784            short: None,
785            long: None, // Neither short nor long!
786            option_type: ArgumentType::String,
787            required: false,
788            default: None,
789            description: "Option".to_string(),
790            choices: vec![],
791            repeatable: false,
792            option_parameters: HashMap::new(),
793        }];
794
795        let result = validate_options(&options, "test");
796        assert!(result.is_err());
797    }
798
799    #[test]
800    fn test_validate_options_default_not_in_choices() {
801        let options = vec![OptionDefinition {
802            name: "mode".to_string(),
803            short: Some("m".to_string()),
804            long: Some("mode".to_string()),
805            option_type: ArgumentType::String,
806            required: false,
807            default: Some("invalid".to_string()), // Not in choices!
808            description: "Mode".to_string(),
809            choices: vec!["fast".to_string(), "slow".to_string()],
810            repeatable: false,
811            option_parameters: HashMap::new(),
812        }];
813
814        let result = validate_options(&options, "test");
815        assert!(result.is_err());
816    }
817
818    #[test]
819    fn test_validate_option_flags_duplicate_short() {
820        let options = vec![
821            OptionDefinition {
822                name: "opt1".to_string(),
823                short: Some("o".to_string()),
824                long: None,
825                option_type: ArgumentType::String,
826                required: false,
827                default: None,
828                description: "Option 1".to_string(),
829                choices: vec![],
830                repeatable: false,
831                option_parameters: HashMap::new(),
832            },
833            OptionDefinition {
834                name: "opt2".to_string(),
835                short: Some("o".to_string()), // Duplicate!
836                long: None,
837                option_type: ArgumentType::String,
838                required: false,
839                default: None,
840                description: "Option 2".to_string(),
841                choices: vec![],
842                repeatable: false,
843                option_parameters: HashMap::new(),
844            },
845        ];
846
847        let result = validate_option_flags(&options, "test");
848        assert!(result.is_err());
849    }
850
851    #[test]
852    fn test_validate_option_flags_invalid_short() {
853        let options = vec![OptionDefinition {
854            name: "opt1".to_string(),
855            short: Some("opt".to_string()), // Too long!
856            long: None,
857            option_type: ArgumentType::String,
858            required: false,
859            default: None,
860            description: "Option".to_string(),
861            choices: vec![],
862            repeatable: false,
863            option_parameters: HashMap::new(),
864        }];
865
866        let result = validate_option_flags(&options, "test");
867        assert!(result.is_err());
868    }
869
870    #[test]
871    fn test_check_name_conflicts() {
872        let args = vec![ArgumentDefinition {
873            name: "output".to_string(),
874            arg_type: ArgumentType::Path,
875            required: true,
876            description: "Output".to_string(),
877            validation: vec![],
878            secure: false,
879        }];
880
881        let options = vec![OptionDefinition {
882            name: "output".to_string(), // Same name as argument!
883            short: Some("o".to_string()),
884            long: Some("output".to_string()),
885            option_type: ArgumentType::Path,
886            required: false,
887            default: None,
888            description: "Output".to_string(),
889            choices: vec![],
890            repeatable: false,
891            option_parameters: HashMap::new(),
892        }];
893
894        let result = check_name_conflicts(&args, &options, "test");
895        assert!(result.is_err());
896    }
897
898    #[test]
899    fn test_validate_command_valid() {
900        let cmd = CommandDefinition {
901            name: "process".to_string(),
902            aliases: vec!["proc".to_string()],
903            description: "Process data".to_string(),
904            required: false,
905            arguments: vec![ArgumentDefinition {
906                name: "input".to_string(),
907                arg_type: ArgumentType::Path,
908                required: true,
909                description: "Input file".to_string(),
910                validation: vec![
911                    ValidationRule::MustExist { must_exist: true },
912                    ValidationRule::Extensions {
913                        extensions: vec!["csv".to_string()],
914                    },
915                ],
916                secure: false,
917            }],
918            options: vec![OptionDefinition {
919                name: "output".to_string(),
920                short: Some("o".to_string()),
921                long: Some("output".to_string()),
922                option_type: ArgumentType::Path,
923                required: false,
924                default: Some("out.csv".to_string()),
925                description: "Output file".to_string(),
926                choices: vec![],
927                repeatable: false,
928                option_parameters: HashMap::new(),
929            }],
930            implementation: "process_handler".to_string(),
931        };
932
933        assert!(validate_command(&cmd).is_ok());
934    }
935
936    #[test]
937    fn test_validate_boolean_with_choices() {
938        let options = vec![OptionDefinition {
939            name: "flag".to_string(),
940            short: Some("f".to_string()),
941            long: Some("flag".to_string()),
942            option_type: ArgumentType::Bool,
943            required: false,
944            default: None,
945            description: "A flag".to_string(),
946            choices: vec!["true".to_string(), "false".to_string()], // Boolean can't have choices!
947            repeatable: false,
948            option_parameters: HashMap::new(),
949        }];
950
951        let result = validate_options(&options, "test");
952        assert!(result.is_err());
953    }
954
955    // ── DD-024: repeatable options / option_parameters ──────────────────────
956
957    #[test]
958    fn test_validate_repeatable_requires_non_empty_choices() {
959        let options = vec![OptionDefinition {
960            name: "output".to_string(),
961            short: None,
962            long: Some("output".to_string()),
963            option_type: ArgumentType::String,
964            required: false,
965            default: None,
966            description: "Output".to_string(),
967            choices: vec![], // Repeatable but no discriminants!
968            repeatable: true,
969            option_parameters: HashMap::new(),
970        }];
971
972        let result = validate_options(&options, "test");
973        assert!(result.is_err());
974    }
975
976    #[test]
977    fn test_validate_repeatable_missing_option_parameters_entry() {
978        let mut option_parameters = HashMap::new();
979        option_parameters.insert(
980            "csv".to_string(),
981            vec![ArgumentDefinition {
982                name: "file".to_string(),
983                arg_type: ArgumentType::Path,
984                required: true,
985                description: "Destination file".to_string(),
986                validation: vec![],
987                secure: false,
988            }],
989        );
990        // "plot" is in choices but has no option_parameters entry.
991        let options = vec![OptionDefinition {
992            name: "output".to_string(),
993            short: None,
994            long: Some("output".to_string()),
995            option_type: ArgumentType::String,
996            required: false,
997            default: None,
998            description: "Output".to_string(),
999            choices: vec!["csv".to_string(), "plot".to_string()],
1000            repeatable: true,
1001            option_parameters,
1002        }];
1003
1004        let result = validate_options(&options, "test");
1005        assert!(result.is_err());
1006    }
1007
1008    #[test]
1009    fn test_validate_repeatable_orphan_option_parameters_key() {
1010        let mut option_parameters = HashMap::new();
1011        option_parameters.insert(
1012            "csv".to_string(),
1013            vec![ArgumentDefinition {
1014                name: "file".to_string(),
1015                arg_type: ArgumentType::Path,
1016                required: true,
1017                description: "Destination file".to_string(),
1018                validation: vec![],
1019                secure: false,
1020            }],
1021        );
1022        // "json" has an option_parameters entry but is not in choices.
1023        option_parameters.insert(
1024            "json".to_string(),
1025            vec![ArgumentDefinition {
1026                name: "file".to_string(),
1027                arg_type: ArgumentType::Path,
1028                required: true,
1029                description: "Destination file".to_string(),
1030                validation: vec![],
1031                secure: false,
1032            }],
1033        );
1034        let options = vec![OptionDefinition {
1035            name: "output".to_string(),
1036            short: None,
1037            long: Some("output".to_string()),
1038            option_type: ArgumentType::String,
1039            required: false,
1040            default: None,
1041            description: "Output".to_string(),
1042            choices: vec!["csv".to_string()],
1043            repeatable: true,
1044            option_parameters,
1045        }];
1046
1047        let result = validate_options(&options, "test");
1048        assert!(result.is_err());
1049    }
1050
1051    #[test]
1052    fn test_validate_repeatable_option_parameters_reuses_argument_validation() {
1053        let mut option_parameters = HashMap::new();
1054        option_parameters.insert(
1055            "csv".to_string(),
1056            vec![ArgumentDefinition {
1057                name: "".to_string(), // Empty name — invalid per validate_argument_names.
1058                arg_type: ArgumentType::Path,
1059                required: true,
1060                description: "Destination file".to_string(),
1061                validation: vec![],
1062                secure: false,
1063            }],
1064        );
1065        let options = vec![OptionDefinition {
1066            name: "output".to_string(),
1067            short: None,
1068            long: Some("output".to_string()),
1069            option_type: ArgumentType::String,
1070            required: false,
1071            default: None,
1072            description: "Output".to_string(),
1073            choices: vec!["csv".to_string()],
1074            repeatable: true,
1075            option_parameters,
1076        }];
1077
1078        let result = validate_options(&options, "test");
1079        assert!(result.is_err());
1080    }
1081
1082    #[test]
1083    fn test_validate_non_repeatable_with_option_parameters_is_error() {
1084        let mut option_parameters = HashMap::new();
1085        option_parameters.insert(
1086            "csv".to_string(),
1087            vec![ArgumentDefinition {
1088                name: "file".to_string(),
1089                arg_type: ArgumentType::Path,
1090                required: true,
1091                description: "Destination file".to_string(),
1092                validation: vec![],
1093                secure: false,
1094            }],
1095        );
1096        let options = vec![OptionDefinition {
1097            name: "output".to_string(),
1098            short: None,
1099            long: Some("output".to_string()),
1100            option_type: ArgumentType::String,
1101            required: false,
1102            default: None,
1103            description: "Output".to_string(),
1104            choices: vec!["csv".to_string()],
1105            repeatable: false, // option_parameters set despite repeatable: false!
1106            option_parameters,
1107        }];
1108
1109        let result = validate_options(&options, "test");
1110        assert!(result.is_err());
1111    }
1112
1113    #[test]
1114    fn test_validate_repeatable_with_default_is_error() {
1115        let mut option_parameters = HashMap::new();
1116        option_parameters.insert(
1117            "csv".to_string(),
1118            vec![ArgumentDefinition {
1119                name: "file".to_string(),
1120                arg_type: ArgumentType::Path,
1121                required: true,
1122                description: "Destination file".to_string(),
1123                validation: vec![],
1124                secure: false,
1125            }],
1126        );
1127        let options = vec![OptionDefinition {
1128            name: "output".to_string(),
1129            short: None,
1130            long: Some("output".to_string()),
1131            option_type: ArgumentType::String,
1132            required: false,
1133            default: Some("csv".to_string()), // Forbidden when repeatable: true!
1134            description: "Output".to_string(),
1135            choices: vec!["csv".to_string()],
1136            repeatable: true,
1137            option_parameters,
1138        }];
1139
1140        let result = validate_options(&options, "test");
1141        assert!(result.is_err());
1142    }
1143
1144    #[test]
1145    fn test_validate_repeatable_valid_config_passes() {
1146        let mut option_parameters = HashMap::new();
1147        option_parameters.insert(
1148            "csv".to_string(),
1149            vec![
1150                ArgumentDefinition {
1151                    name: "file".to_string(),
1152                    arg_type: ArgumentType::Path,
1153                    required: true,
1154                    description: "Destination CSV file".to_string(),
1155                    validation: vec![],
1156                    secure: false,
1157                },
1158                ArgumentDefinition {
1159                    name: "resolution".to_string(),
1160                    arg_type: ArgumentType::Integer,
1161                    required: false,
1162                    description: "Time-step resolution".to_string(),
1163                    validation: vec![],
1164                    secure: false,
1165                },
1166            ],
1167        );
1168        option_parameters.insert(
1169            "plot".to_string(),
1170            vec![ArgumentDefinition {
1171                name: "file".to_string(),
1172                arg_type: ArgumentType::Path,
1173                required: true,
1174                description: "Destination image file".to_string(),
1175                validation: vec![],
1176                secure: false,
1177            }],
1178        );
1179        let options = vec![OptionDefinition {
1180            name: "output".to_string(),
1181            short: None,
1182            long: Some("output".to_string()),
1183            option_type: ArgumentType::String,
1184            required: false,
1185            default: None,
1186            description: "Write simulation results in one or more output kinds".to_string(),
1187            choices: vec!["csv".to_string(), "plot".to_string()],
1188            repeatable: true,
1189            option_parameters,
1190        }];
1191
1192        let result = validate_options(&options, "test");
1193        assert!(result.is_ok());
1194    }
1195}