1//! `allowlist.txt`, parsed. The file is the data; this is only its reader. 2 3use std::sync::OnceLock; 4 5/// What the allowlist says about one icon. 6#[derive(Clone, Copy, Debug, PartialEq, Eq)] 7pub enum Verdict { 8 Admitted, 9 Excluded, 10} 11 12/// 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, 16 /// Why the whole category is out, if it is. 17 pub excluded_because: Option<&'static str>, 18} 19 20/// 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, 26 /// For an excluded icon, why. 27 pub reason: Option<&'static str>, 28} 29 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 { 43 /// Reads an allowlist. Refuses a line it cannot place, an excluded line without a reason, an admitted 44 /// 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 } 94 95 pub fn entries(&self) -> &[Entry] { 96 &self.entries 97 } 98 99 pub fn categories(&self) -> &[Category] { 100 &self.categories 101 } 102} 103 104/// The allowlist shipped in this crate. A file that does not parse is a build error found by the tests, so 105/// 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}