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