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//!       directives: vec![],
27//! };
28//! // After loading configuration
29//! validate_config(&config)?;
30//! # Ok::<(), dynamic_cli::error::DynamicCliError>(())
31//! ```
32
33use crate::config::directive::{
34    effective_directives, DirectiveDefinition, ReplDirective, DIRECTIVE_PREFIX,
35};
36use crate::config::schema::{
37    ArgumentDefinition, ArgumentType, CommandDefinition, CommandsConfig, OptionDefinition,
38    ValidationRule,
39};
40use crate::error::{ConfigError, Result};
41use std::collections::{HashMap, HashSet};
42
43/// Validate the entire configuration
44///
45/// Performs comprehensive validation of the configuration structure,
46/// checking for:
47/// - Duplicate command names and aliases
48/// - Valid argument types
49/// - Consistent validation rules
50/// - Option/argument naming conflicts
51/// - Command names and aliases starting with `:`, which is reserved for
52///   REPL directives
53/// - REPL directive overrides (see [`crate::config::directive`]): each
54///   directive overridden at most once; names and aliases non-empty,
55///   without whitespace or leading `:`; names and aliases unique across
56///   the directive table after merging with the defaults
57///
58/// # Arguments
59///
60/// * `config` - The configuration to validate
61///
62/// # Errors
63///
64/// - [`ConfigError::DuplicateCommand`] if command names/aliases conflict
65/// - [`ConfigError::InvalidSchema`] if structural issues are found,
66///   including every invalid directive override
67/// - [`ConfigError::Inconsistency`] if logical inconsistencies are detected
68///
69/// # Example
70///
71/// ```
72/// use dynamic_cli::config::schema::{CommandsConfig, Metadata};
73/// use dynamic_cli::config::validator::validate_config;
74///
75/// # let config = CommandsConfig {
76///       metadata: Metadata {
77///         version: "1.0.0".to_string(),
78///         prompt: "test".to_string(),
79///         prompt_suffix: " >".to_string()
80///         },
81///       commands: vec![],
82///       global_options: vec![],
83///       directives: vec![],
84/// };
85/// // After loading configuration
86/// validate_config(&config)?;
87/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
88/// ```
89pub fn validate_config(config: &CommandsConfig) -> Result<()> {
90    // Track all command names and aliases to detect duplicates
91    let mut seen_names: HashSet<String> = HashSet::new();
92
93    for (idx, command) in config.commands.iter().enumerate() {
94        // Validate the command itself
95        validate_command(command)?;
96
97        // Check for duplicate command name
98        if !seen_names.insert(command.name.clone()) {
99            return Err(ConfigError::DuplicateCommand {
100                name: command.name.clone(),
101                suggestion: None,
102            }
103            .into());
104        }
105
106        // Check for duplicate aliases
107        for alias in &command.aliases {
108            if !seen_names.insert(alias.clone()) {
109                return Err(ConfigError::DuplicateCommand {
110                    name: alias.clone(),
111                    suggestion: None,
112                }
113                .into());
114            }
115        }
116
117        // Validate that command has a non-empty name
118        if command.name.trim().is_empty() {
119            return Err(ConfigError::InvalidSchema {
120                reason: "Command name cannot be empty".to_string(),
121                path: Some(format!("commands[{}].name", idx)),
122                suggestion: None,
123            }
124            .into());
125        }
126
127        // DA-030 (#92): the `:` prefix belongs to REPL directives
128        if command.name.starts_with(DIRECTIVE_PREFIX) {
129            return Err(reserved_prefix_error(
130                &command.name,
131                format!("commands[{}].name", idx),
132            ));
133        }
134        for (alias_idx, alias) in command.aliases.iter().enumerate() {
135            if alias.starts_with(DIRECTIVE_PREFIX) {
136                return Err(reserved_prefix_error(
137                    alias,
138                    format!("commands[{}].aliases[{}]", idx, alias_idx),
139                ));
140            }
141        }
142
143        // Validate that implementation is specified
144        if command.implementation.trim().is_empty() {
145            return Err(ConfigError::InvalidSchema {
146                reason: "Command implementation cannot be empty".to_string(),
147                path: Some(format!("commands[{}].implementation", idx)),
148                suggestion: None,
149            }
150            .into());
151        }
152    }
153
154    // Validate global options
155    validate_options(&config.global_options, "global_options")?;
156
157    // Validate REPL directive overrides
158    validate_directives(&config.directives)?;
159
160    Ok(())
161}
162
163/// Error for a command name or alias using the directive prefix
164fn reserved_prefix_error(name: &str, path: String) -> crate::error::DynamicCliError {
165    ConfigError::InvalidSchema {
166        reason: format!(
167            "Command name or alias '{}' starts with '{}', which is reserved for REPL directives",
168            name, DIRECTIVE_PREFIX
169        ),
170        path: Some(path),
171        suggestion: Some(format!(
172            "Remove the leading '{}' from '{}'.",
173            DIRECTIVE_PREFIX, name
174        )),
175    }
176    .into()
177}
178
179/// Validate the `directives:` overrides
180///
181/// Checks, in order:
182/// 1. each directive is overridden at most once;
183/// 2. every overriding name and alias is non-empty, contains no
184///    whitespace and does not start with `:`;
185/// 3. names and aliases are unique across the effective directive table
186///    (defaults merged with overrides), so an override cannot reuse a name
187///    or alias that another directive keeps by default.
188///
189/// Directive names are not compared with command names: a directive is
190/// always typed with its `:` prefix, so the two cannot collide.
191fn validate_directives(overrides: &[DirectiveDefinition]) -> Result<()> {
192    // DA-030 (#92): overrides are keyed by `implementation`
193    let mut overridden: HashMap<ReplDirective, usize> = HashMap::new();
194    for (idx, entry) in overrides.iter().enumerate() {
195        if let Some(first) = overridden.insert(entry.implementation, idx) {
196            return Err(ConfigError::InvalidSchema {
197                reason: format!(
198                    "Directive '{}' is overridden more than once (also at directives[{}])",
199                    entry.implementation.as_str(),
200                    first
201                ),
202                path: Some(format!("directives[{}].implementation", idx)),
203                suggestion: Some(format!(
204                    "Merge the entries for '{}' into a single one.",
205                    entry.implementation.as_str()
206                )),
207            }
208            .into());
209        }
210
211        validate_directive_token(&entry.name, format!("directives[{}].name", idx))?;
212        for (alias_idx, alias) in entry.aliases.iter().enumerate() {
213            validate_directive_token(alias, format!("directives[{}].aliases[{}]", idx, alias_idx))?;
214        }
215    }
216
217    // Defaults never collide among themselves, so any collision involves at
218    // least one override; the reported path points at it.
219    let mut owners: HashMap<&str, ReplDirective> = HashMap::new();
220    let table = effective_directives(overrides);
221    for def in &table {
222        let tokens =
223            std::iter::once(def.name.as_str()).chain(def.aliases.iter().map(String::as_str));
224        for token in tokens {
225            if let Some(owner) = owners.insert(token, def.implementation) {
226                let culprit = overridden
227                    .get(&def.implementation)
228                    .or_else(|| overridden.get(&owner))
229                    .copied();
230                let detail = if owner == def.implementation {
231                    format!(
232                        "Directive name or alias '{}' is used twice by '{}'",
233                        token,
234                        owner.as_str()
235                    )
236                } else {
237                    format!(
238                        "Directive name or alias '{}' is used by both '{}' and '{}'",
239                        token,
240                        owner.as_str(),
241                        def.implementation.as_str()
242                    )
243                };
244                return Err(ConfigError::InvalidSchema {
245                    reason: detail,
246                    path: culprit.map(|idx| format!("directives[{}]", idx)),
247                    suggestion: Some(
248                        "Give each directive its own names; an override also replaces \
249                         the default aliases of its directive."
250                            .to_string(),
251                    ),
252                }
253                .into());
254            }
255        }
256    }
257
258    Ok(())
259}
260
261/// Validate one directive name or alias
262fn validate_directive_token(token: &str, path: String) -> Result<()> {
263    let reason = if token.trim().is_empty() {
264        "Directive name or alias cannot be empty".to_string()
265    } else if token.chars().any(char::is_whitespace) {
266        format!("Directive name or alias '{}' contains whitespace", token)
267    } else if token.starts_with(DIRECTIVE_PREFIX) {
268        format!(
269            "Directive name or alias '{}' must not start with '{}'; the REPL adds it",
270            token, DIRECTIVE_PREFIX
271        )
272    } else {
273        return Ok(());
274    };
275
276    Err(ConfigError::InvalidSchema {
277        reason,
278        path: Some(path),
279        suggestion: None,
280    }
281    .into())
282}
283
284/// Validate a single command definition
285///
286/// Checks:
287/// - Argument types are valid
288/// - No duplicate argument/option names
289/// - Validation rules are consistent with types
290/// - Required arguments come before optional ones
291///
292/// # Arguments
293///
294/// * `cmd` - The command definition to validate
295///
296/// # Errors
297///
298/// - [`ConfigError::InvalidSchema`] for structural issues
299/// - [`ConfigError::Inconsistency`] for logical problems
300///
301/// # Example
302///
303/// ```
304/// use dynamic_cli::config::{
305///     schema::{CommandDefinition, ArgumentType},
306///     validator::validate_command,
307/// };
308///
309/// let cmd = CommandDefinition {
310///     name: "test".to_string(),
311///     aliases: vec![],
312///     description: "Test command".to_string(),
313///     required: false,
314///     arguments: vec![],
315///     options: vec![],
316///     implementation: "test_handler".to_string(),
317///     continue_on_failure: false,
318///     requires_success: false,
319/// };
320///
321/// validate_command(&cmd)?;
322/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
323/// ```
324pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
325    // Validate arguments
326    validate_argument_types(&cmd.arguments)?;
327    validate_argument_ordering(&cmd.arguments, &cmd.name)?;
328    validate_argument_names(&cmd.arguments, &cmd.name)?;
329    validate_argument_validation_rules(&cmd.arguments, &cmd.name)?;
330
331    // Validate options
332    validate_options(&cmd.options, &cmd.name)?;
333    validate_option_flags(&cmd.options, &cmd.name)?;
334
335    // Check for name conflicts between arguments and options
336    check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
337
338    Ok(())
339}
340
341/// Validate argument types
342///
343/// Currently, all [`ArgumentType`] variants are valid, but this function
344/// exists for future extensibility and to ensure types are properly defined.
345///
346/// # Arguments
347///
348/// * `args` - List of argument definitions to validate
349///
350/// # Example
351///
352/// ```
353/// use dynamic_cli::config::{
354///     schema::{ArgumentDefinition, ArgumentType},
355///     validator::validate_argument_types,
356/// };
357///
358/// let args = vec![
359///     ArgumentDefinition {
360///         name: "count".to_string(),
361///         arg_type: ArgumentType::Integer,
362///         required: true,
363///         description: "Count".to_string(),
364///         validation: vec![],
365///         secure: false,
366///     }
367/// ];
368///
369/// validate_argument_types(&args)?;
370/// # Ok::<(), dynamic_cli::error::DynamicCliError>(())
371/// ```
372pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
373    // Currently all ArgumentType variants are valid
374    // This function exists for future extensibility
375
376    for arg in args {
377        // Validate that the type is properly defined
378        // (In the current implementation, all enum variants are valid)
379        let _ = arg.arg_type;
380    }
381
382    Ok(())
383}
384
385/// Validate that required arguments come before optional ones
386///
387/// This prevents confusing situations where an optional argument
388/// appears before a required one in the command line.
389///
390/// # Arguments
391///
392/// * `args` - List of argument definitions
393/// * `context` - Context string for error messages (command name)
394fn validate_argument_ordering(args: &[ArgumentDefinition], context: &str) -> Result<()> {
395    let mut seen_optional = false;
396
397    for (idx, arg) in args.iter().enumerate() {
398        if !arg.required {
399            seen_optional = true;
400        } else if seen_optional {
401            return Err(ConfigError::InvalidSchema {
402                reason: format!(
403                    "Required argument '{}' cannot come after optional arguments",
404                    arg.name
405                ),
406                path: Some(format!("{}.arguments[{}]", context, idx)),
407                suggestion: None,
408            }
409            .into());
410        }
411    }
412
413    Ok(())
414}
415
416/// Validate that argument names are unique
417fn validate_argument_names(args: &[ArgumentDefinition], context: &str) -> Result<()> {
418    let mut seen_names: HashSet<String> = HashSet::new();
419
420    for (idx, arg) in args.iter().enumerate() {
421        if arg.name.trim().is_empty() {
422            return Err(ConfigError::InvalidSchema {
423                reason: "Argument name cannot be empty".to_string(),
424                path: Some(format!("{}.arguments[{}]", context, idx)),
425                suggestion: None,
426            }
427            .into());
428        }
429
430        if !seen_names.insert(arg.name.clone()) {
431            return Err(ConfigError::InvalidSchema {
432                reason: format!("Duplicate argument name: '{}'", arg.name),
433                path: Some(format!("{}.arguments", context)),
434                suggestion: None,
435            }
436            .into());
437        }
438    }
439
440    Ok(())
441}
442
443/// Validate that validation rules are consistent with argument types
444fn validate_argument_validation_rules(args: &[ArgumentDefinition], _context: &str) -> Result<()> {
445    for arg in args.iter() {
446        for rule in arg.validation.iter() {
447            match rule {
448                ValidationRule::MustExist { .. } | ValidationRule::Extensions { .. } => {
449                    // These rules only make sense for Path arguments
450                    if arg.arg_type != ArgumentType::Path {
451                        return Err(ConfigError::Inconsistency {
452                            details: format!(
453                                "Validation rule 'must_exist' or 'extensions' can only be used with 'path' type, \
454                                but argument '{}' has type '{}'",
455                                arg.name,
456                                arg.arg_type.as_str()
457                            ),
458                            suggestion: None,
459                        }.into());
460                    }
461                }
462                ValidationRule::Range { min, max } => {
463                    // Range rules only make sense for numeric types
464                    if !matches!(arg.arg_type, ArgumentType::Integer | ArgumentType::Float) {
465                        return Err(ConfigError::Inconsistency {
466                            details: format!(
467                                "Validation rule 'range' can only be used with numeric types, \
468                                but argument '{}' has type '{}'",
469                                arg.name,
470                                arg.arg_type.as_str()
471                            ),
472                            suggestion: None,
473                        }
474                        .into());
475                    }
476
477                    // Validate that min <= max if both are specified
478                    if let (Some(min_val), Some(max_val)) = (min, max) {
479                        if min_val > max_val {
480                            return Err(ConfigError::Inconsistency {
481                                details: format!(
482                                    "Invalid range for argument '{}': min ({}) > max ({})",
483                                    arg.name, min_val, max_val
484                                ),
485                                suggestion: None,
486                            }
487                            .into());
488                        }
489                    }
490                }
491            }
492        }
493    }
494
495    Ok(())
496}
497
498/// Validate option definitions
499fn validate_options(options: &[OptionDefinition], context: &str) -> Result<()> {
500    let mut seen_names: HashSet<String> = HashSet::new();
501
502    for (idx, opt) in options.iter().enumerate() {
503        // Validate name is not empty
504        if opt.name.trim().is_empty() {
505            return Err(ConfigError::InvalidSchema {
506                reason: "Option name cannot be empty".to_string(),
507                path: Some(format!("{}.options[{}]", context, idx)),
508                suggestion: None,
509            }
510            .into());
511        }
512
513        // Check for duplicate names
514        if !seen_names.insert(opt.name.clone()) {
515            return Err(ConfigError::InvalidSchema {
516                reason: format!("Duplicate option name: '{}'", opt.name),
517                path: Some(format!("{}.options", context)),
518                suggestion: None,
519            }
520            .into());
521        }
522
523        // Validate that at least one of short or long is specified
524        if opt.short.is_none() && opt.long.is_none() {
525            return Err(ConfigError::InvalidSchema {
526                reason: format!(
527                    "Option '{}' must have at least a short or long form",
528                    opt.name
529                ),
530                path: Some(format!("{}.options[{}]", context, idx)),
531                suggestion: None,
532            }
533            .into());
534        }
535
536        // --- DD-024 (#21): repeatable options and their option_parameters shapes ---
537        if opt.repeatable {
538            // Rule: a repeatable option's absence already means zero
539            // occurrences, so a default value would be ambiguous — reject
540            // it before the generic default/choices check below, so the
541            // repeatable-specific message takes priority.
542            if let Some(ref default) = opt.default {
543                return Err(ConfigError::Inconsistency {
544                    details: format!(
545                        "Repeatable option '{}' cannot have a default value ('{}')",
546                        opt.name, default
547                    ),
548                    suggestion: Some(
549                        "Remove `default` — a repeatable option's absence already \
550                         means zero occurrences, not an implicit one."
551                            .to_string(),
552                    ),
553                }
554                .into());
555            }
556
557            // Rule: choices doubles as the discriminant list, so it must
558            // be non-empty for a repeatable option.
559            if opt.choices.is_empty() {
560                return Err(ConfigError::InvalidSchema {
561                    reason: format!(
562                        "Repeatable option '{}' must declare at least one discriminant in choices",
563                        opt.name
564                    ),
565                    path: Some(format!("{}.options[{}].choices", context, idx)),
566                    suggestion: Some(
567                        "Add `choices: [...]` listing the valid discriminants for \
568                         this repeatable option."
569                            .to_string(),
570                    ),
571                }
572                .into());
573            }
574
575            // Rule: option_parameters keys must equal choices exactly —
576            // no discriminant left undeclared, no orphan key.
577            for discriminant in &opt.choices {
578                if !opt.option_parameters.contains_key(discriminant) {
579                    return Err(ConfigError::InvalidSchema {
580                        reason: format!(
581                            "Discriminant '{}' is declared in choices for repeatable \
582                             option '{}' but has no matching entry in option_parameters",
583                            discriminant, opt.name
584                        ),
585                        path: Some(format!("{}.options[{}].option_parameters", context, idx)),
586                        suggestion: Some(format!(
587                            "Add an `option_parameters.{}` entry describing this \
588                             discriminant's key=value parameters.",
589                            discriminant
590                        )),
591                    }
592                    .into());
593                }
594            }
595            let choices_set: HashSet<&String> = opt.choices.iter().collect();
596            for key in opt.option_parameters.keys() {
597                if !choices_set.contains(key) {
598                    return Err(ConfigError::InvalidSchema {
599                        reason: format!(
600                            "option_parameters key '{}' on option '{}' is not declared in choices",
601                            key, opt.name
602                        ),
603                        path: Some(format!(
604                            "{}.options[{}].option_parameters.{}",
605                            context, idx, key
606                        )),
607                        suggestion: Some(format!(
608                            "Add '{}' to choices, or remove this option_parameters entry.",
609                            key
610                        )),
611                    }
612                    .into());
613                }
614            }
615
616            // Rule: each discriminant's key=value shape reuses the
617            // existing argument validation — names and types, but
618            // explicitly not ordering, which is meaningless for named
619            // key=value pairs rather than positional arguments.
620            for (discriminant, params) in &opt.option_parameters {
621                let sub_context = format!(
622                    "{}.options[{}].option_parameters.{}",
623                    context, idx, discriminant
624                );
625                validate_argument_names(params, &sub_context)?;
626                validate_argument_types(params)?;
627            }
628        } else if !opt.option_parameters.is_empty() {
629            // Rule: option_parameters is meaningless without repeatable.
630            return Err(ConfigError::Inconsistency {
631                details: format!(
632                    "Option '{}' has option_parameters but repeatable is false",
633                    opt.name
634                ),
635                suggestion: Some(
636                    "Set `repeatable: true`, or remove `option_parameters`.".to_string(),
637                ),
638            }
639            .into());
640        }
641
642        // Validate choices are consistent with default
643        if let Some(ref default) = opt.default {
644            if !opt.choices.is_empty() && !opt.choices.contains(default) {
645                return Err(ConfigError::Inconsistency {
646                    details: format!(
647                        "Default value '{}' for option '{}' is not in choices: [{}]",
648                        default,
649                        opt.name,
650                        opt.choices.join(", ")
651                    ),
652                    suggestion: None,
653                }
654                .into());
655            }
656        }
657
658        // Validate that boolean options don't have choices
659        if opt.option_type == ArgumentType::Bool && !opt.choices.is_empty() {
660            return Err(ConfigError::Inconsistency {
661                details: format!("Boolean option '{}' cannot have choices", opt.name),
662                suggestion: None,
663            }
664            .into());
665        }
666    }
667
668    Ok(())
669}
670
671/// Validate option flags (short and long forms)
672fn validate_option_flags(options: &[OptionDefinition], context: &str) -> Result<()> {
673    let mut seen_short: HashMap<String, String> = HashMap::new();
674    let mut seen_long: HashMap<String, String> = HashMap::new();
675
676    for opt in options {
677        // Check short form
678        if let Some(ref short) = opt.short {
679            if short.len() != 1 {
680                return Err(ConfigError::InvalidSchema {
681                    reason: format!(
682                        "Short option '{}' for '{}' must be a single character",
683                        short, opt.name
684                    ),
685                    path: Some(format!("{}.options", context)),
686                    suggestion: None,
687                }
688                .into());
689            }
690
691            if let Some(existing) = seen_short.insert(short.clone(), opt.name.clone()) {
692                return Err(ConfigError::InvalidSchema {
693                    reason: format!(
694                        "Short option '-{}' is used by both '{}' and '{}'",
695                        short, existing, opt.name
696                    ),
697                    path: Some(format!("{}.options", context)),
698                    suggestion: None,
699                }
700                .into());
701            }
702        }
703
704        // Check long form
705        if let Some(ref long) = opt.long {
706            if long.is_empty() {
707                return Err(ConfigError::InvalidSchema {
708                    reason: format!("Long option for '{}' cannot be empty", opt.name),
709                    path: Some(format!("{}.options", context)),
710                    suggestion: None,
711                }
712                .into());
713            }
714
715            if let Some(existing) = seen_long.insert(long.clone(), opt.name.clone()) {
716                return Err(ConfigError::InvalidSchema {
717                    reason: format!(
718                        "Long option '--{}' is used by both '{}' and '{}'",
719                        long, existing, opt.name
720                    ),
721                    path: Some(format!("{}.options", context)),
722                    suggestion: None,
723                }
724                .into());
725            }
726        }
727    }
728
729    Ok(())
730}
731
732/// Check for name conflicts between arguments and options
733fn check_name_conflicts(
734    args: &[ArgumentDefinition],
735    options: &[OptionDefinition],
736    context: &str,
737) -> Result<()> {
738    let arg_names: HashSet<String> = args.iter().map(|a| a.name.clone()).collect();
739
740    for opt in options {
741        if arg_names.contains(&opt.name) {
742            return Err(ConfigError::InvalidSchema {
743                reason: format!("Option '{}' has the same name as an argument", opt.name),
744                path: Some(format!("{}.options", context)),
745                suggestion: None,
746            }
747            .into());
748        }
749    }
750
751    Ok(())
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use crate::config::schema::CommandsConfig;
758    use std::collections::HashMap;
759
760    #[test]
761    fn test_validate_config_empty() {
762        let config = CommandsConfig::minimal();
763        assert!(validate_config(&config).is_ok());
764    }
765
766    #[test]
767    fn test_validate_config_duplicate_command_name() {
768        let mut config = CommandsConfig::minimal();
769        config.commands = vec![
770            CommandDefinition {
771                name: "test".to_string(),
772                aliases: vec![],
773                description: "Test 1".to_string(),
774                required: false,
775                arguments: vec![],
776                options: vec![],
777                implementation: "handler1".to_string(),
778                continue_on_failure: false,
779                requires_success: false,
780            },
781            CommandDefinition {
782                name: "test".to_string(), // Duplicate!
783                aliases: vec![],
784                description: "Test 2".to_string(),
785                required: false,
786                arguments: vec![],
787                options: vec![],
788                implementation: "handler2".to_string(),
789                continue_on_failure: false,
790                requires_success: false,
791            },
792        ];
793
794        let result = validate_config(&config);
795        assert!(result.is_err());
796        match result.unwrap_err() {
797            crate::error::DynamicCliError::Config(ConfigError::DuplicateCommand {
798                name, ..
799            }) => {
800                assert_eq!(name, "test");
801            }
802            other => panic!("Expected DuplicateCommand error, got {:?}", other),
803        }
804    }
805
806    #[test]
807    fn test_validate_config_duplicate_alias() {
808        let mut config = CommandsConfig::minimal();
809        config.commands = vec![
810            CommandDefinition {
811                name: "cmd1".to_string(),
812                aliases: vec!["c".to_string()],
813                description: "Command 1".to_string(),
814                required: false,
815                arguments: vec![],
816                options: vec![],
817                implementation: "handler1".to_string(),
818                continue_on_failure: false,
819                requires_success: false,
820            },
821            CommandDefinition {
822                name: "cmd2".to_string(),
823                aliases: vec!["c".to_string()], // Duplicate alias!
824                description: "Command 2".to_string(),
825                required: false,
826                arguments: vec![],
827                options: vec![],
828                implementation: "handler2".to_string(),
829                continue_on_failure: false,
830                requires_success: false,
831            },
832        ];
833
834        let result = validate_config(&config);
835        assert!(result.is_err());
836    }
837
838    #[test]
839    fn test_validate_command_empty_name() {
840        let cmd = CommandDefinition {
841            name: "".to_string(), // Empty name!
842            aliases: vec![],
843            description: "Test".to_string(),
844            required: false,
845            arguments: vec![],
846            options: vec![],
847            implementation: "handler".to_string(),
848            continue_on_failure: false,
849            requires_success: false,
850        };
851
852        let mut config = CommandsConfig::minimal();
853        config.commands = vec![cmd];
854
855        let result = validate_config(&config);
856        assert!(result.is_err());
857    }
858
859    #[test]
860    fn test_validate_argument_ordering() {
861        let args = vec![
862            ArgumentDefinition {
863                name: "optional".to_string(),
864                arg_type: ArgumentType::String,
865                required: false,
866                description: "Optional".to_string(),
867                validation: vec![],
868                secure: false,
869            },
870            ArgumentDefinition {
871                name: "required".to_string(),
872                arg_type: ArgumentType::String,
873                required: true, // Required after optional!
874                description: "Required".to_string(),
875                validation: vec![],
876                secure: false,
877            },
878        ];
879
880        let result = validate_argument_ordering(&args, "test");
881        assert!(result.is_err());
882    }
883
884    #[test]
885    fn test_validate_argument_names_duplicate() {
886        let args = vec![
887            ArgumentDefinition {
888                name: "arg1".to_string(),
889                arg_type: ArgumentType::String,
890                required: true,
891                description: "Arg 1".to_string(),
892                validation: vec![],
893                secure: false,
894            },
895            ArgumentDefinition {
896                name: "arg1".to_string(), // Duplicate!
897                arg_type: ArgumentType::Integer,
898                required: true,
899                description: "Arg 1 again".to_string(),
900                validation: vec![],
901                secure: false,
902            },
903        ];
904
905        let result = validate_argument_names(&args, "test");
906        assert!(result.is_err());
907    }
908
909    #[test]
910    fn test_validate_validation_rules_type_mismatch() {
911        let args = vec![ArgumentDefinition {
912            name: "count".to_string(),
913            arg_type: ArgumentType::Integer,
914            required: true,
915            description: "Count".to_string(),
916            validation: vec![
917                ValidationRule::MustExist { must_exist: true }, // Wrong for integer!
918            ],
919            secure: false,
920        }];
921
922        let result = validate_argument_validation_rules(&args, "test");
923        assert!(result.is_err());
924    }
925
926    #[test]
927    fn test_validate_validation_rules_invalid_range() {
928        let args = vec![ArgumentDefinition {
929            name: "percentage".to_string(),
930            arg_type: ArgumentType::Float,
931            required: true,
932            description: "Percentage".to_string(),
933            validation: vec![ValidationRule::Range {
934                min: Some(100.0),
935                max: Some(0.0), // min > max!
936            }],
937            secure: false,
938        }];
939
940        let result = validate_argument_validation_rules(&args, "test");
941        assert!(result.is_err());
942    }
943
944    #[test]
945    fn test_validate_options_no_flags() {
946        let options = vec![OptionDefinition {
947            name: "opt1".to_string(),
948            short: None,
949            long: None, // Neither short nor long!
950            option_type: ArgumentType::String,
951            required: false,
952            default: None,
953            description: "Option".to_string(),
954            choices: vec![],
955            repeatable: false,
956            option_parameters: HashMap::new(),
957        }];
958
959        let result = validate_options(&options, "test");
960        assert!(result.is_err());
961    }
962
963    #[test]
964    fn test_validate_options_default_not_in_choices() {
965        let options = vec![OptionDefinition {
966            name: "mode".to_string(),
967            short: Some("m".to_string()),
968            long: Some("mode".to_string()),
969            option_type: ArgumentType::String,
970            required: false,
971            default: Some("invalid".to_string()), // Not in choices!
972            description: "Mode".to_string(),
973            choices: vec!["fast".to_string(), "slow".to_string()],
974            repeatable: false,
975            option_parameters: HashMap::new(),
976        }];
977
978        let result = validate_options(&options, "test");
979        assert!(result.is_err());
980    }
981
982    #[test]
983    fn test_validate_option_flags_duplicate_short() {
984        let options = vec![
985            OptionDefinition {
986                name: "opt1".to_string(),
987                short: Some("o".to_string()),
988                long: None,
989                option_type: ArgumentType::String,
990                required: false,
991                default: None,
992                description: "Option 1".to_string(),
993                choices: vec![],
994                repeatable: false,
995                option_parameters: HashMap::new(),
996            },
997            OptionDefinition {
998                name: "opt2".to_string(),
999                short: Some("o".to_string()), // Duplicate!
1000                long: None,
1001                option_type: ArgumentType::String,
1002                required: false,
1003                default: None,
1004                description: "Option 2".to_string(),
1005                choices: vec![],
1006                repeatable: false,
1007                option_parameters: HashMap::new(),
1008            },
1009        ];
1010
1011        let result = validate_option_flags(&options, "test");
1012        assert!(result.is_err());
1013    }
1014
1015    #[test]
1016    fn test_validate_option_flags_invalid_short() {
1017        let options = vec![OptionDefinition {
1018            name: "opt1".to_string(),
1019            short: Some("opt".to_string()), // Too long!
1020            long: None,
1021            option_type: ArgumentType::String,
1022            required: false,
1023            default: None,
1024            description: "Option".to_string(),
1025            choices: vec![],
1026            repeatable: false,
1027            option_parameters: HashMap::new(),
1028        }];
1029
1030        let result = validate_option_flags(&options, "test");
1031        assert!(result.is_err());
1032    }
1033
1034    #[test]
1035    fn test_check_name_conflicts() {
1036        let args = vec![ArgumentDefinition {
1037            name: "output".to_string(),
1038            arg_type: ArgumentType::Path,
1039            required: true,
1040            description: "Output".to_string(),
1041            validation: vec![],
1042            secure: false,
1043        }];
1044
1045        let options = vec![OptionDefinition {
1046            name: "output".to_string(), // Same name as argument!
1047            short: Some("o".to_string()),
1048            long: Some("output".to_string()),
1049            option_type: ArgumentType::Path,
1050            required: false,
1051            default: None,
1052            description: "Output".to_string(),
1053            choices: vec![],
1054            repeatable: false,
1055            option_parameters: HashMap::new(),
1056        }];
1057
1058        let result = check_name_conflicts(&args, &options, "test");
1059        assert!(result.is_err());
1060    }
1061
1062    #[test]
1063    fn test_validate_command_valid() {
1064        let cmd = CommandDefinition {
1065            name: "process".to_string(),
1066            aliases: vec!["proc".to_string()],
1067            description: "Process data".to_string(),
1068            required: false,
1069            arguments: vec![ArgumentDefinition {
1070                name: "input".to_string(),
1071                arg_type: ArgumentType::Path,
1072                required: true,
1073                description: "Input file".to_string(),
1074                validation: vec![
1075                    ValidationRule::MustExist { must_exist: true },
1076                    ValidationRule::Extensions {
1077                        extensions: vec!["csv".to_string()],
1078                    },
1079                ],
1080                secure: false,
1081            }],
1082            options: vec![OptionDefinition {
1083                name: "output".to_string(),
1084                short: Some("o".to_string()),
1085                long: Some("output".to_string()),
1086                option_type: ArgumentType::Path,
1087                required: false,
1088                default: Some("out.csv".to_string()),
1089                description: "Output file".to_string(),
1090                choices: vec![],
1091                repeatable: false,
1092                option_parameters: HashMap::new(),
1093            }],
1094            implementation: "process_handler".to_string(),
1095            continue_on_failure: false,
1096            requires_success: false,
1097        };
1098
1099        assert!(validate_command(&cmd).is_ok());
1100    }
1101
1102    #[test]
1103    fn test_validate_boolean_with_choices() {
1104        let options = vec![OptionDefinition {
1105            name: "flag".to_string(),
1106            short: Some("f".to_string()),
1107            long: Some("flag".to_string()),
1108            option_type: ArgumentType::Bool,
1109            required: false,
1110            default: None,
1111            description: "A flag".to_string(),
1112            choices: vec!["true".to_string(), "false".to_string()], // Boolean can't have choices!
1113            repeatable: false,
1114            option_parameters: HashMap::new(),
1115        }];
1116
1117        let result = validate_options(&options, "test");
1118        assert!(result.is_err());
1119    }
1120
1121    // ── DD-024 (#21): repeatable options / option_parameters ────────────────
1122
1123    #[test]
1124    fn test_validate_repeatable_requires_non_empty_choices() {
1125        let options = vec![OptionDefinition {
1126            name: "output".to_string(),
1127            short: None,
1128            long: Some("output".to_string()),
1129            option_type: ArgumentType::String,
1130            required: false,
1131            default: None,
1132            description: "Output".to_string(),
1133            choices: vec![], // Repeatable but no discriminants!
1134            repeatable: true,
1135            option_parameters: HashMap::new(),
1136        }];
1137
1138        let result = validate_options(&options, "test");
1139        assert!(result.is_err());
1140    }
1141
1142    #[test]
1143    fn test_validate_repeatable_missing_option_parameters_entry() {
1144        let mut option_parameters = HashMap::new();
1145        option_parameters.insert(
1146            "csv".to_string(),
1147            vec![ArgumentDefinition {
1148                name: "file".to_string(),
1149                arg_type: ArgumentType::Path,
1150                required: true,
1151                description: "Destination file".to_string(),
1152                validation: vec![],
1153                secure: false,
1154            }],
1155        );
1156        // "plot" is in choices but has no option_parameters entry.
1157        let options = vec![OptionDefinition {
1158            name: "output".to_string(),
1159            short: None,
1160            long: Some("output".to_string()),
1161            option_type: ArgumentType::String,
1162            required: false,
1163            default: None,
1164            description: "Output".to_string(),
1165            choices: vec!["csv".to_string(), "plot".to_string()],
1166            repeatable: true,
1167            option_parameters,
1168        }];
1169
1170        let result = validate_options(&options, "test");
1171        assert!(result.is_err());
1172    }
1173
1174    #[test]
1175    fn test_validate_repeatable_orphan_option_parameters_key() {
1176        let mut option_parameters = HashMap::new();
1177        option_parameters.insert(
1178            "csv".to_string(),
1179            vec![ArgumentDefinition {
1180                name: "file".to_string(),
1181                arg_type: ArgumentType::Path,
1182                required: true,
1183                description: "Destination file".to_string(),
1184                validation: vec![],
1185                secure: false,
1186            }],
1187        );
1188        // "json" has an option_parameters entry but is not in choices.
1189        option_parameters.insert(
1190            "json".to_string(),
1191            vec![ArgumentDefinition {
1192                name: "file".to_string(),
1193                arg_type: ArgumentType::Path,
1194                required: true,
1195                description: "Destination file".to_string(),
1196                validation: vec![],
1197                secure: false,
1198            }],
1199        );
1200        let options = vec![OptionDefinition {
1201            name: "output".to_string(),
1202            short: None,
1203            long: Some("output".to_string()),
1204            option_type: ArgumentType::String,
1205            required: false,
1206            default: None,
1207            description: "Output".to_string(),
1208            choices: vec!["csv".to_string()],
1209            repeatable: true,
1210            option_parameters,
1211        }];
1212
1213        let result = validate_options(&options, "test");
1214        assert!(result.is_err());
1215    }
1216
1217    #[test]
1218    fn test_validate_repeatable_option_parameters_reuses_argument_validation() {
1219        let mut option_parameters = HashMap::new();
1220        option_parameters.insert(
1221            "csv".to_string(),
1222            vec![ArgumentDefinition {
1223                name: "".to_string(), // Empty name — invalid per validate_argument_names.
1224                arg_type: ArgumentType::Path,
1225                required: true,
1226                description: "Destination file".to_string(),
1227                validation: vec![],
1228                secure: false,
1229            }],
1230        );
1231        let options = vec![OptionDefinition {
1232            name: "output".to_string(),
1233            short: None,
1234            long: Some("output".to_string()),
1235            option_type: ArgumentType::String,
1236            required: false,
1237            default: None,
1238            description: "Output".to_string(),
1239            choices: vec!["csv".to_string()],
1240            repeatable: true,
1241            option_parameters,
1242        }];
1243
1244        let result = validate_options(&options, "test");
1245        assert!(result.is_err());
1246    }
1247
1248    #[test]
1249    fn test_validate_non_repeatable_with_option_parameters_is_error() {
1250        let mut option_parameters = HashMap::new();
1251        option_parameters.insert(
1252            "csv".to_string(),
1253            vec![ArgumentDefinition {
1254                name: "file".to_string(),
1255                arg_type: ArgumentType::Path,
1256                required: true,
1257                description: "Destination file".to_string(),
1258                validation: vec![],
1259                secure: false,
1260            }],
1261        );
1262        let options = vec![OptionDefinition {
1263            name: "output".to_string(),
1264            short: None,
1265            long: Some("output".to_string()),
1266            option_type: ArgumentType::String,
1267            required: false,
1268            default: None,
1269            description: "Output".to_string(),
1270            choices: vec!["csv".to_string()],
1271            repeatable: false, // option_parameters set despite repeatable: false!
1272            option_parameters,
1273        }];
1274
1275        let result = validate_options(&options, "test");
1276        assert!(result.is_err());
1277    }
1278
1279    #[test]
1280    fn test_validate_repeatable_with_default_is_error() {
1281        let mut option_parameters = HashMap::new();
1282        option_parameters.insert(
1283            "csv".to_string(),
1284            vec![ArgumentDefinition {
1285                name: "file".to_string(),
1286                arg_type: ArgumentType::Path,
1287                required: true,
1288                description: "Destination file".to_string(),
1289                validation: vec![],
1290                secure: false,
1291            }],
1292        );
1293        let options = vec![OptionDefinition {
1294            name: "output".to_string(),
1295            short: None,
1296            long: Some("output".to_string()),
1297            option_type: ArgumentType::String,
1298            required: false,
1299            default: Some("csv".to_string()), // Forbidden when repeatable: true!
1300            description: "Output".to_string(),
1301            choices: vec!["csv".to_string()],
1302            repeatable: true,
1303            option_parameters,
1304        }];
1305
1306        let result = validate_options(&options, "test");
1307        assert!(result.is_err());
1308    }
1309
1310    #[test]
1311    fn test_validate_repeatable_valid_config_passes() {
1312        let mut option_parameters = HashMap::new();
1313        option_parameters.insert(
1314            "csv".to_string(),
1315            vec![
1316                ArgumentDefinition {
1317                    name: "file".to_string(),
1318                    arg_type: ArgumentType::Path,
1319                    required: true,
1320                    description: "Destination CSV file".to_string(),
1321                    validation: vec![],
1322                    secure: false,
1323                },
1324                ArgumentDefinition {
1325                    name: "resolution".to_string(),
1326                    arg_type: ArgumentType::Integer,
1327                    required: false,
1328                    description: "Time-step resolution".to_string(),
1329                    validation: vec![],
1330                    secure: false,
1331                },
1332            ],
1333        );
1334        option_parameters.insert(
1335            "plot".to_string(),
1336            vec![ArgumentDefinition {
1337                name: "file".to_string(),
1338                arg_type: ArgumentType::Path,
1339                required: true,
1340                description: "Destination image file".to_string(),
1341                validation: vec![],
1342                secure: false,
1343            }],
1344        );
1345        let options = vec![OptionDefinition {
1346            name: "output".to_string(),
1347            short: None,
1348            long: Some("output".to_string()),
1349            option_type: ArgumentType::String,
1350            required: false,
1351            default: None,
1352            description: "Write simulation results in one or more output kinds".to_string(),
1353            choices: vec!["csv".to_string(), "plot".to_string()],
1354            repeatable: true,
1355            option_parameters,
1356        }];
1357
1358        let result = validate_options(&options, "test");
1359        assert!(result.is_ok());
1360    }
1361
1362    // ------------------------------------------------------------------
1363    // REPL directives and the reserved `:` prefix
1364    // ------------------------------------------------------------------
1365
1366    fn command(name: &str, aliases: &[&str]) -> CommandDefinition {
1367        CommandDefinition {
1368            name: name.to_string(),
1369            aliases: aliases.iter().map(|a| a.to_string()).collect(),
1370            description: "Test".to_string(),
1371            required: false,
1372            arguments: vec![],
1373            options: vec![],
1374            implementation: format!("{}_handler", name.trim_start_matches(':')),
1375            continue_on_failure: false,
1376            requires_success: false,
1377        }
1378    }
1379
1380    fn directive(
1381        implementation: ReplDirective,
1382        name: &str,
1383        aliases: &[&str],
1384    ) -> DirectiveDefinition {
1385        DirectiveDefinition {
1386            implementation,
1387            name: name.to_string(),
1388            aliases: aliases.iter().map(|a| a.to_string()).collect(),
1389            description: "Custom".to_string(),
1390        }
1391    }
1392
1393    fn config_with_directives(directives: Vec<DirectiveDefinition>) -> CommandsConfig {
1394        let mut config = CommandsConfig::minimal();
1395        config.directives = directives;
1396        config
1397    }
1398
1399    /// Unwrap an `InvalidSchema` error and return `(reason, path)`
1400    fn invalid_schema(result: Result<()>) -> (String, Option<String>) {
1401        match result {
1402            Err(crate::error::DynamicCliError::Config(ConfigError::InvalidSchema {
1403                reason,
1404                path,
1405                ..
1406            })) => (reason, path),
1407            other => panic!("Expected InvalidSchema error, got {:?}", other),
1408        }
1409    }
1410
1411    #[test]
1412    fn test_validate_config_from_yaml_without_directives() {
1413        let yaml = r#"
1414metadata:
1415  version: "1.0.0"
1416  prompt: "test"
1417commands:
1418  - name: hello
1419    description: "Say hello"
1420    implementation: "hello_handler"
1421"#;
1422        let config: CommandsConfig = serde_yaml::from_str(yaml).unwrap();
1423        assert!(config.directives.is_empty());
1424        assert!(validate_config(&config).is_ok());
1425    }
1426
1427    #[test]
1428    fn test_validate_config_command_names_may_match_directive_names() {
1429        // Directives are typed with `:`, so `help` and `quit` stay free for
1430        // application commands.
1431        let mut config = CommandsConfig::minimal();
1432        config.commands = vec![command("help", &["h"]), command("quit", &["q"])];
1433        assert!(validate_config(&config).is_ok());
1434    }
1435
1436    #[test]
1437    fn test_validate_config_command_name_with_colon_prefix() {
1438        let mut config = CommandsConfig::minimal();
1439        config.commands = vec![command(":run", &[])];
1440        let (reason, path) = invalid_schema(validate_config(&config));
1441        assert!(reason.contains("':run'"), "{}", reason);
1442        assert!(
1443            reason.contains("reserved for REPL directives"),
1444            "{}",
1445            reason
1446        );
1447        assert_eq!(path.as_deref(), Some("commands[0].name"));
1448    }
1449
1450    #[test]
1451    fn test_validate_config_command_alias_with_colon_prefix() {
1452        let mut config = CommandsConfig::minimal();
1453        config.commands = vec![command("list", &[]), command("run", &["r", ":r"])];
1454        let (reason, path) = invalid_schema(validate_config(&config));
1455        assert!(reason.contains("':r'"), "{}", reason);
1456        assert_eq!(path.as_deref(), Some("commands[1].aliases[1]"));
1457    }
1458
1459    #[test]
1460    fn test_validate_config_command_colon_inside_name_allowed() {
1461        let mut config = CommandsConfig::minimal();
1462        config.commands = vec![command("db:migrate", &[])];
1463        assert!(validate_config(&config).is_ok());
1464    }
1465
1466    #[test]
1467    fn test_validate_directives_valid_overrides() {
1468        let config = config_with_directives(vec![
1469            directive(ReplDirective::Help, "aide", &["a", "?"]),
1470            directive(ReplDirective::Quit, "quitter", &["q"]),
1471        ]);
1472        assert!(validate_config(&config).is_ok());
1473    }
1474
1475    #[test]
1476    fn test_validate_directives_override_may_reuse_its_own_default_alias() {
1477        // `h` belongs to `repl_help` by default; overriding `repl_help`
1478        // replaces its aliases, so the override may keep `h`.
1479        let config = config_with_directives(vec![directive(ReplDirective::Help, "aide", &["h"])]);
1480        assert!(validate_config(&config).is_ok());
1481    }
1482
1483    #[test]
1484    fn test_validate_directives_implementation_overridden_twice() {
1485        let config = config_with_directives(vec![
1486            directive(ReplDirective::Quit, "quitter", &[]),
1487            directive(ReplDirective::Help, "aide", &[]),
1488            directive(ReplDirective::Quit, "partir", &[]),
1489        ]);
1490        let (reason, path) = invalid_schema(validate_config(&config));
1491        assert!(reason.contains("'repl_quit'"), "{}", reason);
1492        assert!(reason.contains("directives[0]"), "{}", reason);
1493        assert_eq!(path.as_deref(), Some("directives[2].implementation"));
1494    }
1495
1496    #[test]
1497    fn test_validate_directives_empty_name() {
1498        let config = config_with_directives(vec![directive(ReplDirective::Help, "  ", &[])]);
1499        let (reason, path) = invalid_schema(validate_config(&config));
1500        assert!(reason.contains("cannot be empty"), "{}", reason);
1501        assert_eq!(path.as_deref(), Some("directives[0].name"));
1502    }
1503
1504    #[test]
1505    fn test_validate_directives_empty_alias() {
1506        let config = config_with_directives(vec![directive(ReplDirective::Load, "load", &[""])]);
1507        let (reason, path) = invalid_schema(validate_config(&config));
1508        assert!(reason.contains("cannot be empty"), "{}", reason);
1509        assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1510    }
1511
1512    #[test]
1513    fn test_validate_directives_name_with_whitespace() {
1514        let config =
1515            config_with_directives(vec![directive(ReplDirective::Exit, "sortir vite", &[])]);
1516        let (reason, path) = invalid_schema(validate_config(&config));
1517        assert!(reason.contains("whitespace"), "{}", reason);
1518        assert_eq!(path.as_deref(), Some("directives[0].name"));
1519    }
1520
1521    #[test]
1522    fn test_validate_directives_alias_with_whitespace() {
1523        let config = config_with_directives(vec![directive(ReplDirective::Exit, "exit", &["e\t"])]);
1524        let (reason, path) = invalid_schema(validate_config(&config));
1525        assert!(reason.contains("whitespace"), "{}", reason);
1526        assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1527    }
1528
1529    #[test]
1530    fn test_validate_directives_name_with_colon_prefix() {
1531        let config = config_with_directives(vec![directive(ReplDirective::Quit, ":quit", &[])]);
1532        let (reason, path) = invalid_schema(validate_config(&config));
1533        assert!(reason.contains("must not start with ':'"), "{}", reason);
1534        assert_eq!(path.as_deref(), Some("directives[0].name"));
1535    }
1536
1537    #[test]
1538    fn test_validate_directives_alias_with_colon_prefix() {
1539        let config = config_with_directives(vec![directive(ReplDirective::Quit, "quit", &[":q"])]);
1540        let (reason, path) = invalid_schema(validate_config(&config));
1541        assert!(reason.contains("must not start with ':'"), "{}", reason);
1542        assert_eq!(path.as_deref(), Some("directives[0].aliases[0]"));
1543    }
1544
1545    #[test]
1546    fn test_validate_directives_override_name_collides_with_default_alias() {
1547        // `q` is a default alias of `repl_quit`, which is not overridden.
1548        let config = config_with_directives(vec![directive(ReplDirective::Exit, "q", &[])]);
1549        let (reason, path) = invalid_schema(validate_config(&config));
1550        assert!(reason.contains("'q'"), "{}", reason);
1551        assert!(reason.contains("'repl_quit'"), "{}", reason);
1552        assert!(reason.contains("'repl_exit'"), "{}", reason);
1553        assert_eq!(path.as_deref(), Some("directives[0]"));
1554    }
1555
1556    #[test]
1557    fn test_validate_directives_override_alias_collides_with_default_name() {
1558        // The override of `repl_help` (first in `ALL`) takes `load`, the
1559        // default name of a later directive: the path still points at it.
1560        let config =
1561            config_with_directives(vec![directive(ReplDirective::Help, "help", &["load"])]);
1562        let (reason, path) = invalid_schema(validate_config(&config));
1563        assert!(reason.contains("'load'"), "{}", reason);
1564        assert_eq!(path.as_deref(), Some("directives[0]"));
1565    }
1566
1567    #[test]
1568    fn test_validate_directives_two_overrides_collide() {
1569        let config = config_with_directives(vec![
1570            directive(ReplDirective::Quit, "partir", &[]),
1571            directive(ReplDirective::Exit, "sortir", &["partir"]),
1572        ]);
1573        let (reason, path) = invalid_schema(validate_config(&config));
1574        assert!(reason.contains("'partir'"), "{}", reason);
1575        assert_eq!(path.as_deref(), Some("directives[1]"));
1576    }
1577
1578    #[test]
1579    fn test_validate_directives_name_repeated_as_own_alias() {
1580        let config =
1581            config_with_directives(vec![directive(ReplDirective::Load, "load", &["load"])]);
1582        let (reason, path) = invalid_schema(validate_config(&config));
1583        assert!(reason.contains("used twice by 'repl_load'"), "{}", reason);
1584        assert_eq!(path.as_deref(), Some("directives[0]"));
1585    }
1586}