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