Skip to main content

rumdl_lib/config/
registry.rs

1use std::sync::LazyLock;
2
3use crate::rule::Rule;
4
5use super::flavor::normalize_key;
6
7/// Lazily-initialized default `RuleRegistry` built from rules with default config.
8///
9/// Rule config schemas (valid keys, types, aliases) are intrinsic to each rule type
10/// and do not change based on runtime configuration. This static registry avoids
11/// repeatedly constructing 67+ rule instances just to extract their schemas.
12static DEFAULT_REGISTRY: LazyLock<RuleRegistry> = LazyLock::new(|| {
13    let default_config = super::types::Config::default();
14    let rules = crate::rules::all_rules(&default_config);
15    RuleRegistry::from_rules(&rules)
16});
17
18/// Returns a reference to the lazily-initialized default `RuleRegistry`.
19///
20/// Use this instead of `all_rules(&Config::default())` + `RuleRegistry::from_rules()`
21/// when you only need rule metadata (names, config schemas, aliases) rather than
22/// configured rule instances for linting.
23pub fn default_registry() -> &'static RuleRegistry {
24    &DEFAULT_REGISTRY
25}
26
27/// Registry of all known rules and their config schemas
28pub struct RuleRegistry {
29    /// Map of rule name (e.g. "MD013") to set of valid config keys and their TOML value types
30    pub rule_schemas: std::collections::BTreeMap<String, toml::map::Map<String, toml::Value>>,
31    /// Map of rule name to config key aliases
32    pub rule_aliases: std::collections::BTreeMap<String, std::collections::HashMap<String, String>>,
33}
34
35impl RuleRegistry {
36    /// Build a registry from a list of rules
37    pub fn from_rules(rules: &[Box<dyn Rule>]) -> Self {
38        let mut rule_schemas = std::collections::BTreeMap::new();
39        let mut rule_aliases = std::collections::BTreeMap::new();
40
41        for rule in rules {
42            let norm_name = if let Some((name, toml::Value::Table(mut table))) = rule.default_config_section() {
43                let norm_name = normalize_key(&name); // Normalize the name from default_config_section
44                // Overwrite polymorphic keys with the sentinel so the validator skips
45                // type checking for fields whose deserializer accepts multiple TOML
46                // types. The clean default is preserved for `rumdl config --defaults`
47                // because that path calls `default_config_section()` directly.
48                for key in rule.polymorphic_config_keys() {
49                    table.insert(
50                        (*key).to_string(),
51                        crate::rule_config_serde::polymorphic_sentinel_value(),
52                    );
53                }
54                rule_schemas.insert(norm_name.clone(), table);
55                norm_name
56            } else {
57                let norm_name = normalize_key(rule.name()); // Normalize the name from rule.name()
58                rule_schemas.insert(norm_name.clone(), toml::map::Map::new());
59                norm_name
60            };
61
62            // Store aliases if the rule provides them
63            if let Some(aliases) = rule.config_aliases() {
64                rule_aliases.insert(norm_name, aliases);
65            }
66        }
67
68        RuleRegistry {
69            rule_schemas,
70            rule_aliases,
71        }
72    }
73
74    /// Get all known rule names
75    pub fn rule_names(&self) -> std::collections::BTreeSet<String> {
76        self.rule_schemas.keys().cloned().collect()
77    }
78
79    /// Get the valid configuration keys for a rule, including both original and normalized variants
80    pub fn config_keys_for(&self, rule: &str) -> Option<std::collections::BTreeSet<String>> {
81        self.rule_schemas.get(rule).map(|schema| {
82            let mut all_keys = std::collections::BTreeSet::new();
83
84            // Always allow 'severity' and 'enabled' for any rule
85            all_keys.insert("severity".to_string());
86            all_keys.insert("enabled".to_string());
87
88            // Add original keys from schema
89            for key in schema.keys() {
90                all_keys.insert(key.clone());
91            }
92
93            // Add normalized variants for markdownlint compatibility
94            for key in schema.keys() {
95                // Add kebab-case variant
96                all_keys.insert(key.replace('_', "-"));
97                // Add snake_case variant
98                all_keys.insert(key.replace('-', "_"));
99                // Add normalized variant
100                all_keys.insert(normalize_key(key));
101            }
102
103            // Add any aliases defined by the rule
104            if let Some(aliases) = self.rule_aliases.get(rule) {
105                for alias_key in aliases.keys() {
106                    all_keys.insert(alias_key.clone());
107                    // Also add normalized variants of the alias
108                    all_keys.insert(alias_key.replace('_', "-"));
109                    all_keys.insert(alias_key.replace('-', "_"));
110                    all_keys.insert(normalize_key(alias_key));
111                }
112            }
113
114            all_keys
115        })
116    }
117
118    /// Get the expected value type for a rule's configuration key, trying variants.
119    /// Returns `None` for sentinel values (nullable Option fields, polymorphic fields
120    /// that accept multiple TOML types), which signals the caller to skip type checking
121    /// for that key while still recognizing the key as valid.
122    pub fn expected_value_for(&self, rule: &str, key: &str) -> Option<&toml::Value> {
123        let schema = self.rule_schemas.get(rule)?;
124
125        // Check if this key is an alias
126        if let Some(aliases) = self.rule_aliases.get(rule)
127            && let Some(canonical_key) = aliases.get(key)
128            && let Some(value) = schema.get(canonical_key)
129        {
130            return filter_type_check_sentinels(value);
131        }
132
133        // Try the original key
134        if let Some(value) = schema.get(key) {
135            return filter_type_check_sentinels(value);
136        }
137
138        // Try key variants
139        let key_variants = [
140            key.replace('-', "_"), // Convert kebab-case to snake_case
141            key.replace('_', "-"), // Convert snake_case to kebab-case
142            normalize_key(key),    // Normalized key (lowercase, kebab-case)
143        ];
144
145        for variant in &key_variants {
146            if let Some(value) = schema.get(variant) {
147                return filter_type_check_sentinels(value);
148            }
149        }
150
151        None
152    }
153
154    /// Resolve any rule name (canonical or alias) to its canonical form
155    /// Returns None if the rule name is not recognized
156    ///
157    /// Resolution order:
158    /// 1. Direct canonical name match
159    /// 2. Static aliases (built-in markdownlint aliases)
160    pub fn resolve_rule_name(&self, name: &str) -> Option<String> {
161        // Try normalized canonical name first
162        let normalized = normalize_key(name);
163        if self.rule_schemas.contains_key(&normalized) {
164            return Some(normalized);
165        }
166
167        // Try static alias resolution (O(1) perfect hash lookup)
168        resolve_rule_name_alias(name).map(std::string::ToString::to_string)
169    }
170}
171
172/// Returns `None` if the value is a sentinel that signals "skip type check"
173/// (nullable Option fields, polymorphic fields that accept multiple types).
174/// Otherwise returns `Some(value)` so the validator can compare types.
175fn filter_type_check_sentinels(value: &toml::Value) -> Option<&toml::Value> {
176    if crate::rule_config_serde::is_nullable_sentinel(value) || crate::rule_config_serde::is_polymorphic_sentinel(value)
177    {
178        None
179    } else {
180        Some(value)
181    }
182}
183
184/// Compile-time perfect hash map for O(1) rule alias lookups
185/// Uses phf for zero-cost abstraction - compiles to direct jumps
186pub static RULE_ALIAS_MAP: phf::Map<&'static str, &'static str> = phf::phf_map! {
187    // Canonical names (identity mapping for consistency)
188    "MD001" => "MD001",
189    "MD003" => "MD003",
190    "MD004" => "MD004",
191    "MD005" => "MD005",
192    "MD007" => "MD007",
193    "MD009" => "MD009",
194    "MD010" => "MD010",
195    "MD011" => "MD011",
196    "MD012" => "MD012",
197    "MD013" => "MD013",
198    "MD014" => "MD014",
199    "MD018" => "MD018",
200    "MD019" => "MD019",
201    "MD020" => "MD020",
202    "MD021" => "MD021",
203    "MD022" => "MD022",
204    "MD023" => "MD023",
205    "MD024" => "MD024",
206    "MD025" => "MD025",
207    "MD026" => "MD026",
208    "MD027" => "MD027",
209    "MD028" => "MD028",
210    "MD029" => "MD029",
211    "MD030" => "MD030",
212    "MD031" => "MD031",
213    "MD032" => "MD032",
214    "MD033" => "MD033",
215    "MD034" => "MD034",
216    "MD035" => "MD035",
217    "MD036" => "MD036",
218    "MD037" => "MD037",
219    "MD038" => "MD038",
220    "MD039" => "MD039",
221    "MD040" => "MD040",
222    "MD041" => "MD041",
223    "MD042" => "MD042",
224    "MD043" => "MD043",
225    "MD044" => "MD044",
226    "MD045" => "MD045",
227    "MD046" => "MD046",
228    "MD047" => "MD047",
229    "MD048" => "MD048",
230    "MD049" => "MD049",
231    "MD050" => "MD050",
232    "MD051" => "MD051",
233    "MD052" => "MD052",
234    "MD053" => "MD053",
235    "MD054" => "MD054",
236    "MD055" => "MD055",
237    "MD056" => "MD056",
238    "MD057" => "MD057",
239    "MD058" => "MD058",
240    "MD059" => "MD059",
241    "MD060" => "MD060",
242    "MD061" => "MD061",
243    "MD062" => "MD062",
244    "MD063" => "MD063",
245    "MD064" => "MD064",
246    "MD065" => "MD065",
247    "MD066" => "MD066",
248    "MD067" => "MD067",
249    "MD068" => "MD068",
250    "MD069" => "MD069",
251    "MD070" => "MD070",
252    "MD071" => "MD071",
253    "MD072" => "MD072",
254    "MD073" => "MD073",
255    "MD074" => "MD074",
256    "MD075" => "MD075",
257    "MD076" => "MD076",
258    "MD077" => "MD077",
259    "MD078" => "MD078",
260    "MD079" => "MD079",
261    "MD080" => "MD080",
262    "MD081" => "MD081",
263    "MD082" => "MD082",
264    "MD083" => "MD083",
265    "MD084" => "MD084",
266    "MD085" => "MD085",
267    "MD086" => "MD086",
268    "MD087" => "MD087",
269
270    // Aliases (hyphen format)
271    "HEADING-INCREMENT" => "MD001",
272    "HEADING-STYLE" => "MD003",
273    "UL-STYLE" => "MD004",
274    "LIST-INDENT" => "MD005",
275    "UL-INDENT" => "MD007",
276    "NO-TRAILING-SPACES" => "MD009",
277    "NO-HARD-TABS" => "MD010",
278    "NO-REVERSED-LINKS" => "MD011",
279    "NO-MULTIPLE-BLANKS" => "MD012",
280    "LINE-LENGTH" => "MD013",
281    "COMMANDS-SHOW-OUTPUT" => "MD014",
282    "NO-MISSING-SPACE-ATX" => "MD018",
283    "NO-MULTIPLE-SPACE-ATX" => "MD019",
284    "NO-MISSING-SPACE-CLOSED-ATX" => "MD020",
285    "NO-MULTIPLE-SPACE-CLOSED-ATX" => "MD021",
286    "BLANKS-AROUND-HEADINGS" => "MD022",
287    "HEADING-START-LEFT" => "MD023",
288    "NO-DUPLICATE-HEADING" => "MD024",
289    "SINGLE-TITLE" => "MD025",
290    "SINGLE-H1" => "MD025",
291    "NO-TRAILING-PUNCTUATION" => "MD026",
292    "NO-MULTIPLE-SPACE-BLOCKQUOTE" => "MD027",
293    "NO-BLANKS-BLOCKQUOTE" => "MD028",
294    "OL-PREFIX" => "MD029",
295    "LIST-MARKER-SPACE" => "MD030",
296    "BLANKS-AROUND-FENCES" => "MD031",
297    "BLANKS-AROUND-LISTS" => "MD032",
298    "NO-INLINE-HTML" => "MD033",
299    "NO-BARE-URLS" => "MD034",
300    "HR-STYLE" => "MD035",
301    "NO-EMPHASIS-AS-HEADING" => "MD036",
302    "NO-SPACE-IN-EMPHASIS" => "MD037",
303    "NO-SPACE-IN-CODE" => "MD038",
304    "NO-SPACE-IN-LINKS" => "MD039",
305    "FENCED-CODE-LANGUAGE" => "MD040",
306    "FIRST-LINE-HEADING" => "MD041",
307    "FIRST-LINE-H1" => "MD041",
308    "NO-EMPTY-LINKS" => "MD042",
309    "REQUIRED-HEADINGS" => "MD043",
310    "PROPER-NAMES" => "MD044",
311    "NO-ALT-TEXT" => "MD045",
312    "CODE-BLOCK-STYLE" => "MD046",
313    "SINGLE-TRAILING-NEWLINE" => "MD047",
314    "CODE-FENCE-STYLE" => "MD048",
315    "EMPHASIS-STYLE" => "MD049",
316    "STRONG-STYLE" => "MD050",
317    "LINK-FRAGMENTS" => "MD051",
318    "REFERENCE-LINKS-IMAGES" => "MD052",
319    "LINK-IMAGE-REFERENCE-DEFINITIONS" => "MD053",
320    "LINK-IMAGE-STYLE" => "MD054",
321    "TABLE-PIPE-STYLE" => "MD055",
322    "TABLE-COLUMN-COUNT" => "MD056",
323    "EXISTING-RELATIVE-LINKS" => "MD057",
324    "BLANKS-AROUND-TABLES" => "MD058",
325    "DESCRIPTIVE-LINK-TEXT" => "MD059",
326    "TABLE-CELL-ALIGNMENT" => "MD060",
327    "TABLE-FORMAT" => "MD060",
328    "FORBIDDEN-TERMS" => "MD061",
329    "LINK-DESTINATION-WHITESPACE" => "MD062",
330    "NO-SPACE-IN-LINK-DESTINATION" => "MD062",
331    "HEADING-CAPITALIZATION" => "MD063",
332    "NO-MULTIPLE-CONSECUTIVE-SPACES" => "MD064",
333    "BLANKS-AROUND-HORIZONTAL-RULES" => "MD065",
334    "FOOTNOTE-VALIDATION" => "MD066",
335    "FOOTNOTE-DEFINITION-ORDER" => "MD067",
336    "EMPTY-FOOTNOTE-DEFINITION" => "MD068",
337    "NO-DUPLICATE-LIST-MARKERS" => "MD069",
338    "NESTED-CODE-FENCE" => "MD070",
339    "BLANK-LINE-AFTER-FRONTMATTER" => "MD071",
340    "FRONTMATTER-KEY-SORT" => "MD072",
341    "TOC-VALIDATION" => "MD073",
342    "MKDOCS-NAV" => "MD074",
343    "ORPHANED-TABLE-ROWS" => "MD075",
344    "LIST-ITEM-SPACING" => "MD076",
345    "LIST-CONTINUATION-INDENT" => "MD077",
346    "MISSING-CHUNK-LABELS" => "MD078",
347    "CHUNK-LABEL-SPACES" => "MD079",
348    "HEADING-ANCHOR-COLLISION" => "MD080",
349    "NO-EXCESSIVE-EMPHASIS" => "MD081",
350    "NO-EMPTY-SECTIONS" => "MD082",
351    "MOJIBAKE" => "MD083",
352    "INVISIBLE-CHARACTERS" => "MD084",
353    "PARAGRAPH-CONTINUATION-INDENT" => "MD085",
354    "NO-UNCLOSED-COMMENTS" => "MD086",
355    "UNUSED-DISABLE-COMMENT" => "MD087",
356};
357
358/// The name rumdl uses when it writes a rule name itself, one per rule.
359///
360/// A rule can answer to several aliases, so the readable name it is given in
361/// generated output (a disable comment written by the language server, the name
362/// `rumdl rule` reports) has to be chosen rather than derived. The choice is the
363/// alias each rule's documentation lists first.
364pub static RULE_PRIMARY_ALIAS: phf::Map<&'static str, &'static str> = phf::phf_map! {
365    "MD001" => "heading-increment",
366    "MD003" => "heading-style",
367    "MD004" => "ul-style",
368    "MD005" => "list-indent",
369    "MD007" => "ul-indent",
370    "MD009" => "no-trailing-spaces",
371    "MD010" => "no-hard-tabs",
372    "MD011" => "no-reversed-links",
373    "MD012" => "no-multiple-blanks",
374    "MD013" => "line-length",
375    "MD014" => "commands-show-output",
376    "MD018" => "no-missing-space-atx",
377    "MD019" => "no-multiple-space-atx",
378    "MD020" => "no-missing-space-closed-atx",
379    "MD021" => "no-multiple-space-closed-atx",
380    "MD022" => "blanks-around-headings",
381    "MD023" => "heading-start-left",
382    "MD024" => "no-duplicate-heading",
383    "MD025" => "single-title",
384    "MD026" => "no-trailing-punctuation",
385    "MD027" => "no-multiple-space-blockquote",
386    "MD028" => "no-blanks-blockquote",
387    "MD029" => "ol-prefix",
388    "MD030" => "list-marker-space",
389    "MD031" => "blanks-around-fences",
390    "MD032" => "blanks-around-lists",
391    "MD033" => "no-inline-html",
392    "MD034" => "no-bare-urls",
393    "MD035" => "hr-style",
394    "MD036" => "no-emphasis-as-heading",
395    "MD037" => "no-space-in-emphasis",
396    "MD038" => "no-space-in-code",
397    "MD039" => "no-space-in-links",
398    "MD040" => "fenced-code-language",
399    "MD041" => "first-line-heading",
400    "MD042" => "no-empty-links",
401    "MD043" => "required-headings",
402    "MD044" => "proper-names",
403    "MD045" => "no-alt-text",
404    "MD046" => "code-block-style",
405    "MD047" => "single-trailing-newline",
406    "MD048" => "code-fence-style",
407    "MD049" => "emphasis-style",
408    "MD050" => "strong-style",
409    "MD051" => "link-fragments",
410    "MD052" => "reference-links-images",
411    "MD053" => "link-image-reference-definitions",
412    "MD054" => "link-image-style",
413    "MD055" => "table-pipe-style",
414    "MD056" => "table-column-count",
415    "MD057" => "existing-relative-links",
416    "MD058" => "blanks-around-tables",
417    "MD059" => "descriptive-link-text",
418    "MD060" => "table-format",
419    "MD061" => "forbidden-terms",
420    "MD062" => "link-destination-whitespace",
421    "MD063" => "heading-capitalization",
422    "MD064" => "no-multiple-consecutive-spaces",
423    "MD065" => "blanks-around-horizontal-rules",
424    "MD066" => "footnote-validation",
425    "MD067" => "footnote-definition-order",
426    "MD068" => "empty-footnote-definition",
427    "MD069" => "no-duplicate-list-markers",
428    "MD070" => "nested-code-fence",
429    "MD071" => "blank-line-after-frontmatter",
430    "MD072" => "frontmatter-key-sort",
431    "MD073" => "toc-validation",
432    "MD074" => "mkdocs-nav",
433    "MD075" => "orphaned-table-rows",
434    "MD076" => "list-item-spacing",
435    "MD077" => "list-continuation-indent",
436    "MD078" => "missing-chunk-labels",
437    "MD079" => "chunk-label-spaces",
438    "MD080" => "heading-anchor-collision",
439    "MD081" => "no-excessive-emphasis",
440    "MD082" => "no-empty-sections",
441    "MD083" => "mojibake",
442    "MD084" => "invisible-characters",
443    "MD085" => "paragraph-continuation-indent",
444    "MD086" => "no-unclosed-comments",
445    "MD087" => "unused-disable-comment",
446};
447
448/// The readable name for a rule ID, or `None` for a name that is not a rule ID.
449///
450/// The argument is a canonical ID such as `MD013`; resolve an alias with
451/// [`resolve_rule_name_alias`] first.
452pub fn primary_alias(rule_id: &str) -> Option<&'static str> {
453    RULE_PRIMARY_ALIAS.get(rule_id).copied()
454}
455
456/// Resolve a rule name alias to its canonical form with O(1) perfect hash lookup
457/// Converts rule aliases (like "ul-style", "line-length") to canonical IDs (like "MD004", "MD013")
458/// Returns None if the rule name is not recognized
459pub fn resolve_rule_name_alias(key: &str) -> Option<&'static str> {
460    // Normalize: uppercase and replace underscores with hyphens
461    let normalized_key = key.to_ascii_uppercase().replace('_', "-");
462
463    // O(1) perfect hash lookup
464    RULE_ALIAS_MAP.get(normalized_key.as_str()).copied()
465}
466
467/// Resolves a rule name to its canonical ID, supporting both rule IDs and aliases.
468/// Returns the canonical ID (e.g., "MD001") for any valid input:
469/// - "MD001" → "MD001" (canonical)
470/// - "heading-increment" → "MD001" (alias)
471/// - "HEADING_INCREMENT" → "MD001" (case-insensitive, underscore variant)
472///
473/// For unknown names, falls back to normalization (uppercase for MDxxx pattern, otherwise kebab-case).
474pub fn resolve_rule_name(name: &str) -> String {
475    resolve_rule_name_alias(name).map_or_else(|| normalize_key(name), std::string::ToString::to_string)
476}
477
478/// Resolves a comma-separated list of rule names to canonical IDs.
479/// Handles CLI input like "MD001,line-length,heading-increment".
480/// Empty entries and whitespace are filtered out.
481pub fn resolve_rule_names(input: &str) -> std::collections::HashSet<String> {
482    input
483        .split(',')
484        .map(str::trim)
485        .filter(|s| !s.is_empty())
486        .map(resolve_rule_name)
487        .collect()
488}
489
490/// Checks if a rule name (or alias) is valid.
491/// Returns true if the name resolves to a known rule.
492/// Handles the special "all" value and all aliases.
493pub fn is_valid_rule_name(name: &str) -> bool {
494    // Check for special "all" value (case-insensitive)
495    if name.eq_ignore_ascii_case("all") {
496        return true;
497    }
498    resolve_rule_name_alias(name).is_some()
499}
500
501/// Canonicalizes a rule-name list in place: every entry is rewritten to its canonical
502/// rule ID via [`resolve_rule_name`], duplicates are removed (keeping first occurrence),
503/// and the special `"all"` keyword is preserved as-is (case-insensitive).
504///
505/// This enforces the runtime invariant that rule lists in `Config` (`enable`, `disable`,
506/// `extend_enable`, `extend_disable`, `fixable`, `unfixable`, and per-file ignore values)
507/// always contain canonical rule IDs. Consumers can therefore compare against
508/// `rule.name()` with simple string equality without needing alias resolution at every
509/// call site.
510///
511/// The operation is idempotent: running it twice produces the same result as once.
512pub fn canonicalize_rule_list_in_place(list: &mut Vec<String>) {
513    if list.is_empty() {
514        return;
515    }
516    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::with_capacity(list.len());
517    let mut out: Vec<String> = Vec::with_capacity(list.len());
518    for entry in list.drain(..) {
519        let canonical = if entry.eq_ignore_ascii_case("all") {
520            "all".to_string()
521        } else {
522            resolve_rule_name(&entry)
523        };
524        if seen.insert(canonical.clone()) {
525            out.push(canonical);
526        }
527    }
528    *list = out;
529}
530
531#[cfg(test)]
532mod primary_alias_tests {
533    use super::{RULE_ALIAS_MAP, RULE_PRIMARY_ALIAS, default_registry, primary_alias, resolve_rule_name_alias};
534
535    /// Every rule ID the alias map knows, paired with the aliases it answers to.
536    fn aliases_by_rule() -> std::collections::BTreeMap<&'static str, Vec<&'static str>> {
537        let mut by_rule: std::collections::BTreeMap<&'static str, Vec<&'static str>> =
538            std::collections::BTreeMap::new();
539        for (alias, rule_id) in RULE_ALIAS_MAP.entries() {
540            let entry = by_rule.entry(*rule_id).or_default();
541            if alias != rule_id {
542                entry.push(*alias);
543            }
544        }
545        by_rule
546    }
547
548    #[test]
549    fn every_rule_has_a_readable_name() {
550        let rule_ids = default_registry().rule_names();
551        assert!(
552            rule_ids.contains("MD013"),
553            "control: the registry lists rules by canonical ID, got {rule_ids:?}"
554        );
555        let missing: Vec<_> = rule_ids
556            .into_iter()
557            .filter(|rule_id| primary_alias(rule_id).is_none())
558            .collect();
559        assert!(
560            missing.is_empty(),
561            "these rules have no entry in RULE_PRIMARY_ALIAS: {missing:?}"
562        );
563    }
564
565    #[test]
566    fn a_readable_name_is_one_of_the_rules_own_aliases() {
567        let by_rule = aliases_by_rule();
568        for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
569            let aliases = by_rule
570                .get(rule_id)
571                .unwrap_or_else(|| panic!("{rule_id} has a readable name but is not in RULE_ALIAS_MAP"));
572            assert!(
573                aliases.iter().any(|alias| alias.eq_ignore_ascii_case(primary)),
574                "{rule_id}'s readable name '{primary}' is not one of its aliases {aliases:?}"
575            );
576        }
577    }
578
579    #[test]
580    fn a_readable_name_resolves_back_to_its_rule() {
581        for (rule_id, primary) in RULE_PRIMARY_ALIAS.entries() {
582            assert_eq!(
583                resolve_rule_name_alias(primary),
584                Some(*rule_id),
585                "'{primary}' must be usable anywhere a rule name is accepted"
586            );
587        }
588    }
589
590    #[test]
591    fn a_name_that_is_not_a_rule_id_has_no_readable_name() {
592        // Control: the lookup takes canonical IDs, so an alias or a typo answers None
593        // rather than something plausible.
594        assert_eq!(primary_alias("MD013"), Some("line-length"));
595        assert_eq!(primary_alias("line-length"), None);
596        assert_eq!(primary_alias("MD999"), None);
597    }
598}
599
600#[cfg(test)]
601mod canonicalize_tests {
602    use super::canonicalize_rule_list_in_place;
603
604    #[test]
605    fn rewrites_aliases_to_canonical_ids() {
606        let mut list = vec!["no-inline-html".to_string(), "line-length".to_string()];
607        canonicalize_rule_list_in_place(&mut list);
608        assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
609    }
610
611    #[test]
612    fn dedupes_alias_and_canonical_preserving_order() {
613        let mut list = vec!["MD033".to_string(), "no-inline-html".to_string(), "MD013".to_string()];
614        canonicalize_rule_list_in_place(&mut list);
615        assert_eq!(list, vec!["MD033".to_string(), "MD013".to_string()]);
616    }
617
618    #[test]
619    fn preserves_all_keyword_normalized() {
620        let mut list = vec!["ALL".to_string(), "MD013".to_string()];
621        canonicalize_rule_list_in_place(&mut list);
622        assert_eq!(list, vec!["all".to_string(), "MD013".to_string()]);
623    }
624
625    #[test]
626    fn is_idempotent() {
627        let mut list = vec!["no-inline-html".to_string(), "MD013".to_string()];
628        canonicalize_rule_list_in_place(&mut list);
629        let once = list.clone();
630        canonicalize_rule_list_in_place(&mut list);
631        assert_eq!(list, once);
632    }
633
634    #[test]
635    fn handles_empty_and_unknown_inputs() {
636        let mut empty: Vec<String> = Vec::new();
637        canonicalize_rule_list_in_place(&mut empty);
638        assert!(empty.is_empty());
639
640        let mut unknown = vec!["custom-rule".to_string(), "Custom-Rule".to_string()];
641        canonicalize_rule_list_in_place(&mut unknown);
642        // Both normalize to the same kebab-case form, so they dedupe.
643        assert_eq!(unknown, vec!["custom-rule".to_string()]);
644    }
645}