whiskers.git / crates / whiskers-icons / src / allowlist.rs

allowlist.txt, parsed. The file is the data; this is only its reader.

3use std::sync::OnceLock;

What the allowlist says about one icon.

6#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7pub enum Verdict {
8    Admitted,
9    Excluded,
10}

A category of the pack, as the pack's own README names it, and whether it was excluded whole.

13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct Category {
15    pub name: &'static str,

Why the whole category is out, if it is.

17    pub excluded_because: Option<&'static str>,
18}

One icon of the pack and the decision about it.

21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct Entry {
23    pub name: &'static str,
24    pub category: &'static str,
25    pub verdict: Verdict,

For an excluded icon, why.

27    pub reason: Option<&'static str>,
28}
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct ParseError {
32    pub line: usize,
33    pub why: &'static str,
34}
35
36#[derive(Debug)]
37pub struct Allowlist {
38    categories: Vec<Category>,
39    entries: Vec<Entry>,
40}
41
42impl Allowlist {

Reads an allowlist. Refuses a line it cannot place, an excluded line without a reason, an admitted line in a category that is excluded whole, and a name listed twice.

45    pub fn parse(text: &'static str) -> Result<Allowlist, ParseError> {
46        let mut categories: Vec<Category> = Vec::new();
47        let mut entries: Vec<Entry> = Vec::new();
48        for (i, raw) in text.lines().enumerate() {
49            let line = i + 1;
50            let l = raw.trim();
51            if l.is_empty() || l.starts_with('#') {
52                continue;
53            }
54            if let Some(rest) = l.strip_prefix('[') {
55                let (name, tail) = rest.split_once(']').ok_or(ParseError { line, why: "a category without its closing bracket" })?;
56                let excluded_because = match tail.trim() {
57                    "" => None,
58                    t => Some(t.strip_prefix("EXCLUDED:").ok_or(ParseError { line, why: "text after a category that is not EXCLUDED: and a reason" })?.trim()),
59                };
60                if excluded_because == Some("") {
61                    return Err(ParseError { line, why: "an excluded category without a reason" });
62                }
63                categories.push(Category { name, excluded_because });
64                continue;
65            }
66            let cat = categories.last().ok_or(ParseError { line, why: "an icon before any category" })?;
67            let (mark, rest) = l.split_at(1);
68            let rest = rest.trim();
69            let (name, reason) = match rest.split_once(char::is_whitespace) {
70                Some((n, r)) => (n, Some(r.trim())),
71                None => (rest, None),
72            };
73            if name.is_empty() || !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
74                return Err(ParseError { line, why: "not an icon name" });
75            }
76            let verdict = match mark {
77                "+" => Verdict::Admitted,
78                "-" => Verdict::Excluded,
79                _ => return Err(ParseError { line, why: "a line that is neither + nor -" }),
80            };
81            if verdict == Verdict::Admitted && (cat.excluded_because.is_some() || reason.is_some()) {
82                return Err(ParseError { line, why: "an admitted icon in an excluded category, or with a trailing note" });
83            }
84            if verdict == Verdict::Excluded && reason.is_none() {
85                return Err(ParseError { line, why: "an excluded icon without a reason" });
86            }
87            if entries.iter().any(|e| e.name == name) {
88                return Err(ParseError { line, why: "an icon listed twice" });
89            }
90            entries.push(Entry { name, category: cat.name, verdict, reason });
91        }
92        Ok(Allowlist { categories, entries })
93    }
95    pub fn entries(&self) -> &[Entry] {
96        &self.entries
97    }
98
99    pub fn categories(&self) -> &[Category] {
100        &self.categories
101    }
102}

The allowlist shipped in this crate. A file that does not parse is a build error found by the tests, so reaching a panic here means the tests were not run.

106pub fn allowlist() -> &'static Allowlist {
107    static LIST: OnceLock<Allowlist> = OnceLock::new();
108    LIST.get_or_init(|| Allowlist::parse(include_str!("../allowlist.txt")).expect("allowlist.txt parses (crates/whiskers-icons tests)"))
109}