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
9pub 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 #[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 #[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 #[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
161pub(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}