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    fixable: Option<&str>,
18    unfixable: Option<&str>,
19) -> Vec<ConfigValidationWarning> {
20    let mut warnings = Vec::new();
21    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
22
23    let validate_list = |input: &str, flag_name: &str, warnings: &mut Vec<ConfigValidationWarning>| {
24        for name in input.split(',').map(str::trim).filter(|s| !s.is_empty()) {
25            // Check for special "all" value (case-insensitive)
26            if name.eq_ignore_ascii_case("all") {
27                continue;
28            }
29            if resolve_rule_name_alias(name).is_none() {
30                let message = if let Some(suggestion) = suggest_similar_key(name, &all_rule_names) {
31                    let formatted = if suggestion.starts_with("MD") {
32                        suggestion
33                    } else {
34                        suggestion.to_lowercase()
35                    };
36                    format!("Unknown rule in {flag_name}: {name} (did you mean: {formatted}?)")
37                } else {
38                    format!("Unknown rule in {flag_name}: {name}")
39                };
40                warnings.push(ConfigValidationWarning {
41                    message,
42                    rule: Some(name.to_string()),
43                    key: None,
44                });
45            }
46        }
47    };
48
49    if let Some(e) = enable {
50        validate_list(e, "--enable", &mut warnings);
51    }
52    if let Some(d) = disable {
53        validate_list(d, "--disable", &mut warnings);
54    }
55    if let Some(ee) = extend_enable {
56        validate_list(ee, "--extend-enable", &mut warnings);
57    }
58    if let Some(ed) = extend_disable {
59        validate_list(ed, "--extend-disable", &mut warnings);
60    }
61    if let Some(f) = fixable {
62        validate_list(f, "--fixable", &mut warnings);
63    }
64    if let Some(u) = unfixable {
65        validate_list(u, "--unfixable", &mut warnings);
66    }
67
68    warnings
69}
70
71/// Internal validation function that works with any SourcedConfig state.
72/// This is used by both the public `validate_config_sourced` and the typestate `validate()` method.
73pub(super) fn validate_config_sourced_internal<S>(
74    sourced: &SourcedConfig<S>,
75    registry: &RuleRegistry,
76) -> Vec<ConfigValidationWarning> {
77    let mut warnings = validate_config_sourced_impl(&sourced.rules, &sourced.unknown_keys, registry);
78
79    // Validate enable/disable arrays in [global] section
80    let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
81
82    for rule_name in &sourced.global.enable.value {
83        if !is_valid_rule_name(rule_name) {
84            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
85                let formatted = if suggestion.starts_with("MD") {
86                    suggestion
87                } else {
88                    suggestion.to_lowercase()
89                };
90                format!("Unknown rule in global.enable: {rule_name} (did you mean: {formatted}?)")
91            } else {
92                format!("Unknown rule in global.enable: {rule_name}")
93            };
94            warnings.push(ConfigValidationWarning {
95                message,
96                rule: Some(rule_name.clone()),
97                key: None,
98            });
99        }
100    }
101
102    for rule_name in &sourced.global.disable.value {
103        if !is_valid_rule_name(rule_name) {
104            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
105                let formatted = if suggestion.starts_with("MD") {
106                    suggestion
107                } else {
108                    suggestion.to_lowercase()
109                };
110                format!("Unknown rule in global.disable: {rule_name} (did you mean: {formatted}?)")
111            } else {
112                format!("Unknown rule in global.disable: {rule_name}")
113            };
114            warnings.push(ConfigValidationWarning {
115                message,
116                rule: Some(rule_name.clone()),
117                key: None,
118            });
119        }
120    }
121
122    for rule_name in &sourced.global.extend_enable.value {
123        if !is_valid_rule_name(rule_name) {
124            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
125                let formatted = if suggestion.starts_with("MD") {
126                    suggestion
127                } else {
128                    suggestion.to_lowercase()
129                };
130                format!("Unknown rule in global.extend-enable: {rule_name} (did you mean: {formatted}?)")
131            } else {
132                format!("Unknown rule in global.extend-enable: {rule_name}")
133            };
134            warnings.push(ConfigValidationWarning {
135                message,
136                rule: Some(rule_name.clone()),
137                key: None,
138            });
139        }
140    }
141
142    for rule_name in &sourced.global.extend_disable.value {
143        if !is_valid_rule_name(rule_name) {
144            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
145                let formatted = if suggestion.starts_with("MD") {
146                    suggestion
147                } else {
148                    suggestion.to_lowercase()
149                };
150                format!("Unknown rule in global.extend-disable: {rule_name} (did you mean: {formatted}?)")
151            } else {
152                format!("Unknown rule in global.extend-disable: {rule_name}")
153            };
154            warnings.push(ConfigValidationWarning {
155                message,
156                rule: Some(rule_name.clone()),
157                key: None,
158            });
159        }
160    }
161
162    for rule_name in &sourced.global.fixable.value {
163        if !is_valid_rule_name(rule_name) {
164            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
165                let formatted = if suggestion.starts_with("MD") {
166                    suggestion
167                } else {
168                    suggestion.to_lowercase()
169                };
170                format!("Unknown rule in global.fixable: {rule_name} (did you mean: {formatted}?)")
171            } else {
172                format!("Unknown rule in global.fixable: {rule_name}")
173            };
174            warnings.push(ConfigValidationWarning {
175                message,
176                rule: Some(rule_name.clone()),
177                key: None,
178            });
179        }
180    }
181
182    for rule_name in &sourced.global.unfixable.value {
183        if !is_valid_rule_name(rule_name) {
184            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
185                let formatted = if suggestion.starts_with("MD") {
186                    suggestion
187                } else {
188                    suggestion.to_lowercase()
189                };
190                format!("Unknown rule in global.unfixable: {rule_name} (did you mean: {formatted}?)")
191            } else {
192                format!("Unknown rule in global.unfixable: {rule_name}")
193            };
194            warnings.push(ConfigValidationWarning {
195                message,
196                rule: Some(rule_name.clone()),
197                key: None,
198            });
199        }
200    }
201
202    warnings
203}
204
205/// Core validation implementation that doesn't depend on SourcedConfig type parameter.
206fn validate_config_sourced_impl(
207    rules: &BTreeMap<String, SourcedRuleConfig>,
208    unknown_keys: &[(String, String, Option<String>)],
209    registry: &RuleRegistry,
210) -> Vec<ConfigValidationWarning> {
211    let mut warnings = Vec::new();
212    let known_rules = registry.rule_names();
213    // 1. Unknown rules
214    for rule in rules.keys() {
215        if !known_rules.contains(rule) {
216            // Include both canonical names AND aliases for fuzzy matching
217            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
218            let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
219                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
220                let formatted_suggestion = if suggestion.starts_with("MD") {
221                    suggestion
222                } else {
223                    suggestion.to_lowercase()
224                };
225                format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
226            } else {
227                format!("Unknown rule in config: {rule}")
228            };
229            warnings.push(ConfigValidationWarning {
230                message,
231                rule: Some(rule.clone()),
232                key: None,
233            });
234        }
235    }
236    // 2. Unknown options and type mismatches
237    for (rule, rule_cfg) in rules {
238        if let Some(valid_keys) = registry.config_keys_for(rule) {
239            for key in rule_cfg.values.keys() {
240                if !valid_keys.contains(key) {
241                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
242                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
243                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
244                    } else {
245                        format!("Unknown option for rule {rule}: {key}")
246                    };
247                    warnings.push(ConfigValidationWarning {
248                        message,
249                        rule: Some(rule.clone()),
250                        key: Some(key.clone()),
251                    });
252                } else {
253                    // Type check: compare type of value to type of default
254                    if let Some(expected) = registry.expected_value_for(rule, key) {
255                        let actual = &rule_cfg.values[key].value;
256                        if !toml_value_type_matches(expected, actual) {
257                            warnings.push(ConfigValidationWarning {
258                                message: format!(
259                                    "Type mismatch for {}.{}: expected {}, got {}",
260                                    rule,
261                                    key,
262                                    toml_type_name(expected),
263                                    toml_type_name(actual)
264                                ),
265                                rule: Some(rule.clone()),
266                                key: Some(key.clone()),
267                            });
268                        }
269                    }
270                }
271            }
272        }
273    }
274    // 3. Unknown global options (from unknown_keys). Suggestions come from the
275    // dispatch table itself, so a newly added global key is suggestible without a
276    // second list to keep in step, plus the keys holding a table or a path rather
277    // than a plain value.
278    let known_global_keys: Vec<String> = super::global_keys::GLOBAL_VALUE_KEYS
279        .iter()
280        .map(|k| (*k).to_string())
281        .chain(
282            ["per-file-ignores", "per-file-flavor", "extends"]
283                .into_iter()
284                .map(str::to_string),
285        )
286        .collect();
287
288    for (section, key, display_name) in unknown_keys {
289        // Already display-ready: the parser decided how this file may be named.
290        let display_path = display_name.as_ref();
291
292        if section.contains("[global]") || section.contains("[tool.rumdl]") {
293            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
294                if let Some(path) = display_path {
295                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
296                } else {
297                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
298                }
299            } else if let Some(path) = display_path {
300                format!("Unknown global option in {path}: {key}")
301            } else {
302                format!("Unknown global option: {key}")
303            };
304            warnings.push(ConfigValidationWarning {
305                message,
306                rule: None,
307                key: Some(key.clone()),
308            });
309        } else if !key.is_empty() {
310            // An option of a rule rumdl knows, recorded here instead of in the
311            // config map because it came from a file whose text may not be
312            // shown. Naming that file is what makes the warning actionable.
313            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
314            let message = if let Some(path) = display_path {
315                format!("Unknown option for rule {rule_name} in {path}: {key}")
316            } else {
317                format!("Unknown option for rule {rule_name}: {key}")
318            };
319            warnings.push(ConfigValidationWarning {
320                message,
321                rule: Some(rule_name.to_string()),
322                key: Some(key.clone()),
323            });
324        } else {
325            // Unknown rule section - suggest similar rule names
326            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
327            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
328            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
329                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
330                let formatted_suggestion = if suggestion.starts_with("MD") {
331                    suggestion
332                } else {
333                    suggestion.to_lowercase()
334                };
335                if let Some(path) = display_path {
336                    format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
337                } else {
338                    format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
339                }
340            } else if let Some(path) = display_path {
341                format!("Unknown rule in {path}: {rule_name}")
342            } else {
343                format!("Unknown rule in config: {rule_name}")
344            };
345            warnings.push(ConfigValidationWarning {
346                message,
347                rule: None,
348                key: None,
349            });
350        }
351    }
352    warnings
353}
354
355/// Convert a file path to a display-friendly relative path.
356///
357/// Tries to make the path relative to the current working directory.
358/// If that fails, returns the original path unchanged. The result uses `/`
359/// separators for consistent output across platforms.
360pub(super) fn to_relative_display_path(path: &str) -> String {
361    let file_path = Path::new(path);
362
363    // Try to make relative to CWD
364    if let Ok(cwd) = std::env::current_dir() {
365        // Try with canonicalized paths first (handles symlinks)
366        if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
367            && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
368        {
369            return normalize_separators(relative.to_string_lossy().to_string());
370        }
371
372        // Fall back to non-canonicalized comparison
373        if let Ok(relative) = file_path.strip_prefix(&cwd) {
374            return normalize_separators(relative.to_string_lossy().to_string());
375        }
376    }
377
378    // Return original if we can't make it relative
379    normalize_separators(path.to_string())
380}
381
382/// Normalize path separators to `/` for consistent cross-platform output.
383///
384/// Only the platform's native separator is converted: on Windows `\` becomes `/`.
385/// On Unix this is a no-op, where `\` is a legal filename character that must be
386/// preserved.
387fn normalize_separators(path: String) -> String {
388    if cfg!(windows) { path.replace('\\', "/") } else { path }
389}
390
391/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking.
392///
393/// This is the legacy API that works with `SourcedConfig<ConfigLoaded>`.
394/// For new code, prefer using `sourced.validate(&registry)` which returns a
395/// `SourcedConfig<ConfigValidated>` that can be converted to `Config`.
396pub fn validate_config_sourced(
397    sourced: &SourcedConfig<ConfigLoaded>,
398    registry: &RuleRegistry,
399) -> Vec<ConfigValidationWarning> {
400    validate_config_sourced_internal(sourced, registry)
401}
402
403/// Validate a config that has already been validated (no-op, returns stored warnings).
404///
405/// This exists for API consistency - validated configs already have their warnings stored.
406pub fn validate_config_sourced_validated(
407    sourced: &SourcedConfig<ConfigValidated>,
408    _registry: &RuleRegistry,
409) -> Vec<ConfigValidationWarning> {
410    sourced.validation_warnings.clone()
411}
412
413fn toml_type_name(val: &toml::Value) -> &'static str {
414    match val {
415        toml::Value::String(_) => "string",
416        toml::Value::Integer(_) => "integer",
417        toml::Value::Float(_) => "float",
418        toml::Value::Boolean(_) => "boolean",
419        toml::Value::Array(_) => "array",
420        toml::Value::Table(_) => "table",
421        toml::Value::Datetime(_) => "datetime",
422    }
423}
424
425/// Calculate Levenshtein distance between two strings (simple implementation)
426fn levenshtein_distance(s1: &str, s2: &str) -> usize {
427    let len1 = s1.len();
428    let len2 = s2.len();
429
430    if len1 == 0 {
431        return len2;
432    }
433    if len2 == 0 {
434        return len1;
435    }
436
437    let s1_chars: Vec<char> = s1.chars().collect();
438    let s2_chars: Vec<char> = s2.chars().collect();
439
440    let mut prev_row: Vec<usize> = (0..=len2).collect();
441    let mut curr_row = vec![0; len2 + 1];
442
443    for i in 1..=len1 {
444        curr_row[0] = i;
445        for j in 1..=len2 {
446            let cost = usize::from(s1_chars[i - 1] != s2_chars[j - 1]);
447            curr_row[j] = (prev_row[j] + 1)          // deletion
448                .min(curr_row[j - 1] + 1)            // insertion
449                .min(prev_row[j - 1] + cost); // substitution
450        }
451        std::mem::swap(&mut prev_row, &mut curr_row);
452    }
453
454    prev_row[len2]
455}
456
457/// Suggest a similar key from a list of valid keys using fuzzy matching
458pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
459    let unknown_lower = unknown.to_lowercase();
460    let max_distance = 2.max(unknown.len() / 3); // Allow up to 2 edits or 30% of string length
461
462    let mut best_match: Option<(String, usize)> = None;
463
464    for valid in valid_keys {
465        let valid_lower = valid.to_lowercase();
466        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
467
468        if distance <= max_distance {
469            if let Some((_, best_dist)) = &best_match {
470                if distance < *best_dist {
471                    best_match = Some((valid.clone(), distance));
472                }
473            } else {
474                best_match = Some((valid.clone(), distance));
475            }
476        }
477    }
478
479    best_match.map(|(key, _)| key)
480}
481
482fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
483    use toml::Value::{Array, Boolean, Datetime, Float, Integer, String, Table};
484    match (expected, actual) {
485        (String(_), String(_)) => true,
486        (Integer(_), Integer(_)) => true,
487        (Float(_), Float(_)) => true,
488        (Boolean(_), Boolean(_)) => true,
489        (Array(_), Array(_)) => true,
490        (Table(_), Table(_)) => true,
491        (Datetime(_), Datetime(_)) => true,
492        // Allow integer for float
493        (Float(_), Integer(_)) => true,
494        _ => false,
495    }
496}