Skip to main content

rumdl_lib/config/
validation.rs

1use super::flavor::{ConfigLoaded, ConfigValidated};
2use super::registry::{RULE_ALIAS_MAP, RuleRegistry, is_valid_rule_name, resolve_rule_name_alias};
3use super::source_tracking::{ConfigValidationWarning, SourcedConfig, SourcedRuleConfig};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7/// Validates rule names from CLI flags against the known rule set.
8/// Returns warnings for unknown rules with "did you mean" suggestions.
9///
10/// This provides consistent validation between config files and CLI flags.
11/// Unknown rules are warned about but don't cause failures.
12pub fn validate_cli_rule_names(
13    enable: Option<&str>,
14    disable: Option<&str>,
15    extend_enable: Option<&str>,
16    extend_disable: Option<&str>,
17) -> Vec<ConfigValidationWarning> {
18    let mut warnings = Vec::new();
19    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
20
21    let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
22        for name in input.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
23            // Check for special "all" value (case-insensitive)
24            if name.eq_ignore_ascii_case("all") {
25                continue;
26            }
27            if resolve_rule_name_alias(name).is_none() {
28                let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
29                    let formatted = if suggestion.starts_with("MD") {
30                        suggestion
31                    } else {
32                        suggestion.to_lowercase()
33                    };
34                    format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
35                } else {
36                    format!("Unknown rule in {flag_name}: {name}")
37                };
38                warnings.push(ConfigValidationWarning {
39                    message,
40                    rule: Some(name.to_string()),
41                    key: None,
42                });
43            }
44        }
45    };
46
47    if let Some(e) = enable {
48        validate_list(e, "--enable", &mut warnings);
49    }
50    if let Some(d) = disable {
51        validate_list(d, "--disable", &mut warnings);
52    }
53    if let Some(ee) = extend_enable {
54        validate_list(ee, "--extend-enable", &mut warnings);
55    }
56    if let Some(ed) = extend_disable {
57        validate_list(ed, "--extend-disable", &mut warnings);
58    }
59
60    warnings
61}
62
63/// Internal validation function that works with any SourcedConfig state.
64/// This is used by both the public `validate_config_sourced` and the typestate `validate()` method.
65pub(super) fn validate_config_sourced_internal<S>(
66    sourced: &SourcedConfig<S>,
67    registry: &RuleRegistry,
68) -> Vec<ConfigValidationWarning> {
69    let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
70
71    // Validate enable/disable arrays in [global] section
72    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
73
74    for rule_name in &sourced.global.enable.value {
75        if !is_valid_rule_name(rule_name) {
76            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
77                let formatted = if suggestion.starts_with("MD") {
78                    suggestion
79                } else {
80                    suggestion.to_lowercase()
81                };
82                format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
83            } else {
84                format!("Unknown rule in global.enable: {rule_name}")
85            };
86            warnings.push(ConfigValidationWarning {
87                message,
88                rule: Some(rule_name.clone()),
89                key: None,
90            });
91        }
92    }
93
94    for rule_name in &sourced.global.disable.value {
95        if !is_valid_rule_name(rule_name) {
96            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
97                let formatted = if suggestion.starts_with("MD") {
98                    suggestion
99                } else {
100                    suggestion.to_lowercase()
101                };
102                format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
103            } else {
104                format!("Unknown rule in global.disable: {rule_name}")
105            };
106            warnings.push(ConfigValidationWarning {
107                message,
108                rule: Some(rule_name.clone()),
109                key: None,
110            });
111        }
112    }
113
114    for rule_name in &sourced.global.extend_enable.value {
115        if !is_valid_rule_name(rule_name) {
116            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
117                let formatted = if suggestion.starts_with("MD") {
118                    suggestion
119                } else {
120                    suggestion.to_lowercase()
121                };
122                format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
123            } else {
124                format!("Unknown rule in global.extend-enable: {rule_name}")
125            };
126            warnings.push(ConfigValidationWarning {
127                message,
128                rule: Some(rule_name.clone()),
129                key: None,
130            });
131        }
132    }
133
134    for rule_name in &sourced.global.extend_disable.value {
135        if !is_valid_rule_name(rule_name) {
136            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
137                let formatted = if suggestion.starts_with("MD") {
138                    suggestion
139                } else {
140                    suggestion.to_lowercase()
141                };
142                format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
143            } else {
144                format!("Unknown rule in global.extend-disable: {rule_name}")
145            };
146            warnings.push(ConfigValidationWarning {
147                message,
148                rule: Some(rule_name.clone()),
149                key: None,
150            });
151        }
152    }
153
154    warnings
155}
156
157/// Core validation implementation that doesn't depend on SourcedConfig type parameter.
158fn validate_config_sourced_impl(
159    rules: &BTreeMap<String, SourcedRuleConfig>,
160    unknown_keys: &[(String, String, Option<String>)],
161    registry: &RuleRegistry,
162) -> Vec<ConfigValidationWarning> {
163    let mut warnings = Vec::new();
164    let known_rules = registry.rule_names();
165    // 1. Unknown rules
166    for rule in rules.keys() {
167        if !known_rules.contains(rule) {
168            // Include both canonical names AND aliases for fuzzy matching
169            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
170            let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
171                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
172                let formatted_suggestion = if suggestion.starts_with("MD") {
173                    suggestion
174                } else {
175                    suggestion.to_lowercase()
176                };
177                format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
178            } else {
179                format!("Unknown rule in config: {rule}")
180            };
181            warnings.push(ConfigValidationWarning {
182                message,
183                rule: Some(rule.clone()),
184                key: None,
185            });
186        }
187    }
188    // 2. Unknown options and type mismatches
189    for (rule, rule_cfg) in rules {
190        if let Some(valid_keys) = registry.config_keys_for(rule) {
191            for key in rule_cfg.values.keys() {
192                if !valid_keys.contains(key) {
193                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
194                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
195                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
196                    } else {
197                        format!("Unknown option for rule {rule}: {key}")
198                    };
199                    warnings.push(ConfigValidationWarning {
200                        message,
201                        rule: Some(rule.clone()),
202                        key: Some(key.clone()),
203                    });
204                } else {
205                    // Type check: compare type of value to type of default
206                    if let Some(expected) = registry.expected_value_for(rule, key) {
207                        let actual = &rule_cfg.values[key].value;
208                        if !toml_value_type_matches(expected, actual) {
209                            warnings.push(ConfigValidationWarning {
210                                message: format!(
211                                    "Type mismatch for {}.{}: expected {}, got {}",
212                                    rule,
213                                    key,
214                                    toml_type_name(expected),
215                                    toml_type_name(actual)
216                                ),
217                                rule: Some(rule.clone()),
218                                key: Some(key.clone()),
219                            });
220                        }
221                    }
222                }
223            }
224        }
225    }
226    // 3. Unknown global options (from unknown_keys)
227    let known_global_keys = vec![
228        "enable".to_string(),
229        "disable".to_string(),
230        "extend-enable".to_string(),
231        "extend-disable".to_string(),
232        "include".to_string(),
233        "exclude".to_string(),
234        "respect-gitignore".to_string(),
235        "line-length".to_string(),
236        "fixable".to_string(),
237        "unfixable".to_string(),
238        "flavor".to_string(),
239        "force-exclude".to_string(),
240        "output-format".to_string(),
241        "cache-dir".to_string(),
242        "cache".to_string(),
243    ];
244
245    for (section, key, file_path) in unknown_keys {
246        // Convert file path to relative for cleaner output
247        let display_path = file_path.as_ref().map(|p| to_relative_display_path(p));
248
249        if section.contains("[global]") || section.contains("[tool.rumdl]") {
250            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
251                if let Some(ref path) = display_path {
252                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
253                } else {
254                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
255                }
256            } else if let Some(ref path) = display_path {
257                format!("Unknown global option in {path}: {key}")
258            } else {
259                format!("Unknown global option: {key}")
260            };
261            warnings.push(ConfigValidationWarning {
262                message,
263                rule: None,
264                key: Some(key.clone()),
265            });
266        } else if !key.is_empty() {
267            // This is an unknown rule section (key is empty means it's a section header)
268            continue;
269        } else {
270            // Unknown rule section - suggest similar rule names
271            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
272            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(|s| s.to_string()).collect();
273            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
274                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
275                let formatted_suggestion = if suggestion.starts_with("MD") {
276                    suggestion
277                } else {
278                    suggestion.to_lowercase()
279                };
280                if let Some(ref path) = display_path {
281                    format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
282                } else {
283                    format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
284                }
285            } else if let Some(ref path) = display_path {
286                format!("Unknown rule in {path}: {rule_name}")
287            } else {
288                format!("Unknown rule in config: {rule_name}")
289            };
290            warnings.push(ConfigValidationWarning {
291                message,
292                rule: None,
293                key: None,
294            });
295        }
296    }
297    warnings
298}
299
300/// Convert a file path to a display-friendly relative path.
301///
302/// Tries to make the path relative to the current working directory.
303/// If that fails, returns the original path unchanged.
304pub(super) fn to_relative_display_path(path: &str) -> String {
305    let file_path = Path::new(path);
306
307    // Try to make relative to CWD
308    if let Ok(cwd) = std::env::current_dir() {
309        // Try with canonicalized paths first (handles symlinks)
310        if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
311            && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
312        {
313            return relative.to_string_lossy().to_string();
314        }
315
316        // Fall back to non-canonicalized comparison
317        if let Ok(relative) = file_path.strip_prefix(&cwd) {
318            return relative.to_string_lossy().to_string();
319        }
320    }
321
322    // Return original if we can't make it relative
323    path.to_string()
324}
325
326/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking.
327///
328/// This is the legacy API that works with `SourcedConfig<ConfigLoaded>`.
329/// For new code, prefer using `sourced.validate(&registry)` which returns a
330/// `SourcedConfig<ConfigValidated>` that can be converted to `Config`.
331pub fn validate_config_sourced(
332    sourced: &SourcedConfig<ConfigLoaded>,
333    registry: &RuleRegistry,
334) -> Vec<ConfigValidationWarning> {
335    validate_config_sourced_internal(sourced, registry)
336}
337
338/// Validate a config that has already been validated (no-op, returns stored warnings).
339///
340/// This exists for API consistency - validated configs already have their warnings stored.
341pub fn validate_config_sourced_validated(
342    sourced: &SourcedConfig<ConfigValidated>,
343    _registry: &RuleRegistry,
344) -> Vec<ConfigValidationWarning> {
345    sourced.validation_warnings.clone()
346}
347
348fn toml_type_name(val: &toml::Value) -> &'static str {
349    match val {
350        toml::Value::String(_) => "string",
351        toml::Value::Integer(_) => "integer",
352        toml::Value::Float(_) => "float",
353        toml::Value::Boolean(_) => "boolean",
354        toml::Value::Array(_) => "array",
355        toml::Value::Table(_) => "table",
356        toml::Value::Datetime(_) => "datetime",
357    }
358}
359
360/// Calculate Levenshtein distance between two strings (simple implementation)
361fn levenshtein_distance(s1: &str, s2: &str) -> usize {
362    let len1 = s1.len();
363    let len2 = s2.len();
364
365    if len1 == 0 {
366        return len2;
367    }
368    if len2 == 0 {
369        return len1;
370    }
371
372    let s1_chars: Vec<char> = s1.chars().collect();
373    let s2_chars: Vec<char> = s2.chars().collect();
374
375    let mut prev_row: Vec<usize> = (0..=len2).collect();
376    let mut curr_row = vec![0; len2 + 1];
377
378    for i in 1..=len1 {
379        curr_row[0] = i;
380        for j in 1..=len2 {
381            let cost = if s1_chars[i - 1] == s2_chars[j - 1] { 0 } else { 1 };
382            curr_row[j] = (prev_row[j] + 1)          // deletion
383                .min(curr_row[j - 1] + 1)            // insertion
384                .min(prev_row[j - 1] + cost); // substitution
385        }
386        std::mem::swap(&mut prev_row, &mut curr_row);
387    }
388
389    prev_row[len2]
390}
391
392/// Suggest a similar key from a list of valid keys using fuzzy matching
393pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
394    let unknown_lower = unknown.to_lowercase();
395    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
396
397    let mut best_match: Option<(String, usize)> = None;
398
399    for valid in valid_keys {
400        let valid_lower = valid.to_lowercase();
401        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
402
403        if distance <= max_distance {
404            if let Some((_, best_dist)) = &best_match {
405                if distance < *best_dist {
406                    best_match = Some((valid.clone(), distance));
407                }
408            } else {
409                best_match = Some((valid.clone(), distance));
410            }
411        }
412    }
413
414    best_match.map(|(key, _)| key)
415}
416
417fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
418    use toml::Value::*;
419    match (expected, actual) {
420        (String(_), String(_)) => true,
421        (Integer(_), Integer(_)) => true,
422        (Float(_), Float(_)) => true,
423        (Boolean(_), Boolean(_)) => true,
424        (Array(_), Array(_)) => true,
425        (Table(_), Table(_)) => true,
426        (Datetime(_), Datetime(_)) => true,
427        // Allow integer for float
428        (Float(_), Integer(_)) => true,
429        _ => false,
430    }
431}