use crate::config::schema::{
ArgumentDefinition, ArgumentType, CommandDefinition, CommandsConfig, OptionDefinition,
ValidationRule,
};
use crate::error::{ConfigError, Result};
use std::collections::{HashMap, HashSet};
pub fn validate_config(config: &CommandsConfig) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (idx, command) in config.commands.iter().enumerate() {
validate_command(command)?;
if !seen_names.insert(command.name.clone()) {
return Err(ConfigError::DuplicateCommand {
name: command.name.clone(),
suggestion: None,
}
.into());
}
for alias in &command.aliases {
if !seen_names.insert(alias.clone()) {
return Err(ConfigError::DuplicateCommand {
name: alias.clone(),
suggestion: None,
}
.into());
}
}
if command.name.trim().is_empty() {
return Err(ConfigError::InvalidSchema {
reason: "Command name cannot be empty".to_string(),
path: Some(format!("commands[{}].name", idx)),
suggestion: None,
}
.into());
}
if command.implementation.trim().is_empty() {
return Err(ConfigError::InvalidSchema {
reason: "Command implementation cannot be empty".to_string(),
path: Some(format!("commands[{}].implementation", idx)),
suggestion: None,
}
.into());
}
}
validate_options(&config.global_options, "global_options")?;
Ok(())
}
pub fn validate_command(cmd: &CommandDefinition) -> Result<()> {
validate_argument_types(&cmd.arguments)?;
validate_argument_ordering(&cmd.arguments, &cmd.name)?;
validate_argument_names(&cmd.arguments, &cmd.name)?;
validate_argument_validation_rules(&cmd.arguments, &cmd.name)?;
validate_options(&cmd.options, &cmd.name)?;
validate_option_flags(&cmd.options, &cmd.name)?;
check_name_conflicts(&cmd.arguments, &cmd.options, &cmd.name)?;
Ok(())
}
pub fn validate_argument_types(args: &[ArgumentDefinition]) -> Result<()> {
for arg in args {
let _ = arg.arg_type;
}
Ok(())
}
fn validate_argument_ordering(args: &[ArgumentDefinition], context: &str) -> Result<()> {
let mut seen_optional = false;
for (idx, arg) in args.iter().enumerate() {
if !arg.required {
seen_optional = true;
} else if seen_optional {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Required argument '{}' cannot come after optional arguments",
arg.name
),
path: Some(format!("{}.arguments[{}]", context, idx)),
suggestion: None,
}
.into());
}
}
Ok(())
}
fn validate_argument_names(args: &[ArgumentDefinition], context: &str) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (idx, arg) in args.iter().enumerate() {
if arg.name.trim().is_empty() {
return Err(ConfigError::InvalidSchema {
reason: "Argument name cannot be empty".to_string(),
path: Some(format!("{}.arguments[{}]", context, idx)),
suggestion: None,
}
.into());
}
if !seen_names.insert(arg.name.clone()) {
return Err(ConfigError::InvalidSchema {
reason: format!("Duplicate argument name: '{}'", arg.name),
path: Some(format!("{}.arguments", context)),
suggestion: None,
}
.into());
}
}
Ok(())
}
fn validate_argument_validation_rules(args: &[ArgumentDefinition], _context: &str) -> Result<()> {
for arg in args.iter() {
for rule in arg.validation.iter() {
match rule {
ValidationRule::MustExist { .. } | ValidationRule::Extensions { .. } => {
if arg.arg_type != ArgumentType::Path {
return Err(ConfigError::Inconsistency {
details: format!(
"Validation rule 'must_exist' or 'extensions' can only be used with 'path' type, \
but argument '{}' has type '{}'",
arg.name,
arg.arg_type.as_str()
),
suggestion: None,
}.into());
}
}
ValidationRule::Range { min, max } => {
if !matches!(arg.arg_type, ArgumentType::Integer | ArgumentType::Float) {
return Err(ConfigError::Inconsistency {
details: format!(
"Validation rule 'range' can only be used with numeric types, \
but argument '{}' has type '{}'",
arg.name,
arg.arg_type.as_str()
),
suggestion: None,
}
.into());
}
if let (Some(min_val), Some(max_val)) = (min, max) {
if min_val > max_val {
return Err(ConfigError::Inconsistency {
details: format!(
"Invalid range for argument '{}': min ({}) > max ({})",
arg.name, min_val, max_val
),
suggestion: None,
}
.into());
}
}
}
}
}
}
Ok(())
}
fn validate_options(options: &[OptionDefinition], context: &str) -> Result<()> {
let mut seen_names: HashSet<String> = HashSet::new();
for (idx, opt) in options.iter().enumerate() {
if opt.name.trim().is_empty() {
return Err(ConfigError::InvalidSchema {
reason: "Option name cannot be empty".to_string(),
path: Some(format!("{}.options[{}]", context, idx)),
suggestion: None,
}
.into());
}
if !seen_names.insert(opt.name.clone()) {
return Err(ConfigError::InvalidSchema {
reason: format!("Duplicate option name: '{}'", opt.name),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
if opt.short.is_none() && opt.long.is_none() {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Option '{}' must have at least a short or long form",
opt.name
),
path: Some(format!("{}.options[{}]", context, idx)),
suggestion: None,
}
.into());
}
if opt.repeatable {
if let Some(ref default) = opt.default {
return Err(ConfigError::Inconsistency {
details: format!(
"Repeatable option '{}' cannot have a default value ('{}')",
opt.name, default
),
suggestion: Some(
"Remove `default` — a repeatable option's absence already \
means zero occurrences, not an implicit one."
.to_string(),
),
}
.into());
}
if opt.choices.is_empty() {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Repeatable option '{}' must declare at least one discriminant in choices",
opt.name
),
path: Some(format!("{}.options[{}].choices", context, idx)),
suggestion: Some(
"Add `choices: [...]` listing the valid discriminants for \
this repeatable option."
.to_string(),
),
}
.into());
}
for discriminant in &opt.choices {
if !opt.option_parameters.contains_key(discriminant) {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Discriminant '{}' is declared in choices for repeatable \
option '{}' but has no matching entry in option_parameters",
discriminant, opt.name
),
path: Some(format!("{}.options[{}].option_parameters", context, idx)),
suggestion: Some(format!(
"Add an `option_parameters.{}` entry describing this \
discriminant's key=value parameters.",
discriminant
)),
}
.into());
}
}
let choices_set: HashSet<&String> = opt.choices.iter().collect();
for key in opt.option_parameters.keys() {
if !choices_set.contains(key) {
return Err(ConfigError::InvalidSchema {
reason: format!(
"option_parameters key '{}' on option '{}' is not declared in choices",
key, opt.name
),
path: Some(format!(
"{}.options[{}].option_parameters.{}",
context, idx, key
)),
suggestion: Some(format!(
"Add '{}' to choices, or remove this option_parameters entry.",
key
)),
}
.into());
}
}
for (discriminant, params) in &opt.option_parameters {
let sub_context = format!(
"{}.options[{}].option_parameters.{}",
context, idx, discriminant
);
validate_argument_names(params, &sub_context)?;
validate_argument_types(params)?;
}
} else if !opt.option_parameters.is_empty() {
return Err(ConfigError::Inconsistency {
details: format!(
"Option '{}' has option_parameters but repeatable is false",
opt.name
),
suggestion: Some(
"Set `repeatable: true`, or remove `option_parameters`.".to_string(),
),
}
.into());
}
if let Some(ref default) = opt.default {
if !opt.choices.is_empty() && !opt.choices.contains(default) {
return Err(ConfigError::Inconsistency {
details: format!(
"Default value '{}' for option '{}' is not in choices: [{}]",
default,
opt.name,
opt.choices.join(", ")
),
suggestion: None,
}
.into());
}
}
if opt.option_type == ArgumentType::Bool && !opt.choices.is_empty() {
return Err(ConfigError::Inconsistency {
details: format!("Boolean option '{}' cannot have choices", opt.name),
suggestion: None,
}
.into());
}
}
Ok(())
}
fn validate_option_flags(options: &[OptionDefinition], context: &str) -> Result<()> {
let mut seen_short: HashMap<String, String> = HashMap::new();
let mut seen_long: HashMap<String, String> = HashMap::new();
for opt in options {
if let Some(ref short) = opt.short {
if short.len() != 1 {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Short option '{}' for '{}' must be a single character",
short, opt.name
),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
if let Some(existing) = seen_short.insert(short.clone(), opt.name.clone()) {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Short option '-{}' is used by both '{}' and '{}'",
short, existing, opt.name
),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
}
if let Some(ref long) = opt.long {
if long.is_empty() {
return Err(ConfigError::InvalidSchema {
reason: format!("Long option for '{}' cannot be empty", opt.name),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
if let Some(existing) = seen_long.insert(long.clone(), opt.name.clone()) {
return Err(ConfigError::InvalidSchema {
reason: format!(
"Long option '--{}' is used by both '{}' and '{}'",
long, existing, opt.name
),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
}
}
Ok(())
}
fn check_name_conflicts(
args: &[ArgumentDefinition],
options: &[OptionDefinition],
context: &str,
) -> Result<()> {
let arg_names: HashSet<String> = args.iter().map(|a| a.name.clone()).collect();
for opt in options {
if arg_names.contains(&opt.name) {
return Err(ConfigError::InvalidSchema {
reason: format!("Option '{}' has the same name as an argument", opt.name),
path: Some(format!("{}.options", context)),
suggestion: None,
}
.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::schema::CommandsConfig;
use std::collections::HashMap;
#[test]
fn test_validate_config_empty() {
let config = CommandsConfig::minimal();
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_validate_config_duplicate_command_name() {
let mut config = CommandsConfig::minimal();
config.commands = vec![
CommandDefinition {
name: "test".to_string(),
aliases: vec![],
description: "Test 1".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "handler1".to_string(),
},
CommandDefinition {
name: "test".to_string(), aliases: vec![],
description: "Test 2".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "handler2".to_string(),
},
];
let result = validate_config(&config);
assert!(result.is_err());
match result.unwrap_err() {
crate::error::DynamicCliError::Config(ConfigError::DuplicateCommand {
name, ..
}) => {
assert_eq!(name, "test");
}
other => panic!("Expected DuplicateCommand error, got {:?}", other),
}
}
#[test]
fn test_validate_config_duplicate_alias() {
let mut config = CommandsConfig::minimal();
config.commands = vec![
CommandDefinition {
name: "cmd1".to_string(),
aliases: vec!["c".to_string()],
description: "Command 1".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "handler1".to_string(),
},
CommandDefinition {
name: "cmd2".to_string(),
aliases: vec!["c".to_string()], description: "Command 2".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "handler2".to_string(),
},
];
let result = validate_config(&config);
assert!(result.is_err());
}
#[test]
fn test_validate_command_empty_name() {
let cmd = CommandDefinition {
name: "".to_string(), aliases: vec![],
description: "Test".to_string(),
required: false,
arguments: vec![],
options: vec![],
implementation: "handler".to_string(),
};
let mut config = CommandsConfig::minimal();
config.commands = vec![cmd];
let result = validate_config(&config);
assert!(result.is_err());
}
#[test]
fn test_validate_argument_ordering() {
let args = vec![
ArgumentDefinition {
name: "optional".to_string(),
arg_type: ArgumentType::String,
required: false,
description: "Optional".to_string(),
validation: vec![],
secure: false,
},
ArgumentDefinition {
name: "required".to_string(),
arg_type: ArgumentType::String,
required: true, description: "Required".to_string(),
validation: vec![],
secure: false,
},
];
let result = validate_argument_ordering(&args, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_argument_names_duplicate() {
let args = vec![
ArgumentDefinition {
name: "arg1".to_string(),
arg_type: ArgumentType::String,
required: true,
description: "Arg 1".to_string(),
validation: vec![],
secure: false,
},
ArgumentDefinition {
name: "arg1".to_string(), arg_type: ArgumentType::Integer,
required: true,
description: "Arg 1 again".to_string(),
validation: vec![],
secure: false,
},
];
let result = validate_argument_names(&args, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_validation_rules_type_mismatch() {
let args = vec![ArgumentDefinition {
name: "count".to_string(),
arg_type: ArgumentType::Integer,
required: true,
description: "Count".to_string(),
validation: vec![
ValidationRule::MustExist { must_exist: true }, ],
secure: false,
}];
let result = validate_argument_validation_rules(&args, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_validation_rules_invalid_range() {
let args = vec![ArgumentDefinition {
name: "percentage".to_string(),
arg_type: ArgumentType::Float,
required: true,
description: "Percentage".to_string(),
validation: vec![ValidationRule::Range {
min: Some(100.0),
max: Some(0.0), }],
secure: false,
}];
let result = validate_argument_validation_rules(&args, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_options_no_flags() {
let options = vec![OptionDefinition {
name: "opt1".to_string(),
short: None,
long: None, option_type: ArgumentType::String,
required: false,
default: None,
description: "Option".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_options_default_not_in_choices() {
let options = vec![OptionDefinition {
name: "mode".to_string(),
short: Some("m".to_string()),
long: Some("mode".to_string()),
option_type: ArgumentType::String,
required: false,
default: Some("invalid".to_string()), description: "Mode".to_string(),
choices: vec!["fast".to_string(), "slow".to_string()],
repeatable: false,
option_parameters: HashMap::new(),
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_option_flags_duplicate_short() {
let options = vec![
OptionDefinition {
name: "opt1".to_string(),
short: Some("o".to_string()),
long: None,
option_type: ArgumentType::String,
required: false,
default: None,
description: "Option 1".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
},
OptionDefinition {
name: "opt2".to_string(),
short: Some("o".to_string()), long: None,
option_type: ArgumentType::String,
required: false,
default: None,
description: "Option 2".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
},
];
let result = validate_option_flags(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_option_flags_invalid_short() {
let options = vec![OptionDefinition {
name: "opt1".to_string(),
short: Some("opt".to_string()), long: None,
option_type: ArgumentType::String,
required: false,
default: None,
description: "Option".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
}];
let result = validate_option_flags(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_check_name_conflicts() {
let args = vec![ArgumentDefinition {
name: "output".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Output".to_string(),
validation: vec![],
secure: false,
}];
let options = vec![OptionDefinition {
name: "output".to_string(), short: Some("o".to_string()),
long: Some("output".to_string()),
option_type: ArgumentType::Path,
required: false,
default: None,
description: "Output".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
}];
let result = check_name_conflicts(&args, &options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_command_valid() {
let cmd = CommandDefinition {
name: "process".to_string(),
aliases: vec!["proc".to_string()],
description: "Process data".to_string(),
required: false,
arguments: vec![ArgumentDefinition {
name: "input".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Input file".to_string(),
validation: vec![
ValidationRule::MustExist { must_exist: true },
ValidationRule::Extensions {
extensions: vec!["csv".to_string()],
},
],
secure: false,
}],
options: vec![OptionDefinition {
name: "output".to_string(),
short: Some("o".to_string()),
long: Some("output".to_string()),
option_type: ArgumentType::Path,
required: false,
default: Some("out.csv".to_string()),
description: "Output file".to_string(),
choices: vec![],
repeatable: false,
option_parameters: HashMap::new(),
}],
implementation: "process_handler".to_string(),
};
assert!(validate_command(&cmd).is_ok());
}
#[test]
fn test_validate_boolean_with_choices() {
let options = vec![OptionDefinition {
name: "flag".to_string(),
short: Some("f".to_string()),
long: Some("flag".to_string()),
option_type: ArgumentType::Bool,
required: false,
default: None,
description: "A flag".to_string(),
choices: vec!["true".to_string(), "false".to_string()], repeatable: false,
option_parameters: HashMap::new(),
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_requires_non_empty_choices() {
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Output".to_string(),
choices: vec![], repeatable: true,
option_parameters: HashMap::new(),
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_missing_option_parameters_entry() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Output".to_string(),
choices: vec!["csv".to_string(), "plot".to_string()],
repeatable: true,
option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_orphan_option_parameters_key() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
option_parameters.insert(
"json".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Output".to_string(),
choices: vec!["csv".to_string()],
repeatable: true,
option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_option_parameters_reuses_argument_validation() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![ArgumentDefinition {
name: "".to_string(), arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Output".to_string(),
choices: vec!["csv".to_string()],
repeatable: true,
option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_non_repeatable_with_option_parameters_is_error() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Output".to_string(),
choices: vec!["csv".to_string()],
repeatable: false, option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_with_default_is_error() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: Some("csv".to_string()), description: "Output".to_string(),
choices: vec!["csv".to_string()],
repeatable: true,
option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
#[test]
fn test_validate_repeatable_valid_config_passes() {
let mut option_parameters = HashMap::new();
option_parameters.insert(
"csv".to_string(),
vec![
ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination CSV file".to_string(),
validation: vec![],
secure: false,
},
ArgumentDefinition {
name: "resolution".to_string(),
arg_type: ArgumentType::Integer,
required: false,
description: "Time-step resolution".to_string(),
validation: vec![],
secure: false,
},
],
);
option_parameters.insert(
"plot".to_string(),
vec![ArgumentDefinition {
name: "file".to_string(),
arg_type: ArgumentType::Path,
required: true,
description: "Destination image file".to_string(),
validation: vec![],
secure: false,
}],
);
let options = vec![OptionDefinition {
name: "output".to_string(),
short: None,
long: Some("output".to_string()),
option_type: ArgumentType::String,
required: false,
default: None,
description: "Write simulation results in one or more output kinds".to_string(),
choices: vec!["csv".to_string(), "plot".to_string()],
repeatable: true,
option_parameters,
}];
let result = validate_options(&options, "test");
assert!(result.is_ok());
}
}