1use std::collections::BTreeSet;
10use std::path::Path;
11
12use anyhow::{bail, Context, Result};
13use serde::Deserialize;
14
15#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Config {
25 pub python: Option<PythonConfig>,
26 pub typescript: Option<TypeScriptConfig>,
27 pub rust: Option<RustConfig>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct PythonConfig {
35 pub coverage: Option<PythonCoverage>,
36 #[serde(default)]
37 pub exempt: Vec<Exemption>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TypeScriptConfig {
44 pub coverage: Option<TypeScriptCoverage>,
45 #[serde(default)]
46 pub exempt: Vec<Exemption>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct RustConfig {
53 pub coverage: Option<RustCoverage>,
54 #[serde(default)]
55 pub exempt: Vec<Exemption>,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct PythonCoverage {
62 pub branch: bool,
63 pub fail_under: u8,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct TypeScriptCoverage {
70 pub lines: u8,
71 pub branches: u8,
72 pub functions: u8,
73 pub statements: u8,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct RustCoverage {
81 pub regions: u8,
82 pub lines: u8,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Rule {
89 Location,
91 Coverage,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Exemption {
105 pub path: String,
107 pub rules: Vec<Rule>,
109 pub reason: String,
111}
112
113pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
121 let path = path.as_ref();
122 let contents = std::fs::read_to_string(path)
123 .with_context(|| format!("reading config file `{}`", path.display()))?;
124 let config: Config = toml::from_str(&contents)
125 .with_context(|| format!("parsing config file `{}`", path.display()))?;
126 config
127 .validate()
128 .with_context(|| format!("validating config file `{}`", path.display()))?;
129 Ok(config)
130}
131
132impl Config {
133 pub fn exemptions(&self, language: crate::location::Language) -> &[Exemption] {
135 match language {
136 crate::location::Language::Python => self.python.as_ref().map_or(&[], |c| &c.exempt),
137 crate::location::Language::TypeScript => {
138 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
139 }
140 }
141 }
142
143 fn validate(&self) -> Result<()> {
146 let tables = [
147 ("python", self.python.as_ref().map(|c| &c.exempt)),
148 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
149 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
150 ];
151 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
152 for entry in exempt {
153 if entry.rules.is_empty() {
154 bail!(
155 "[{table}].exempt entry for `{}` names no rules — set \
156 `rules = [\"location\"]` and/or `\"coverage\"`",
157 entry.path
158 );
159 }
160 if entry.reason.trim().is_empty() {
161 bail!(
162 "[{table}].exempt entry for `{}` has an empty reason — \
163 every exemption must say why the file is exempt",
164 entry.path
165 );
166 }
167 }
168 }
169 Ok(())
170 }
171}
172
173pub fn resolve_exempt(
181 root: &Path,
182 exemptions: &[Exemption],
183 rule: Rule,
184) -> Result<BTreeSet<String>> {
185 let mut paths = BTreeSet::new();
186 for entry in exemptions {
187 if !entry.rules.contains(&rule) {
188 continue;
189 }
190 if !root.join(&entry.path).is_file() {
191 bail!(
192 "exempt entry `{}` matches no file under `{}` — remove the stale \
193 entry or fix the path",
194 entry.path,
195 root.display()
196 );
197 }
198 paths.insert(entry.path.replace('\\', "/"));
199 }
200 Ok(paths)
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::sync::atomic::{AtomicU64, Ordering};
207
208 fn parse(toml_src: &str) -> Result<Config> {
209 let config: Config = toml::from_str(toml_src)?;
210 config.validate()?;
211 Ok(config)
212 }
213
214 #[test]
215 fn an_exemption_with_no_rules_is_rejected() {
216 let err = parse(
217 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
218 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
219 )
220 .unwrap_err();
221 assert!(err.to_string().contains("names no rules"), "got: {err}");
222 }
223
224 #[test]
225 fn an_exemption_with_an_empty_reason_is_rejected() {
226 let err = parse(
227 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
228 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"location\"]\nreason = \" \"\n",
229 )
230 .unwrap_err();
231 assert!(err.to_string().contains("empty reason"), "got: {err}");
232 }
233
234 #[test]
235 fn an_unknown_rule_is_rejected() {
236 assert!(parse(
237 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
238 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
239 )
240 .is_err());
241 }
242
243 #[test]
244 fn a_valid_exemption_parses() {
245 let config = parse(
246 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
247 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"location\", \"coverage\"]\n\
248 reason = \"thin launcher\"\n",
249 )
250 .unwrap();
251 let exempt = &config.python.unwrap().exempt;
252 assert_eq!(exempt.len(), 1);
253 assert_eq!(exempt[0].rules, vec![Rule::Location, Rule::Coverage]);
254 }
255
256 struct TempTree(std::path::PathBuf);
258
259 impl TempTree {
260 fn new(files: &[&str]) -> Self {
261 static COUNTER: AtomicU64 = AtomicU64::new(0);
262 let root = std::env::temp_dir().join(format!(
263 "tc-exempt-{}-{}",
264 std::process::id(),
265 COUNTER.fetch_add(1, Ordering::Relaxed),
266 ));
267 for rel in files {
268 let path = root.join(rel);
269 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
270 std::fs::write(path, "x = 1\n").unwrap();
271 }
272 TempTree(root)
273 }
274 }
275
276 impl Drop for TempTree {
277 fn drop(&mut self) {
278 let _ = std::fs::remove_dir_all(&self.0);
279 }
280 }
281
282 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
283 Exemption {
284 path: path.to_string(),
285 rules: rules.to_vec(),
286 reason: "deliberate".to_string(),
287 }
288 }
289
290 #[test]
291 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
292 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
293 let exemptions = [
294 exemption("cli.py", &[Rule::Location, Rule::Coverage]),
295 exemption("pkg/gen.py", &[Rule::Coverage]),
296 exemption("loc_only.py", &[Rule::Location]),
297 ];
298 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
299 assert_eq!(
300 coverage.into_iter().collect::<Vec<_>>(),
301 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
302 );
303 let location = resolve_exempt(&tree.0, &exemptions, Rule::Location).unwrap();
304 assert_eq!(
305 location.into_iter().collect::<Vec<_>>(),
306 vec!["cli.py".to_string(), "loc_only.py".to_string()],
307 );
308 }
309
310 #[test]
311 fn a_stale_exempt_path_is_an_error() {
312 let tree = TempTree::new(&["cli.py"]);
313 let exemptions = [exemption("ghost.py", &[Rule::Location])];
314 let err = resolve_exempt(&tree.0, &exemptions, Rule::Location).unwrap_err();
315 assert!(err.to_string().contains("matches no file"), "got: {err}");
316 }
317}