use super::flavor::{ConfigLoaded, ConfigValidated};
use super::registry::{RULE_ALIAS_MAP, RuleRegistry, is_valid_rule_name, resolve_rule_name_alias};
use super::source_tracking::{ConfigValidationWarning, SourcedConfig, SourcedRuleConfig};
use std::collections::BTreeMap;
use std::path::Path;
pub fn validate_cli_rule_names(
enable: Option<&str>,
disable: Option<&str>,
extend_enable: Option<&str>,
extend_disable: Option<&str>,
fixable: Option<&str>,
unfixable: Option<&str>,
) -> Vec<ConfigValidationWarning> {
let mut warnings = Vec::new();
let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
if name.eq_ignore_ascii_case("all") {
continue;
}
if resolve_rule_name_alias(name).is_none() {
let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in {flag_name}: {name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(name.to_string()),
key: None,
});
}
}
};
if let Some(e) = enable {
validate_list(e, "--enable", &mut warnings);
}
if let Some(d) = disable {
validate_list(d, "--disable", &mut warnings);
}
if let Some(ee) = extend_enable {
validate_list(ee, "--extend-enable", &mut warnings);
}
if let Some(ed) = extend_disable {
validate_list(ed, "--extend-disable", &mut warnings);
}
if let Some(f) = fixable {
validate_list(f, "--fixable", &mut warnings);
}
if let Some(u) = unfixable {
validate_list(u, "--unfixable", &mut warnings);
}
warnings
}
pub(super) fn validate_config_sourced_internal<S>(
sourced: &SourcedConfig<S>,
registry: &RuleRegistry,
) -> Vec<ConfigValidationWarning> {
let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
for rule_name in &sourced.global.enable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.enable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
for rule_name in &sourced.global.disable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.disable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
for rule_name in &sourced.global.extend_enable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.extend-enable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
for rule_name in &sourced.global.extend_disable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.extend-disable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
for rule_name in &sourced.global.fixable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.fixable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.fixable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
for rule_name in &sourced.global.unfixable.value {
if !is_valid_rule_name(rule_name) {
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in global.unfixable: {rule_name} (did you mean: {formatted}?)")
} else {
format!("Unknown rule in global.unfixable: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule_name.clone()),
key: None,
});
}
}
warnings
}
fn validate_config_sourced_impl(
rules: &BTreeMap<String, SourcedRuleConfig>,
unknown_keys: &[(String, String, Option<String>)],
registry: &RuleRegistry,
) -> Vec<ConfigValidationWarning> {
let mut warnings = Vec::new();
let known_rules = registry.rule_names();
for rule in rules.keys() {
if !known_rules.contains(rule) {
let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
let formatted_suggestion = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
} else {
format!("Unknown rule in config: {rule}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule.clone()),
key: None,
});
}
}
for (rule, rule_cfg) in rules {
if let Some(valid_keys) = registry.config_keys_for(rule) {
for key in rule_cfg.values.keys() {
if !valid_keys.contains(key) {
let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
} else {
format!("Unknown option for rule {rule}: {key}")
};
warnings.push(ConfigValidationWarning {
message,
rule: Some(rule.clone()),
key: Some(key.clone()),
});
} else {
if let Some(expected) = registry.expected_value_for(rule, key) {
let actual = &rule_cfg.values[key].value;
if !toml_value_type_matches(expected, actual) {
warnings.push(ConfigValidationWarning {
message: format!(
"Type mismatch for {}.{}: expected {}, got {}",
rule,
key,
toml_type_name(expected),
toml_type_name(actual)
),
rule: Some(rule.clone()),
key: Some(key.clone()),
});
}
}
}
}
}
}
let known_global_keys = vec![
"enable".to_string(),
"disable".to_string(),
"extend-enable".to_string(),
"extend-disable".to_string(),
"include".to_string(),
"exclude".to_string(),
"respect-gitignore".to_string(),
"line-length".to_string(),
"fixable".to_string(),
"unfixable".to_string(),
"flavor".to_string(),
"force-exclude".to_string(),
"output-format".to_string(),
"cache-dir".to_string(),
"cache".to_string(),
];
for (section, key, file_path) in unknown_keys {
let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
if section.contains("[global]") || section.contains("[tool.rumdl]") {
let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
if let Some(ref path) = display_path {
format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
} else {
format!("Unknown global option: {key} (did you mean: {suggestion}?)")
}
} else if let Some(ref path) = display_path {
format!("Unknown global option in {path}: {key}")
} else {
format!("Unknown global option: {key}")
};
warnings.push(ConfigValidationWarning {
message,
rule: None,
key: Some(key.clone()),
});
} else if !key.is_empty() {
continue;
} else {
let rule_name = section.trim_matches(|c| c == '[' || c == ']');
let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
let formatted_suggestion = if suggestion.starts_with("MD") {
suggestion
} else {
suggestion.to_lowercase()
};
if let Some(ref path) = display_path {
format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
} else {
format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
}
} else if let Some(ref path) = display_path {
format!("Unknown rule in {path}: {rule_name}")
} else {
format!("Unknown rule in config: {rule_name}")
};
warnings.push(ConfigValidationWarning {
message,
rule: None,
key: None,
});
}
}
warnings
}
pub(super) fn to_relative_display_path(path: &str) -> String {
let file_path = Path::new(path);
if let Ok(cwd) = std::env::current_dir() {
if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
&& let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
{
return relative.to_string_lossy().to_string();
}
if let Ok(relative) = file_path.strip_prefix(&cwd) {
return relative.to_string_lossy().to_string();
}
}
path.to_string()
}
pub fn validate_config_sourced(
sourced: &SourcedConfig<ConfigLoaded>,
registry: &RuleRegistry,
) -> Vec<ConfigValidationWarning> {
validate_config_sourced_internal(sourced, registry)
}
pub fn validate_config_sourced_validated(
sourced: &SourcedConfig<ConfigValidated>,
_registry: &RuleRegistry,
) -> Vec<ConfigValidationWarning> {
sourced.validation_warnings.clone()
}
fn toml_type_name(val: &toml::Value) -> &'static str {
match val {
toml::Value::String(_) => "string",
toml::Value::Integer(_) => "integer",
toml::Value::Float(_) => "float",
toml::Value::Boolean(_) => "boolean",
toml::Value::Array(_) => "array",
toml::Value::Table(_) => "table",
toml::Value::Datetime(_) => "datetime",
}
}
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let len1 = s1.len();
let len2 = s2.len();
if len1 == 0 {
return len2;
}
if len2 == 0 {
return len1;
}
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
let mut prev_row: Vec<usize> = (0..=len2).collect();
let mut curr_row = vec![0; len2 + 1];
for i in 1..=len1 {
curr_row[0] = i;
for j in 1..=len2 {
let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
curr_row[j] = (prev_row[j] + 1) .min(curr_row[j - 1] + 1) .min(prev_row[j - 1] + cost); }
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[len2]
}
pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
let unknown_lower = unknown.to_lowercase();
let max_distance = 2.max(unknown.len() / 3);
let mut best_match: Option<(String, usize)> = None;
for valid in valid_keys {
let valid_lower = valid.to_lowercase();
let distance = levenshtein_distance(&unknown_lower, &valid_lower);
if distance <= max_distance {
if let Some((_, best_dist)) = &best_match {
if distance < *best_dist {
best_match = Some((valid.clone(), distance));
}
} else {
best_match = Some((valid.clone(), distance));
}
}
}
best_match.map(|(key, _)| key)
}
fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
use toml::Value::*;
match (expected, actual) {
(String(_), String(_)) => true,
(Integer(_), Integer(_)) => true,
(Float(_), Float(_)) => true,
(Boolean(_), Boolean(_)) => true,
(Array(_), Array(_)) => true,
(Table(_), Table(_)) => true,
(Datetime(_), Datetime(_)) => true,
(Float(_), Integer(_)) => true,
_ => false,
}
}