Skip to main content

sofie_check/rules/
allowances.rs

1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use sofie_model::ModelError;
6
7pub const ALLOW_FILE: &str = "dead-code-allow.toml";
8
9/// `dead-code-allow.toml`: `[[item]]` and `[[dependency]]` entries.
10///
11/// ```toml
12/// [[item]]
13/// package = "sofie-http"
14/// name = "Screens"
15/// reason = "Why it stays although nothing names it."
16/// ```
17pub struct AllowList {
18    pub(super) file: PathBuf,
19    pub(super) allowances: Vec<Allowance>,
20}
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub(super) enum Allowed {
24    Item,
25    Dependency,
26}
27
28pub(super) struct Allowance {
29    pub kind: Allowed,
30    pub package: String,
31    pub name: String,
32    pub line: usize,
33}
34
35impl AllowList {
36    pub fn read(root: &Path) -> Result<Self, ModelError> {
37        let file = root.join(ALLOW_FILE);
38        let text = read_optional(&file)?.unwrap_or_default();
39        Self::parse(&file, &text)
40    }
41
42    fn parse(file: &Path, text: &str) -> Result<Self, ModelError> {
43        let allowances = parse_entries(file, text, &["item", "dependency"], &["package", "name"])?
44            .into_iter()
45            .map(|entry| {
46                Ok(Allowance {
47                    kind: if entry.kind == "item" {
48                        Allowed::Item
49                    } else {
50                        Allowed::Dependency
51                    },
52                    package: entry.required(file, "package")?,
53                    name: entry.required(file, "name")?,
54                    line: entry.line,
55                })
56            })
57            .collect::<Result<_, ModelError>>()?;
58        Ok(Self {
59            file: file.to_path_buf(),
60            allowances,
61        })
62    }
63
64    /// An `[[item]]` entry to append to the file.
65    #[must_use]
66    pub fn item_entry(package: &str, name: &str, reason: &str) -> String {
67        format!("\n[[item]]\npackage = \"{package}\"\nname = \"{name}\"\nreason = \"{reason}\"\n")
68    }
69
70    /// Where the `[[item]]` entry allowing `name` of `package` starts, if there is one.
71    #[must_use]
72    pub fn item_line(&self, package: &str, name: &str) -> Option<usize> {
73        self.allowances
74            .iter()
75            .find(|allowance| {
76                allowance.kind == Allowed::Item
77                    && allowance.package == package
78                    && allowance.name == name
79            })
80            .map(|allowance| allowance.line)
81    }
82
83    /// The file's text without the entries starting at `lines` (from 1), each
84    /// up to the next entry, with the blank lines before it.
85    #[must_use]
86    pub fn without_entries(text: &str, lines: &[usize]) -> String {
87        let all: Vec<&str> = text.split_inclusive('\n').collect();
88        let mut kept: Vec<&str> = Vec::with_capacity(all.len());
89        let mut dropping = false;
90        for (index, line) in all.iter().enumerate() {
91            if line.trim_start().starts_with("[[") {
92                dropping = lines.contains(&(index + 1));
93                if dropping {
94                    while kept.last().is_some_and(|line| line.trim().is_empty()) {
95                        kept.pop();
96                    }
97                }
98            }
99            if !dropping {
100                kept.push(line);
101            }
102        }
103        kept.concat()
104    }
105
106    pub(super) fn allows(&self, kind: Allowed, package: &str, name: &str) -> bool {
107        self.allowances.iter().any(|allowance| {
108            allowance.kind == kind && allowance.package == package && allowance.name == name
109        })
110    }
111}
112
113pub(super) fn read_optional(file: &Path) -> Result<Option<String>, ModelError> {
114    match fs::read_to_string(file) {
115        Ok(text) => Ok(Some(text)),
116        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
117        Err(error) => Err(ModelError::in_file(
118            file,
119            format!("cannot read the file: {error}"),
120        )),
121    }
122}
123
124pub(super) struct Entry {
125    pub kind: String,
126    pub line: usize,
127    values: Vec<(String, String)>,
128}
129
130impl Entry {
131    pub(super) fn value(&self, key: &str) -> Option<&str> {
132        self.values
133            .iter()
134            .find(|(name, _)| name == key)
135            .map(|(_, value)| value.as_str())
136    }
137
138    pub(super) fn required(&self, file: &Path, key: &str) -> Result<String, ModelError> {
139        self.value(key).map(str::to_owned).ok_or_else(|| {
140            ModelError::new(file, self.line, format!("this entry needs `{key} = \"…\"`"))
141        })
142    }
143
144    fn set(&mut self, line: &str, keys: &[&str]) -> Result<(), String> {
145        let (key, value) = line.split_once('=').ok_or("write `key = \"value\"`")?;
146        let key = key.trim();
147        let value = plain_string(value.trim())
148            .ok_or("write the value as a quoted string without quotes or backslashes inside")?;
149        if key != "reason" && !keys.contains(&key) {
150            let names: Vec<String> = keys.iter().map(|key| format!("`{key}`")).collect();
151            return Err(format!("the keys are {} and `reason`", names.join(", ")));
152        }
153        if self.value(key).is_some() {
154            return Err("this key is already set in this entry".to_owned());
155        }
156        self.values.push((key.to_owned(), value));
157        Ok(())
158    }
159}
160
161/// Read without a TOML library: the file holds only `[[kind]]` tables of quoted strings.
162pub(super) fn parse_entries(
163    file: &Path,
164    text: &str,
165    kinds: &[&str],
166    keys: &[&str],
167) -> Result<Vec<Entry>, ModelError> {
168    let mut entries: Vec<Entry> = Vec::new();
169    for (index, line) in text.lines().enumerate() {
170        let error = |message: String| ModelError::new(file, index + 1, message);
171        let line = line.trim();
172        if line.is_empty() || line.starts_with('#') {
173            continue;
174        }
175        if let Some(kind) = table_kind(line, kinds) {
176            entries.push(Entry {
177                kind: kind.to_owned(),
178                line: index + 1,
179                values: Vec::new(),
180            });
181            continue;
182        }
183        let entry = entries.last_mut().ok_or_else(|| {
184            let tables: Vec<String> = kinds.iter().map(|kind| format!("`[[{kind}]]`")).collect();
185            error(format!("start an entry with {}", tables.join(" or ")))
186        })?;
187        entry.set(line, keys).map_err(error)?;
188    }
189    for entry in &entries {
190        let reason = entry.required(file, "reason")?;
191        if reason.trim().is_empty() {
192            return Err(ModelError::new(
193                file,
194                entry.line,
195                "this entry needs `reason = \"…\"`",
196            ));
197        }
198    }
199    Ok(entries)
200}
201
202fn table_kind<'kind>(line: &str, kinds: &[&'kind str]) -> Option<&'kind str> {
203    let name = line.strip_prefix("[[")?.strip_suffix("]]")?;
204    kinds.iter().copied().find(|kind| *kind == name)
205}
206
207fn plain_string(value: &str) -> Option<String> {
208    let inner = value.strip_prefix('"')?.strip_suffix('"')?;
209    (!inner.contains(['"', '\\'])).then(|| inner.to_owned())
210}
211
212#[cfg(test)]
213mod tests {
214    use std::path::Path;
215
216    use super::{AllowList, Allowed};
217
218    fn parse(text: &str) -> Result<AllowList, String> {
219        AllowList::parse(Path::new("dead-code-allow.toml"), text).map_err(|error| error.to_string())
220    }
221
222    #[test]
223    fn reads_items_and_dependencies_with_their_reasons() {
224        let list = parse(
225            "# Exceptions.\n\n[[item]]\npackage = \"sofie-http\"\nname = \"Screens\"\nreason = \"x\"\n\n\
226             [[dependency]]\npackage = \"sofie\"\nname = \"tokio\"\nreason = \"y\"\n",
227        )
228        .unwrap();
229
230        assert!(list.allows(Allowed::Item, "sofie-http", "Screens"));
231        assert!(list.allows(Allowed::Dependency, "sofie", "tokio"));
232        assert!(!list.allows(Allowed::Item, "sofie", "tokio"));
233        assert_eq!(list.allowances[1].line, 8);
234    }
235
236    #[test]
237    fn an_appended_entry_reads_back_and_drops_out_by_its_line() {
238        let text = format!(
239            "# Exceptions.\n{}{}",
240            AllowList::item_entry("shop", "Kept", "x"),
241            AllowList::item_entry("shop-contract", "Scaffolded", "y")
242        );
243        let list = parse(&text).unwrap();
244
245        let without = AllowList::without_entries(&text, &[list.allowances[1].line]);
246
247        assert!(list.allows(Allowed::Item, "shop-contract", "Scaffolded"));
248        assert_eq!(
249            without,
250            "# Exceptions.\n\n[[item]]\npackage = \"shop\"\nname = \"Kept\"\nreason = \"x\"\n"
251        );
252    }
253
254    #[test]
255    fn refuses_entries_without_a_reason_and_anything_else() {
256        for (text, expected) in [
257            (
258                "[[item]]\npackage = \"a\"\nname = \"b\"\n",
259                "dead-code-allow.toml:1: this entry needs `reason",
260            ),
261            (
262                "package = \"a\"\n",
263                "dead-code-allow.toml:1: start an entry",
264            ),
265            (
266                "[[item]]\nwhy = \"a\"\n",
267                "dead-code-allow.toml:2: the keys are",
268            ),
269            (
270                "[[item]]\nname = 'a'\n",
271                "dead-code-allow.toml:2: write the value",
272            ),
273            (
274                "[[item]]\nname = \"a\"\nname = \"b\"\n",
275                "dead-code-allow.toml:3: this key is already set",
276            ),
277        ] {
278            let error = parse(text).err().unwrap_or_default();
279            assert!(error.starts_with(expected), "{text}: {error}");
280        }
281    }
282}