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.extend(validate_code_block_tools(&sourced.code_block_tools.value));
203
204    warnings
205}
206
207/// Warnings for `[code-block-tools.languages.*]` tool ids that name nothing rumdl can
208/// run, or a tool the slot asks for something it cannot do.
209///
210/// A tool id that resolves to nothing is otherwise reported only by a `log::warn!`,
211/// invisible at default verbosity: the run skips the tool, finds no issues and exits 0,
212/// which is indistinguishable from the tool having run and been happy. That silence is
213/// what makes a typo here cost an afternoon rather than a second.
214///
215/// Runs whether or not `enabled` is set, so a typo is caught before the switch is
216/// flipped; a config with no `languages` section produces nothing either way.
217fn validate_code_block_tools(config: &crate::code_block_tools::CodeBlockToolsConfig) -> Vec<ConfigValidationWarning> {
218    use crate::code_block_tools::{ToolRegistry, ToolSlot, is_rumdl_builtin};
219
220    let mut warnings = Vec::new();
221
222    // `warn` says a tool is missing once for the whole run, beside the config that
223    // named it. Which languages a run meets is only known from the documents, so
224    // there is no such place for this setting and it would behave as `ignore`
225    // without saying so.
226    if config.on_missing_language_definition == crate::code_block_tools::OnMissing::Warn {
227        warnings.push(ConfigValidationWarning {
228            message: "code-block-tools.on-missing-language-definition: \"warn\" behaves as \"ignore\"; \
229                      use \"fail\" to report a language with no tools"
230                .to_string(),
231            rule: None,
232            key: None,
233        });
234    }
235
236    if config.languages.is_empty() {
237        return warnings;
238    }
239
240    let registry = ToolRegistry::new(config.tools.clone());
241    // Suggestions come from the registry itself, so a tool added to it is suggestible
242    // without a second list to keep in step.
243    let known_tools: Vec<String> = registry.list_tools().into_iter().map(str::to_string).collect();
244
245    for (lang, lang_config) in &config.languages {
246        for (slot, slot_name, tool_ids) in [
247            (ToolSlot::Lint, "lint", &lang_config.lint),
248            (ToolSlot::Format, "format", &lang_config.format),
249        ] {
250            for tool_id in tool_ids {
251                // rumdl's own markdown linting, short-circuited before tool resolution.
252                if is_rumdl_builtin(tool_id) && !(slot == ToolSlot::Format && tool_id == "rumdl:lint") {
253                    continue;
254                }
255
256                // A tool id and a language key are both text out of whichever file
257                // supplied the section, so a section reached through `extends` is
258                // described rather than quoted - the suggestion too, which would
259                // otherwise say how close the withheld text came to a real id.
260                let message = if !is_rumdl_builtin(tool_id) && registry.resolve_id(tool_id, slot).is_none() {
261                    if config.values_withheld {
262                        let withheld = crate::config::WITHHELD;
263                        format!("Unknown tool in code-block-tools.languages.{withheld}.{slot_name}: {withheld}")
264                    } else if let Some(suggestion) = suggest_similar_key(tool_id, &known_tools) {
265                        format!(
266                            "Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id} (did you mean: {suggestion}?)"
267                        )
268                    } else {
269                        format!("Unknown tool in code-block-tools.languages.{lang}.{slot_name}: {tool_id}")
270                    }
271                } else if slot == ToolSlot::Format
272                    && (tool_id == "rumdl:lint" || registry.fills_format_slot(tool_id) == Some(false))
273                {
274                    // A linter in a format slot writes diagnostics where the formatted
275                    // code should go, so rumdl declines the output and the block is
276                    // never formatted.
277                    if config.values_withheld {
278                        let withheld = crate::config::WITHHELD;
279                        format!("Tool in code-block-tools.languages.{withheld}.format cannot format: {withheld}")
280                    } else {
281                        format!(
282                            "Tool in code-block-tools.languages.{lang}.format cannot format: {tool_id} is a linter (move it to lint)"
283                        )
284                    }
285                } else {
286                    continue;
287                };
288
289                warnings.push(ConfigValidationWarning {
290                    message,
291                    rule: None,
292                    key: None,
293                });
294            }
295        }
296    }
297
298    warnings
299}
300
301/// Core validation implementation that doesn't depend on SourcedConfig type parameter.
302fn validate_config_sourced_impl(
303    rules: &BTreeMap<String, SourcedRuleConfig>,
304    unknown_keys: &[(String, String, Option<String>)],
305    registry: &RuleRegistry,
306) -> Vec<ConfigValidationWarning> {
307    let mut warnings = Vec::new();
308    let known_rules = registry.rule_names();
309    // 1. Unknown rules
310    for rule in rules.keys() {
311        if !known_rules.contains(rule) {
312            // Include both canonical names AND aliases for fuzzy matching
313            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
314            let message = if let Some(suggestion) = suggest_similar_key(rule, &all_rule_names) {
315                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
316                let formatted_suggestion = if suggestion.starts_with("MD") {
317                    suggestion
318                } else {
319                    suggestion.to_lowercase()
320                };
321                format!("Unknown rule in config: {rule} (did you mean: {formatted_suggestion}?)")
322            } else {
323                format!("Unknown rule in config: {rule}")
324            };
325            warnings.push(ConfigValidationWarning {
326                message,
327                rule: Some(rule.clone()),
328                key: None,
329            });
330        }
331    }
332    // 2. Unknown options and type mismatches
333    for (rule, rule_cfg) in rules {
334        if let Some(valid_keys) = registry.config_keys_for(rule) {
335            for key in rule_cfg.values.keys() {
336                if !valid_keys.contains(key) {
337                    let valid_keys_vec: Vec<String> = valid_keys.iter().cloned().collect();
338                    let message = if let Some(suggestion) = suggest_similar_key(key, &valid_keys_vec) {
339                        format!("Unknown option for rule {rule}: {key} (did you mean: {suggestion}?)")
340                    } else {
341                        format!("Unknown option for rule {rule}: {key}")
342                    };
343                    warnings.push(ConfigValidationWarning {
344                        message,
345                        rule: Some(rule.clone()),
346                        key: Some(key.clone()),
347                    });
348                } else {
349                    // Type check: compare type of value to type of default
350                    if let Some(expected) = registry.expected_value_for(rule, key) {
351                        let actual = &rule_cfg.values[key].value;
352                        if !toml_value_type_matches(expected, actual) {
353                            warnings.push(ConfigValidationWarning {
354                                message: format!(
355                                    "Type mismatch for {}.{}: expected {}, got {}",
356                                    rule,
357                                    key,
358                                    toml_type_name(expected),
359                                    toml_type_name(actual)
360                                ),
361                                rule: Some(rule.clone()),
362                                key: Some(key.clone()),
363                            });
364                        }
365                    }
366                }
367            }
368        }
369    }
370    // 3. Unknown global options (from unknown_keys). Suggestions come from the
371    // dispatch table itself, so a newly added global key is suggestible without a
372    // second list to keep in step, plus the keys holding a table or a path rather
373    // than a plain value.
374    let known_global_keys: Vec<String> = super::global_keys::GLOBAL_VALUE_KEYS
375        .iter()
376        .map(|k| (*k).to_string())
377        .chain(
378            ["per-file-ignores", "per-file-flavor", "extends"]
379                .into_iter()
380                .map(str::to_string),
381        )
382        .collect();
383
384    for (section, key, display_name) in unknown_keys {
385        // Already display-ready: the parser decided how this file may be named.
386        let display_path = display_name.as_ref();
387
388        if section.contains("[global]") || section.contains("[tool.rumdl]") {
389            let message = if let Some(suggestion) = suggest_similar_key(key, &known_global_keys) {
390                if let Some(path) = display_path {
391                    format!("Unknown global option in {path}: {key} (did you mean: {suggestion}?)")
392                } else {
393                    format!("Unknown global option: {key} (did you mean: {suggestion}?)")
394                }
395            } else if let Some(path) = display_path {
396                format!("Unknown global option in {path}: {key}")
397            } else {
398                format!("Unknown global option: {key}")
399            };
400            warnings.push(ConfigValidationWarning {
401                message,
402                rule: None,
403                key: Some(key.clone()),
404            });
405        } else if !key.is_empty() {
406            // An option of a rule rumdl knows, recorded here instead of in the
407            // config map because it came from a file whose text may not be
408            // shown. Naming that file is what makes the warning actionable.
409            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
410            let message = if let Some(path) = display_path {
411                format!("Unknown option for rule {rule_name} in {path}: {key}")
412            } else {
413                format!("Unknown option for rule {rule_name}: {key}")
414            };
415            warnings.push(ConfigValidationWarning {
416                message,
417                rule: Some(rule_name.to_string()),
418                key: Some(key.clone()),
419            });
420        } else {
421            // Unknown rule section - suggest similar rule names
422            let rule_name = section.trim_matches(|c| c == '[' || c == ']');
423            let all_rule_names: Vec<String> = RULE_ALIAS_MAP.keys().map(std::string::ToString::to_string).collect();
424            let message = if let Some(suggestion) = suggest_similar_key(rule_name, &all_rule_names) {
425                // Convert alias suggestions to lowercase for better UX (MD001 stays uppercase, ul-style becomes lowercase)
426                let formatted_suggestion = if suggestion.starts_with("MD") {
427                    suggestion
428                } else {
429                    suggestion.to_lowercase()
430                };
431                if let Some(path) = display_path {
432                    format!("Unknown rule in {path}: {rule_name} (did you mean: {formatted_suggestion}?)")
433                } else {
434                    format!("Unknown rule in config: {rule_name} (did you mean: {formatted_suggestion}?)")
435                }
436            } else if let Some(path) = display_path {
437                format!("Unknown rule in {path}: {rule_name}")
438            } else {
439                format!("Unknown rule in config: {rule_name}")
440            };
441            warnings.push(ConfigValidationWarning {
442                message,
443                rule: None,
444                key: None,
445            });
446        }
447    }
448    warnings
449}
450
451/// Convert a file path to a display-friendly relative path.
452///
453/// Tries to make the path relative to the current working directory.
454/// If that fails, returns the original path unchanged. The result uses `/`
455/// separators for consistent output across platforms.
456pub(super) fn to_relative_display_path(path: &str) -> String {
457    let file_path = Path::new(path);
458
459    // Try to make relative to CWD
460    if let Ok(cwd) = std::env::current_dir() {
461        // Try with canonicalized paths first (handles symlinks)
462        if let (Ok(canonical_file), Ok(canonical_cwd)) = (file_path.canonicalize(), cwd.canonicalize())
463            && let Ok(relative) = canonical_file.strip_prefix(&canonical_cwd)
464        {
465            return normalize_for_display(relative.to_string_lossy().to_string());
466        }
467
468        // Fall back to non-canonicalized comparison
469        if let Ok(relative) = file_path.strip_prefix(&cwd) {
470            return normalize_for_display(relative.to_string_lossy().to_string());
471        }
472    }
473
474    // Return original if we can't make it relative
475    normalize_for_display(path.to_string())
476}
477
478/// Normalize a path for output: `/` separators on every platform, and no Win32
479/// verbatim prefix.
480///
481/// Only the platform's native separator is converted: on Windows `\` becomes `/`.
482/// On Unix this is a no-op, where `\` is a legal filename character that must be
483/// preserved. A config path resolved through `canonicalize` also sheds the `\\?\`
484/// prefix that form carries, the same way the CLI displays linted files.
485fn normalize_for_display(path: String) -> String {
486    if cfg!(windows) {
487        windows_display_path(&path)
488    } else {
489        path
490    }
491}
492
493/// The Windows half of [`normalize_for_display`]: pure string logic, so it is
494/// tested on every platform.
495pub(super) fn windows_display_path(path: &str) -> String {
496    crate::discovery::strip_verbatim_prefix(path).replace('\\', "/")
497}
498
499/// Validate a loaded config against the rule registry, using SourcedConfig for unknown key tracking.
500///
501/// This is the legacy API that works with `SourcedConfig<ConfigLoaded>`.
502/// For new code, prefer using `sourced.validate(&registry)` which returns a
503/// `SourcedConfig<ConfigValidated>` that can be converted to `Config`.
504pub fn validate_config_sourced(
505    sourced: &SourcedConfig<ConfigLoaded>,
506    registry: &RuleRegistry,
507) -> Vec<ConfigValidationWarning> {
508    validate_config_sourced_internal(sourced, registry)
509}
510
511/// Validate a config that has already been validated (no-op, returns stored warnings).
512///
513/// This exists for API consistency - validated configs already have their warnings stored.
514pub fn validate_config_sourced_validated(
515    sourced: &SourcedConfig<ConfigValidated>,
516    _registry: &RuleRegistry,
517) -> Vec<ConfigValidationWarning> {
518    sourced.validation_warnings.clone()
519}
520
521fn toml_type_name(val: &toml::Value) -> &'static str {
522    match val {
523        toml::Value::String(_) => "string",
524        toml::Value::Integer(_) => "integer",
525        toml::Value::Float(_) => "float",
526        toml::Value::Boolean(_) => "boolean",
527        toml::Value::Array(_) => "array",
528        toml::Value::Table(_) => "table",
529        toml::Value::Datetime(_) => "datetime",
530    }
531}
532
533/// Calculate Levenshtein distance between two strings (simple implementation)
534///
535/// The distance counts character edits, so every length here is a character
536/// count. Measuring in bytes instead makes the two disagree for any non-ASCII
537/// input: the row is indexed past what the loops filled, and the untouched
538/// cell returns a distance of 0, reporting an unrelated key as an exact match.
539fn levenshtein_distance(s1: &str, s2: &str) -> usize {
540    let s1_chars: Vec<char> = s1.chars().collect();
541    let s2_chars: Vec<char> = s2.chars().collect();
542
543    let len1 = s1_chars.len();
544    let len2 = s2_chars.len();
545
546    if len1 == 0 {
547        return len2;
548    }
549    if len2 == 0 {
550        return len1;
551    }
552
553    let mut prev_row: Vec<usize> = (0..=len2).collect();
554    let mut curr_row = vec![0; len2 + 1];
555
556    for i in 1..=len1 {
557        curr_row[0] = i;
558        for j in 1..=len2 {
559            let cost = usize::from(s1_chars[i - 1] != s2_chars[j - 1]);
560            curr_row[j] = (prev_row[j] + 1)          // deletion
561                .min(curr_row[j - 1] + 1)            // insertion
562                .min(prev_row[j - 1] + cost); // substitution
563        }
564        std::mem::swap(&mut prev_row, &mut curr_row);
565    }
566
567    prev_row[len2]
568}
569
570/// Suggest a similar key from a list of valid keys using fuzzy matching
571///
572/// Several keys are routinely the same distance from a typo, so the closest one
573/// alone does not name a single answer. Ties go to the smaller key, which makes
574/// the suggestion depend on the key set rather than on the order the caller
575/// happens to hold it in.
576pub fn suggest_similar_key(unknown: &str, valid_keys: &[String]) -> Option<String> {
577    let unknown_lower = unknown.to_lowercase();
578    // Allow up to 2 edits or 30% of the key's length. Counted in characters, to
579    // match the distance being compared against it: a byte count would hand a
580    // non-ASCII key a budget several times the one an ASCII key of the same
581    // length gets, and suggest a key it has nothing in common with.
582    let max_distance = 2.max(unknown.chars().count() / 3);
583
584    let mut best_match: Option<(&String, usize)> = None;
585
586    for valid in valid_keys {
587        let valid_lower = valid.to_lowercase();
588        let distance = levenshtein_distance(&unknown_lower, &valid_lower);
589
590        if distance > max_distance {
591            continue;
592        }
593        let is_better = match &best_match {
594            Some((best_key, best_dist)) => distance < *best_dist || (distance == *best_dist && valid < *best_key),
595            None => true,
596        };
597        if is_better {
598            best_match = Some((valid, distance));
599        }
600    }
601
602    best_match.map(|(key, _)| key.clone())
603}
604
605fn toml_value_type_matches(expected: &toml::Value, actual: &toml::Value) -> bool {
606    use toml::Value::{Array, Boolean, Datetime, Float, Integer, String, Table};
607    match (expected, actual) {
608        (String(_), String(_)) => true,
609        (Integer(_), Integer(_)) => true,
610        (Float(_), Float(_)) => true,
611        (Boolean(_), Boolean(_)) => true,
612        (Array(_), Array(_)) => true,
613        (Table(_), Table(_)) => true,
614        (Datetime(_), Datetime(_)) => true,
615        // Allow integer for float
616        (Float(_), Integer(_)) => true,
617        _ => false,
618    }
619}
620
621#[cfg(test)]
622mod suggestion_tests {
623    use super::*;
624
625    fn keys(names: &[&str]) -> Vec<String> {
626        names.iter().map(|s| (*s).to_string()).collect()
627    }
628
629    #[test]
630    fn a_closer_key_wins_over_an_earlier_one() {
631        let candidates = keys(&["MD049", "MD013"]);
632        assert_eq!(suggest_similar_key("MD01", &candidates), Some("MD013".to_string()));
633    }
634
635    #[test]
636    fn equally_close_keys_resolve_to_the_smaller_name() {
637        // Both are two substitutions away from MD999, so only the tie-break
638        // decides which one the user is shown.
639        for order in [["MD049", "MD009"], ["MD009", "MD049"]] {
640            assert_eq!(
641                suggest_similar_key("MD999", &keys(&order)),
642                Some("MD009".to_string()),
643                "suggestion changed with the caller's key order: {order:?}"
644            );
645        }
646    }
647
648    #[test]
649    fn a_key_beyond_the_edit_budget_is_no_suggestion() {
650        assert_eq!(suggest_similar_key("MD999", &keys(&["line-length"])), None);
651    }
652
653    #[test]
654    fn multibyte_character_no_panic() {
655        assert_eq!(suggest_similar_key("β€”", &keys(&["MD049", "MD009"])), None);
656    }
657
658    /// Not panicking is only half the requirement: the distance has to be right.
659    /// Measuring the row index in bytes reads a cell the loops never filled, and
660    /// its initial 0 says "these strings are identical".
661    #[test]
662    fn distance_to_a_multibyte_key_is_not_zero() {
663        assert_eq!(levenshtein_distance("md013", "β€”"), 5);
664        assert_eq!(levenshtein_distance("md013", "πŸŽ‰πŸŽ‰"), 5);
665        assert_eq!(levenshtein_distance("", "β€”"), 1);
666    }
667
668    /// Edit distance is symmetric. A byte-indexed row breaks that, which is the
669    /// cheapest way to see the two operands being measured on different scales.
670    #[test]
671    fn distance_is_symmetric_across_encodings() {
672        for (a, b) in [("md013", "β€”"), ("cafΓ©", "cafe"), ("β€”", "MD049"), ("ζ—₯本θͺž", "md013")] {
673            assert_eq!(
674                levenshtein_distance(a, b),
675                levenshtein_distance(b, a),
676                "distance between {a:?} and {b:?} depends on argument order"
677            );
678        }
679    }
680
681    /// A character's byte width must not buy it a wider edit budget. Five em
682    /// dashes are 15 bytes, enough for a budget of 5 to reach `MD001`.
683    #[test]
684    fn a_multibyte_key_gets_no_wider_edit_budget() {
685        assert_eq!(suggest_similar_key("β€”β€”β€”β€”β€”", &keys(&["MD001", "MD049"])), None);
686        assert_eq!(suggest_similar_key("πŸŽ‰πŸŽ‰πŸŽ‰", &keys(&["MD001", "MD049"])), None);
687    }
688
689    /// The ASCII behaviour is the control: it must be untouched by all of the above.
690    #[test]
691    fn ascii_suggestions_are_unchanged() {
692        assert_eq!(
693            suggest_similar_key("line-lenght", &keys(&["line-length"])),
694            Some("line-length".to_string())
695        );
696        assert_eq!(levenshtein_distance("md013", "md009"), 2);
697        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
698    }
699
700    /// A near-miss of a real key still resolves when the typo itself is non-ASCII,
701    /// so the character-counted budget is not merely stricter, it is correct.
702    #[test]
703    fn a_non_ascii_typo_of_a_real_key_still_resolves() {
704        assert_eq!(
705            suggest_similar_key("lineβ€”length", &keys(&["line-length"])),
706            Some("line-length".to_string()),
707            "one em dash for one hyphen is a single substitution"
708        );
709    }
710}
711
712#[cfg(test)]
713mod code_block_tool_tests {
714    use crate::code_block_tools::{CodeBlockToolsConfig, LanguageToolConfig, ToolDefinition};
715
716    fn config_with(lang: &str, lint: &[&str], format: &[&str]) -> CodeBlockToolsConfig {
717        let mut config = CodeBlockToolsConfig {
718            enabled: true,
719            ..Default::default()
720        };
721        config.languages.insert(
722            lang.to_string(),
723            LanguageToolConfig {
724                lint: lint.iter().map(|s| (*s).to_string()).collect(),
725                format: format.iter().map(|s| (*s).to_string()).collect(),
726                ..Default::default()
727            },
728        );
729        config
730    }
731
732    fn messages(config: &CodeBlockToolsConfig) -> Vec<String> {
733        super::validate_code_block_tools(config)
734            .into_iter()
735            .map(|w| w.message)
736            .collect()
737    }
738
739    #[test]
740    fn an_unknown_tool_id_is_reported_with_a_suggestion() {
741        let messages = messages(&config_with("python", &[], &["blackk"]));
742        assert_eq!(
743            messages,
744            vec!["Unknown tool in code-block-tools.languages.python.format: blackk (did you mean: black?)"]
745        );
746    }
747
748    #[test]
749    fn a_resolvable_tool_id_is_not_reported() {
750        // The control for the test above: same shape, one letter apart, silent.
751        assert!(messages(&config_with("python", &["ruff:check"], &["black"])).is_empty());
752    }
753
754    #[test]
755    fn a_bare_name_resolving_through_a_variant_is_not_reported() {
756        // `terraform` is registered as `terraform:format`, and a lint slot answers
757        // through the same entry by comparing the formatter's output.
758        assert!(messages(&config_with("terraform", &["terraform"], &["terraform"])).is_empty());
759    }
760
761    #[test]
762    fn a_linter_in_a_format_slot_is_reported() {
763        let messages = messages(&config_with("python", &[], &["ruff:check"]));
764        assert_eq!(
765            messages,
766            vec![
767                "Tool in code-block-tools.languages.python.format cannot format: ruff:check is a linter (move it to lint)"
768            ]
769        );
770    }
771
772    #[test]
773    fn a_linter_in_a_lint_slot_is_not_reported() {
774        assert!(messages(&config_with("python", &["ruff:check"], &[])).is_empty());
775    }
776
777    #[test]
778    fn a_user_tool_shadowing_a_builtin_linter_may_format() {
779        // The user wrote this command, so rumdl has no opinion about what it does -
780        // and must not answer from the built-in `ruff:check` it shadows.
781        let mut config = config_with("python", &[], &["ruff:check"]);
782        config.tools.insert(
783            "ruff:check".to_string(),
784            ToolDefinition {
785                command: vec!["my-formatter".to_string(), "-".to_string()],
786                stdin: true,
787                stdout: true,
788                lint_args: vec![],
789                format_args: vec![],
790            },
791        );
792        assert!(messages(&config).is_empty());
793    }
794
795    #[test]
796    fn a_user_tool_is_a_suggestion_candidate() {
797        let mut config = config_with("python", &[], &["my-formater"]);
798        config.tools.insert(
799            "my-formatter".to_string(),
800            ToolDefinition {
801                command: vec!["my-formatter".to_string(), "-".to_string()],
802                stdin: true,
803                stdout: true,
804                lint_args: vec![],
805                format_args: vec![],
806            },
807        );
808        assert_eq!(
809            messages(&config),
810            vec!["Unknown tool in code-block-tools.languages.python.format: my-formater (did you mean: my-formatter?)"]
811        );
812    }
813
814    #[test]
815    fn rumdls_own_markdown_linting_is_not_an_unknown_tool() {
816        assert!(messages(&config_with("markdown", &["rumdl"], &["rumdl"])).is_empty());
817    }
818
819    #[test]
820    fn explicit_tool_variants_are_validated_in_their_slots() {
821        for (language, lint, format) in [
822            ("markdown", vec!["rumdl:lint"], vec!["rumdl:format"]),
823            ("javascript", vec!["oxfmt:lint"], vec!["oxfmt:format"]),
824            (
825                "html",
826                vec!["djlint:html:lint", "djlint:html:format-check"],
827                vec!["djlint:html:format"],
828            ),
829            (
830                "jinja",
831                vec!["djlint:jinja:lint", "djlint:jinja:format-check"],
832                vec!["djlint:jinja:format"],
833            ),
834            (
835                "shell",
836                vec!["shuck:lint", "shuck:format-check"],
837                vec!["shuck:lint-fix", "shuck:format"],
838            ),
839        ] {
840            assert!(messages(&config_with(language, &lint, &format)).is_empty());
841        }
842        for id in [
843            "rumdl:lint",
844            "oxfmt:lint",
845            "djlint:html:format-check",
846            "djlint:jinja:format-check",
847            "shuck:format-check",
848        ] {
849            let warnings = messages(&config_with("markdown", &[], &[id]));
850            assert_eq!(warnings.len(), 1);
851            assert!(warnings[0].contains("cannot format"), "{warnings:?}");
852        }
853    }
854
855    #[test]
856    fn a_withheld_section_names_neither_the_tool_nor_the_language() {
857        let mut config = config_with("python", &["blackk"], &["ruff:check"]);
858        config.values_withheld = true;
859        let messages = messages(&config);
860        assert_eq!(
861            messages,
862            vec![
863                "Unknown tool in code-block-tools.languages.<withheld>.lint: <withheld>",
864                "Tool in code-block-tools.languages.<withheld>.format cannot format: <withheld>",
865            ]
866        );
867        // A suggestion would say how close the withheld text came to a real id.
868        for message in &messages {
869            assert!(!message.contains("black"), "withheld text is inferable from: {message}");
870            assert!(!message.contains("ruff"), "withheld text is inferable from: {message}");
871            assert!(
872                !message.contains("python"),
873                "withheld text is inferable from: {message}"
874            );
875        }
876    }
877}