1use std::collections::BTreeSet;
10use std::path::Path;
11
12use anyhow::{bail, Context, Result};
13use serde::Deserialize;
14
15#[derive(Debug, Clone, Default, 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, Default, PartialEq, Eq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct PythonConfig {
37 pub coverage: Option<PythonCoverage>,
38 #[serde(default)]
39 pub exempt: Vec<Exemption>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct TypeScriptConfig {
46 pub coverage: Option<TypeScriptCoverage>,
47 #[serde(default)]
48 pub exempt: Vec<Exemption>,
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct RustConfig {
55 pub coverage: Option<RustCoverage>,
56 #[serde(default)]
57 pub exempt: Vec<Exemption>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct PythonCoverage {
64 pub branch: bool,
65 pub fail_under: u8,
66}
67
68impl Default for PythonCoverage {
75 fn default() -> Self {
76 Self {
77 branch: true,
78 fail_under: 100,
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct TypeScriptCoverage {
87 pub lines: u8,
88 pub branches: u8,
89 pub functions: u8,
90 pub statements: u8,
91}
92
93impl Default for TypeScriptCoverage {
99 fn default() -> Self {
100 Self {
101 lines: 100,
102 branches: 100,
103 functions: 100,
104 statements: 100,
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct RustCoverage {
114 pub regions: u8,
115 pub lines: u8,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
120#[serde(rename_all = "kebab-case")]
121pub enum Rule {
122 ColocatedTest,
124 Coverage,
126 CoChange,
129 NoMonkeypatch,
131 NoInlinePatch,
133 NoEnvironMutation,
135 NoConstantPatch,
137 NoFirstPartyPatch,
139 NoOutOfModuleCall,
141 NoOutOfModuleImport,
143 NoFirstPartyDouble,
145 UnmockedCollaborator,
147 UntypedMock,
149 NoFirstPartyMock,
151}
152
153impl Rule {
154 pub fn id(self) -> &'static str {
157 match self {
158 Rule::ColocatedTest => "colocated-test",
159 Rule::Coverage => "coverage",
160 Rule::CoChange => "co-change",
161 Rule::NoMonkeypatch => "no-monkeypatch",
162 Rule::NoInlinePatch => "no-inline-patch",
163 Rule::NoEnvironMutation => "no-environ-mutation",
164 Rule::NoConstantPatch => "no-constant-patch",
165 Rule::NoFirstPartyPatch => "no-first-party-patch",
166 Rule::NoOutOfModuleCall => "no-out-of-module-call",
167 Rule::NoOutOfModuleImport => "no-out-of-module-import",
168 Rule::NoFirstPartyDouble => "no-first-party-double",
169 Rule::UnmockedCollaborator => "unmocked-collaborator",
170 Rule::UntypedMock => "untyped-mock",
171 Rule::NoFirstPartyMock => "no-first-party-mock",
172 }
173 }
174
175 pub fn from_id(id: &str) -> Option<Rule> {
177 [
178 Rule::ColocatedTest,
179 Rule::Coverage,
180 Rule::CoChange,
181 Rule::NoMonkeypatch,
182 Rule::NoInlinePatch,
183 Rule::NoEnvironMutation,
184 Rule::NoConstantPatch,
185 Rule::NoFirstPartyPatch,
186 Rule::NoOutOfModuleCall,
187 Rule::NoOutOfModuleImport,
188 Rule::NoFirstPartyDouble,
189 Rule::UnmockedCollaborator,
190 Rule::UntypedMock,
191 Rule::NoFirstPartyMock,
192 ]
193 .into_iter()
194 .find(|rule| rule.id() == id)
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct Exemption {
208 pub path: String,
210 pub rules: Vec<Rule>,
212 pub reason: String,
214}
215
216pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
224 let path = path.as_ref();
225 let contents = std::fs::read_to_string(path)
226 .with_context(|| format!("reading config file `{}`", path.display()))?;
227 let config: Config = toml::from_str(&contents)
228 .with_context(|| format!("parsing config file `{}`", path.display()))?;
229 config
230 .validate()
231 .with_context(|| format!("validating config file `{}`", path.display()))?;
232 Ok(config)
233}
234
235impl Config {
236 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
238 match language {
239 crate::colocated_test::Language::Python => {
240 self.python.as_ref().map_or(&[], |c| &c.exempt)
241 }
242 crate::colocated_test::Language::TypeScript => {
243 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
244 }
245 crate::colocated_test::Language::Rust => self.rust_exemptions(),
246 }
247 }
248
249 pub fn rust_exemptions(&self) -> &[Exemption] {
253 self.rust.as_ref().map_or(&[], |c| &c.exempt)
254 }
255
256 fn validate(&self) -> Result<()> {
259 let tables = [
260 ("python", self.python.as_ref().map(|c| &c.exempt)),
261 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
262 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
263 ];
264 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
265 for entry in exempt {
266 if entry.rules.is_empty() {
267 bail!(
268 "[{table}].exempt entry for `{}` names no rules — set \
269 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
270 entry.path
271 );
272 }
273 if entry.reason.trim().is_empty() {
274 bail!(
275 "[{table}].exempt entry for `{}` has an empty reason — \
276 every exemption must say why the file is exempt",
277 entry.path
278 );
279 }
280 }
281 }
282 Ok(())
283 }
284}
285
286pub fn resolve_exempt(
294 root: &Path,
295 exemptions: &[Exemption],
296 rule: Rule,
297) -> Result<BTreeSet<String>> {
298 let mut paths = BTreeSet::new();
299 for entry in exemptions {
300 if !entry.rules.contains(&rule) {
301 continue;
302 }
303 if !root.join(&entry.path).is_file() {
304 bail!(
305 "exempt entry `{}` matches no file under `{}` — remove the stale \
306 entry or fix the path",
307 entry.path,
308 root.display()
309 );
310 }
311 paths.insert(entry.path.replace('\\', "/"));
312 }
313 Ok(paths)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use std::sync::atomic::{AtomicU64, Ordering};
320
321 fn parse(toml_src: &str) -> Result<Config> {
322 let config: Config = toml::from_str(toml_src)?;
323 config.validate()?;
324 Ok(config)
325 }
326
327 #[test]
328 fn an_exemption_with_no_rules_is_rejected() {
329 let err = parse(
330 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
331 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
332 )
333 .unwrap_err();
334 assert!(err.to_string().contains("names no rules"), "got: {err}");
335 }
336
337 #[test]
338 fn an_exemption_with_an_empty_reason_is_rejected() {
339 let err = parse(
340 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
341 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
342 )
343 .unwrap_err();
344 assert!(err.to_string().contains("empty reason"), "got: {err}");
345 }
346
347 #[test]
348 fn an_unknown_rule_is_rejected() {
349 assert!(parse(
350 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
351 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
352 )
353 .is_err());
354 }
355
356 #[test]
357 fn default_python_coverage_is_the_strict_floor() {
358 assert_eq!(
361 PythonCoverage::default(),
362 PythonCoverage {
363 branch: true,
364 fail_under: 100,
365 }
366 );
367 }
368
369 #[test]
370 fn default_typescript_coverage_is_the_strict_floor() {
371 assert_eq!(
374 TypeScriptCoverage::default(),
375 TypeScriptCoverage {
376 lines: 100,
377 branches: 100,
378 functions: 100,
379 statements: 100,
380 }
381 );
382 }
383
384 #[test]
385 fn a_valid_exemption_parses() {
386 let config = parse(
387 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
388 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
389 reason = \"thin launcher\"\n",
390 )
391 .unwrap();
392 let exempt = &config.python.unwrap().exempt;
393 assert_eq!(exempt.len(), 1);
394 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
395 }
396
397 #[test]
398 fn exemptions_reads_the_rust_table() {
399 let config = parse(
400 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
401 reason = \"generated\"\n",
402 )
403 .unwrap();
404 let rust = config.exemptions(crate::colocated_test::Language::Rust);
405 assert_eq!(rust.len(), 1);
406 assert_eq!(rust[0].path, "build.rs");
407 }
408
409 struct TempTree(std::path::PathBuf);
411
412 impl TempTree {
413 fn new(files: &[&str]) -> Self {
414 static COUNTER: AtomicU64 = AtomicU64::new(0);
415 let root = std::env::temp_dir().join(format!(
416 "tc-exempt-{}-{}",
417 std::process::id(),
418 COUNTER.fetch_add(1, Ordering::Relaxed),
419 ));
420 for rel in files {
421 let path = root.join(rel);
422 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
423 std::fs::write(path, "x = 1\n").unwrap();
424 }
425 TempTree(root)
426 }
427 }
428
429 impl Drop for TempTree {
430 fn drop(&mut self) {
431 let _ = std::fs::remove_dir_all(&self.0);
432 }
433 }
434
435 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
436 Exemption {
437 path: path.to_string(),
438 rules: rules.to_vec(),
439 reason: "deliberate".to_string(),
440 }
441 }
442
443 #[test]
444 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
445 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
446 let exemptions = [
447 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
448 exemption("pkg/gen.py", &[Rule::Coverage]),
449 exemption("loc_only.py", &[Rule::ColocatedTest]),
450 ];
451 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
452 assert_eq!(
453 coverage.into_iter().collect::<Vec<_>>(),
454 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
455 );
456 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
457 assert_eq!(
458 colocated_test.into_iter().collect::<Vec<_>>(),
459 vec!["cli.py".to_string(), "loc_only.py".to_string()],
460 );
461 }
462
463 #[test]
464 fn a_stale_exempt_path_is_an_error() {
465 let tree = TempTree::new(&["cli.py"]);
466 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
467 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
468 assert!(err.to_string().contains("matches no file"), "got: {err}");
469 }
470}