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 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;
#[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![],
},
ArgumentDefinition {
name: "required".to_string(),
arg_type: ArgumentType::String,
required: true, description: "Required".to_string(),
validation: vec![],
},
];
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![],
},
ArgumentDefinition {
name: "arg1".to_string(), arg_type: ArgumentType::Integer,
required: true,
description: "Arg 1 again".to_string(),
validation: vec![],
},
];
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 }, ],
}];
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), }],
}];
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![],
}];
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()],
}];
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![],
},
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![],
},
];
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![],
}];
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![],
}];
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![],
}];
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()],
},
],
}],
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![],
}],
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()], }];
let result = validate_options(&options, "test");
assert!(result.is_err());
}
}