Skip to main content

alint_core/
category.rs

1//! The rule-category vocabulary.
2//!
3//! `Category` is the single, closed, typed source of truth for the rule
4//! taxonomy. A rule kind can belong to several categories (many-to-many); the
5//! associations live in `docs/rules.md` `**Categories:**` lines and are
6//! generated into an in-crate table (`alint-rules`), but the VOCABULARY, its
7//! display order, its titles, and its URL slugs live here so every consumer
8//! (facts.json, docs-export, gen-model, the CLI) imports one definition.
9//!
10//! Variants are declared in DISPLAY order, which equals the `## ` family-heading
11//! sequence in `docs/rules.md`. The derived `Ord` therefore sorts categories
12//! into display order, and [`Category::order`] returns that position. An xtask
13//! gate asserts the declaration order matches the rules.md H2 sequence, and that
14//! [`Category::slug`] equals `docs_export::slugify(title)`.
15
16/// A rule category (a "family" in the docs). Closed set: adding one is a
17/// deliberate edit here plus a title/slug review, not an accidental string.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum Category {
20    Existence,
21    Content,
22    StructuredQuery,
23    Naming,
24    TextHygiene,
25    SecurityUnicodeSanity,
26    Encoding,
27    Structure,
28    PortableMetadata,
29    UnixMetadata,
30    GitHygiene,
31    CrossFile,
32    PluginTier1,
33}
34
35impl Category {
36    /// Every category, in display order.
37    pub const ALL: [Category; 13] = [
38        Category::Existence,
39        Category::Content,
40        Category::StructuredQuery,
41        Category::Naming,
42        Category::TextHygiene,
43        Category::SecurityUnicodeSanity,
44        Category::Encoding,
45        Category::Structure,
46        Category::PortableMetadata,
47        Category::UnixMetadata,
48        Category::GitHygiene,
49        Category::CrossFile,
50        Category::PluginTier1,
51    ];
52
53    /// All categories, in display order.
54    pub fn all() -> &'static [Category] {
55        &Self::ALL
56    }
57
58    /// The display title, matching the `## ` heading in `docs/rules.md` exactly.
59    pub fn title(self) -> &'static str {
60        match self {
61            Category::Existence => "Existence",
62            Category::Content => "Content",
63            Category::StructuredQuery => "Structured query",
64            Category::Naming => "Naming",
65            Category::TextHygiene => "Text hygiene",
66            Category::SecurityUnicodeSanity => "Security / Unicode sanity",
67            Category::Encoding => "Encoding",
68            Category::Structure => "Structure",
69            Category::PortableMetadata => "Portable metadata",
70            Category::UnixMetadata => "Unix metadata",
71            Category::GitHygiene => "Git hygiene",
72            Category::CrossFile => "Cross-file",
73            Category::PluginTier1 => "Plugin (tier 1)",
74        }
75    }
76
77    /// The URL slug. Must equal `docs_export::slugify(self.title())` (gated).
78    pub fn slug(self) -> &'static str {
79        match self {
80            Category::Existence => "existence",
81            Category::Content => "content",
82            Category::StructuredQuery => "structured-query",
83            Category::Naming => "naming",
84            Category::TextHygiene => "text-hygiene",
85            Category::SecurityUnicodeSanity => "security-unicode-sanity",
86            Category::Encoding => "encoding",
87            Category::Structure => "structure",
88            Category::PortableMetadata => "portable-metadata",
89            Category::UnixMetadata => "unix-metadata",
90            Category::GitHygiene => "git-hygiene",
91            Category::CrossFile => "cross-file",
92            Category::PluginTier1 => "plugin-tier-1",
93        }
94    }
95
96    /// Zero-based display position (index into [`Category::ALL`]).
97    ///
98    /// Exhaustive `match` on purpose: adding a variant is a compile error here
99    /// (as in `title`/`slug`), and the `order() == ALL index` test guards these
100    /// indices against a reorder of `ALL`. No panic path, unlike
101    /// `ALL.position(..).unwrap()`.
102    pub fn order(self) -> usize {
103        match self {
104            Category::Existence => 0,
105            Category::Content => 1,
106            Category::StructuredQuery => 2,
107            Category::Naming => 3,
108            Category::TextHygiene => 4,
109            Category::SecurityUnicodeSanity => 5,
110            Category::Encoding => 6,
111            Category::Structure => 7,
112            Category::PortableMetadata => 8,
113            Category::UnixMetadata => 9,
114            Category::GitHygiene => 10,
115            Category::CrossFile => 11,
116            Category::PluginTier1 => 12,
117        }
118    }
119
120    /// Parse a category from its display title (the form used on a
121    /// `**Categories:**` line). Case- and surrounding-whitespace-sensitive on
122    /// the canonical title.
123    pub fn from_title(title: &str) -> Option<Category> {
124        let t = title.trim();
125        Self::ALL.iter().copied().find(|c| c.title() == t)
126    }
127
128    /// Parse a category from its URL slug.
129    pub fn from_slug(slug: &str) -> Option<Category> {
130        Self::ALL.iter().copied().find(|c| c.slug() == slug)
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn all_has_thirteen_in_declaration_order() {
140        assert_eq!(Category::ALL.len(), 13);
141        for (i, c) in Category::ALL.iter().enumerate() {
142            assert_eq!(c.order(), i, "order() must equal ALL index for {c:?}");
143        }
144    }
145
146    #[test]
147    fn ord_matches_display_order() {
148        let mut sorted = Category::ALL;
149        sorted.sort();
150        assert_eq!(
151            sorted,
152            Category::ALL,
153            "derived Ord must equal display order"
154        );
155        assert!(Category::Existence < Category::PluginTier1);
156    }
157
158    #[test]
159    fn titles_and_slugs_are_unique() {
160        let mut titles: Vec<&str> = Category::ALL.iter().map(|c| c.title()).collect();
161        titles.sort_unstable();
162        titles.dedup();
163        assert_eq!(titles.len(), 13, "titles must be unique");
164
165        let mut slugs: Vec<&str> = Category::ALL.iter().map(|c| c.slug()).collect();
166        slugs.sort_unstable();
167        slugs.dedup();
168        assert_eq!(slugs.len(), 13, "slugs must be unique");
169    }
170
171    #[test]
172    fn from_title_round_trips_and_trims() {
173        for c in Category::ALL {
174            assert_eq!(Category::from_title(c.title()), Some(c));
175            assert_eq!(Category::from_title(&format!("  {}  ", c.title())), Some(c));
176        }
177        assert_eq!(Category::from_title("security / unicode sanity"), None); // case-sensitive
178        assert_eq!(Category::from_title("Nonsense"), None);
179    }
180
181    #[test]
182    fn from_slug_round_trips() {
183        for c in Category::ALL {
184            assert_eq!(Category::from_slug(c.slug()), Some(c));
185        }
186        assert_eq!(Category::from_slug("nope"), None);
187    }
188}