Skip to main content

codoseo_core/
check.rs

1//! Check IDs, severity and the per-page issue bitmask. The check metadata (title, severity,
2//! category) lives in the checks crate.
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Severity {
9    Critical,
10    Warning,
11    Notice,
12}
13
14impl Severity {
15    /// The severity named by its lowercase slug (`critical`, `warning`, `notice`), as it is
16    /// written in URLs, filters and the API.
17    pub fn from_slug(slug: &str) -> Option<Severity> {
18        match slug {
19            "critical" => Some(Severity::Critical),
20            "warning" => Some(Severity::Warning),
21            "notice" => Some(Severity::Notice),
22            _ => None,
23        }
24    }
25}
26
27/// One bit per check, stored as `pages.issues`. Bit numbers are never reused.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct IssueBits(pub u64);
30
31macro_rules! check_ids {
32    ($($variant:ident = $id:literal => $slug:literal,)+) => {
33        /// Every check, with its stable ID. The value is the bit in `IssueBits` and is never
34        /// reused; new checks are appended with the next number. The slug is the serde name.
35        #[repr(u8)]
36        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
37        pub enum CheckId {
38            $(
39                #[serde(rename = $slug)]
40                $variant = $id,
41            )+
42        }
43
44        impl CheckId {
45            /// All checks in ID order.
46            pub const ALL: [CheckId; { [$($slug),+].len() }] = [$(CheckId::$variant),+];
47
48            /// Stable name, the same as the serde name.
49            pub fn slug(self) -> &'static str {
50                match self {
51                    $(CheckId::$variant => $slug,)+
52                }
53            }
54        }
55    };
56}
57
58check_ids! {
59    Http4xx = 0 => "http_4xx",
60    Http5xx = 1 => "http_5xx",
61    FetchFailed = 2 => "fetch_failed",
62    RedirectLoop = 3 => "redirect_loop",
63    Redirected = 4 => "redirected",
64    RedirectChain = 5 => "redirect_chain",
65    Noindex = 6 => "noindex",
66    Canonicalised = 7 => "canonicalised",
67    CanonicalMissing = 8 => "canonical_missing",
68    BlockedByRobots = 9 => "blocked_by_robots",
69    CanonicalToNon200 = 10 => "canonical_to_non_200",
70    RobotsBlocksSite = 11 => "robots_blocks_site",
71    TitleMissing = 12 => "title_missing",
72    TitleTooLong = 13 => "title_too_long",
73    TitleTooShort = 14 => "title_too_short",
74    TitleMultiple = 15 => "title_multiple",
75    TitleDuplicate = 16 => "title_duplicate",
76    DescriptionMissing = 17 => "description_missing",
77    DescriptionTooLong = 18 => "description_too_long",
78    DescriptionTooShort = 19 => "description_too_short",
79    DescriptionDuplicate = 20 => "description_duplicate",
80    H1Missing = 21 => "h1_missing",
81    H1Multiple = 22 => "h1_multiple",
82    H1Duplicate = 23 => "h1_duplicate",
83    ThinContent = 24 => "thin_content",
84    ContentDuplicate = 25 => "content_duplicate",
85    ImagesMissingAlt = 26 => "images_missing_alt",
86    LinksToBroken = 27 => "links_to_broken",
87    LinksToRedirect = 28 => "links_to_redirect",
88    Orphan = 29 => "orphan",
89    NoInternalOutlinks = 30 => "no_internal_outlinks",
90    NofollowInternalLinks = 31 => "nofollow_internal_links",
91    DeepPage = 32 => "deep_page",
92    SitemapNon200 = 33 => "sitemap_non_200",
93    SitemapNoindex = 34 => "sitemap_noindex",
94    SitemapCanonicalised = 35 => "sitemap_canonicalised",
95    NotInSitemap = 36 => "not_in_sitemap",
96    SitemapMissing = 37 => "sitemap_missing",
97    MixedContent = 38 => "mixed_content",
98    NotHttps = 39 => "not_https",
99    SlowResponse = 40 => "slow_response",
100    OgMissing = 41 => "og_missing",
101    JsonldInvalid = 42 => "jsonld_invalid",
102    HreflangMissingSelf = 43 => "hreflang_missing_self",
103}
104
105// IDs are unique, below 64, and `ALL` lists them in ascending order. Each ID is written
106// out in the table above, so moving or renumbering a variant is a visible edit that the
107// pinned table in tests/check_ids.rs catches.
108const _: () = {
109    let mut i = 0;
110    while i < CheckId::ALL.len() {
111        assert!((CheckId::ALL[i] as u8) < 64, "check ID is 64 or more");
112        if i > 0 {
113            assert!(
114                (CheckId::ALL[i - 1] as u8) < (CheckId::ALL[i] as u8),
115                "check IDs must be unique and in ascending order"
116            );
117        }
118        i += 1;
119    }
120};
121
122impl CheckId {
123    /// The bit number in `IssueBits`.
124    pub const fn bit(self) -> u8 {
125        self as u8
126    }
127
128    pub fn from_bit(bit: u8) -> Option<CheckId> {
129        CheckId::ALL.into_iter().find(|c| c.bit() == bit)
130    }
131
132    /// The check with this slug, if this version knows it.
133    pub fn from_slug(slug: &str) -> Option<CheckId> {
134        CheckId::ALL.into_iter().find(|c| c.slug() == slug)
135    }
136}
137
138impl IssueBits {
139    pub fn set_check(&mut self, id: CheckId) {
140        self.set(id.bit());
141    }
142
143    pub fn has_check(&self, id: CheckId) -> bool {
144        self.has(id.bit())
145    }
146
147    pub fn set(&mut self, bit: u8) {
148        assert!(bit < 64, "check bit {bit} out of range");
149        self.0 |= 1 << bit;
150    }
151
152    pub fn has(&self, bit: u8) -> bool {
153        bit < 64 && self.0 & (1 << bit) != 0
154    }
155
156    pub fn iter(&self) -> impl Iterator<Item = u8> + '_ {
157        (0..64u8).filter(|b| self.has(*b))
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn issue_bits_set_and_test() {
167        let mut b = IssueBits::default();
168        b.set(0);
169        b.set(63);
170        assert!(b.has(0));
171        assert!(b.has(63));
172        assert!(!b.has(1));
173        assert_eq!(b.iter().collect::<Vec<_>>(), vec![0, 63]);
174    }
175
176    #[test]
177    #[should_panic(expected = "check bit 64 out of range")]
178    fn issue_bit_64_is_rejected() {
179        IssueBits::default().set(64);
180    }
181}