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)]
113#[serde(deny_unknown_fields)]
114pub struct RustCoverage {
115 #[serde(default)]
116 pub regions: Option<u8>,
117 pub lines: u8,
118}
119
120impl Default for RustCoverage {
130 fn default() -> Self {
131 Self {
132 regions: None,
133 lines: 100,
134 }
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum Rule {
142 ColocatedTest,
144 Coverage,
146 CoChange,
149 NoMonkeypatch,
151 NoInlinePatch,
153 NoEnvironMutation,
155 NoConstantPatch,
157 NoFirstPartyPatch,
159 NoOutOfModuleCall,
161 NoOutOfModuleImport,
163 NoFirstPartyDouble,
165 UnmockedCollaborator,
167 UntypedMock,
169 NoFirstPartyMock,
171 Mutation,
173}
174
175impl Rule {
176 pub fn id(self) -> &'static str {
179 match self {
180 Rule::ColocatedTest => "colocated-test",
181 Rule::Coverage => "coverage",
182 Rule::CoChange => "co-change",
183 Rule::NoMonkeypatch => "no-monkeypatch",
184 Rule::NoInlinePatch => "no-inline-patch",
185 Rule::NoEnvironMutation => "no-environ-mutation",
186 Rule::NoConstantPatch => "no-constant-patch",
187 Rule::NoFirstPartyPatch => "no-first-party-patch",
188 Rule::NoOutOfModuleCall => "no-out-of-module-call",
189 Rule::NoOutOfModuleImport => "no-out-of-module-import",
190 Rule::NoFirstPartyDouble => "no-first-party-double",
191 Rule::UnmockedCollaborator => "unmocked-collaborator",
192 Rule::UntypedMock => "untyped-mock",
193 Rule::NoFirstPartyMock => "no-first-party-mock",
194 Rule::Mutation => "mutation",
195 }
196 }
197
198 pub fn from_id(id: &str) -> Option<Rule> {
200 [
201 Rule::ColocatedTest,
202 Rule::Coverage,
203 Rule::CoChange,
204 Rule::NoMonkeypatch,
205 Rule::NoInlinePatch,
206 Rule::NoEnvironMutation,
207 Rule::NoConstantPatch,
208 Rule::NoFirstPartyPatch,
209 Rule::NoOutOfModuleCall,
210 Rule::NoOutOfModuleImport,
211 Rule::NoFirstPartyDouble,
212 Rule::UnmockedCollaborator,
213 Rule::UntypedMock,
214 Rule::NoFirstPartyMock,
215 Rule::Mutation,
216 ]
217 .into_iter()
218 .find(|rule| rule.id() == id)
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct Exemption {
232 pub path: String,
234 pub rules: Vec<Rule>,
236 pub reason: String,
238}
239
240pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
248 let path = path.as_ref();
249 let contents = std::fs::read_to_string(path)
250 .with_context(|| format!("reading config file `{}`", path.display()))?;
251 let config: Config = toml::from_str(&contents)
252 .with_context(|| format!("parsing config file `{}`", path.display()))?;
253 config
254 .validate()
255 .with_context(|| format!("validating config file `{}`", path.display()))?;
256 Ok(config)
257}
258
259impl Config {
260 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
262 match language {
263 crate::colocated_test::Language::Python => {
264 self.python.as_ref().map_or(&[], |c| &c.exempt)
265 }
266 crate::colocated_test::Language::TypeScript => {
267 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
268 }
269 crate::colocated_test::Language::Rust => self.rust_exemptions(),
270 }
271 }
272
273 pub fn rust_exemptions(&self) -> &[Exemption] {
277 self.rust.as_ref().map_or(&[], |c| &c.exempt)
278 }
279
280 fn validate(&self) -> Result<()> {
283 let tables = [
284 ("python", self.python.as_ref().map(|c| &c.exempt)),
285 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
286 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
287 ];
288 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
289 for entry in exempt {
290 if entry.rules.is_empty() {
291 bail!(
292 "[{table}].exempt entry for `{}` names no rules — set \
293 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
294 entry.path
295 );
296 }
297 if entry.reason.trim().is_empty() {
298 bail!(
299 "[{table}].exempt entry for `{}` has an empty reason — \
300 every exemption must say why the file is exempt",
301 entry.path
302 );
303 }
304 }
305 }
306 Ok(())
307 }
308}
309
310pub fn resolve_exempt(
318 root: &Path,
319 exemptions: &[Exemption],
320 rule: Rule,
321) -> Result<BTreeSet<String>> {
322 let mut paths = BTreeSet::new();
323 for entry in exemptions {
324 if !entry.rules.contains(&rule) {
325 continue;
326 }
327 if !root.join(&entry.path).is_file() {
328 bail!(
329 "exempt entry `{}` matches no file under `{}` — remove the stale \
330 entry or fix the path",
331 entry.path,
332 root.display()
333 );
334 }
335 paths.insert(entry.path.replace('\\', "/"));
336 }
337 Ok(paths)
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use std::sync::atomic::{AtomicU64, Ordering};
344
345 fn parse(toml_src: &str) -> Result<Config> {
346 let config: Config = toml::from_str(toml_src)?;
347 config.validate()?;
348 Ok(config)
349 }
350
351 #[test]
352 fn an_exemption_with_no_rules_is_rejected() {
353 let err = parse(
354 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
355 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
356 )
357 .unwrap_err();
358 assert!(err.to_string().contains("names no rules"), "got: {err}");
359 }
360
361 #[test]
362 fn an_exemption_with_an_empty_reason_is_rejected() {
363 let err = parse(
364 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
365 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
366 )
367 .unwrap_err();
368 assert!(err.to_string().contains("empty reason"), "got: {err}");
369 }
370
371 #[test]
372 fn an_unknown_rule_is_rejected() {
373 assert!(parse(
374 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
375 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
376 )
377 .is_err());
378 }
379
380 #[test]
381 fn default_python_coverage_is_the_strict_floor() {
382 assert_eq!(
385 PythonCoverage::default(),
386 PythonCoverage {
387 branch: true,
388 fail_under: 100,
389 }
390 );
391 }
392
393 #[test]
394 fn default_typescript_coverage_is_the_strict_floor() {
395 assert_eq!(
398 TypeScriptCoverage::default(),
399 TypeScriptCoverage {
400 lines: 100,
401 branches: 100,
402 functions: 100,
403 statements: 100,
404 }
405 );
406 }
407
408 #[test]
409 fn default_rust_coverage_is_the_strict_line_floor() {
410 assert_eq!(
415 RustCoverage::default(),
416 RustCoverage {
417 regions: None,
418 lines: 100,
419 }
420 );
421 }
422
423 #[test]
424 fn rust_coverage_table_parses_with_regions_omitted() {
425 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
428 let coverage = config.rust.unwrap().coverage.unwrap();
429 assert_eq!(coverage.regions, None);
430 assert_eq!(coverage.lines, 90);
431 }
432
433 #[test]
434 fn a_valid_exemption_parses() {
435 let config = parse(
436 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
437 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
438 reason = \"thin launcher\"\n",
439 )
440 .unwrap();
441 let exempt = &config.python.unwrap().exempt;
442 assert_eq!(exempt.len(), 1);
443 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
444 }
445
446 #[test]
447 fn exemptions_reads_the_rust_table() {
448 let config = parse(
449 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
450 reason = \"generated\"\n",
451 )
452 .unwrap();
453 let rust = config.exemptions(crate::colocated_test::Language::Rust);
454 assert_eq!(rust.len(), 1);
455 assert_eq!(rust[0].path, "build.rs");
456 }
457
458 struct TempTree(std::path::PathBuf);
460
461 impl TempTree {
462 fn new(files: &[&str]) -> Self {
463 static COUNTER: AtomicU64 = AtomicU64::new(0);
464 let root = std::env::temp_dir().join(format!(
465 "tc-exempt-{}-{}",
466 std::process::id(),
467 COUNTER.fetch_add(1, Ordering::Relaxed),
468 ));
469 for rel in files {
470 let path = root.join(rel);
471 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
472 std::fs::write(path, "x = 1\n").unwrap();
473 }
474 TempTree(root)
475 }
476 }
477
478 impl Drop for TempTree {
479 fn drop(&mut self) {
480 let _ = std::fs::remove_dir_all(&self.0);
481 }
482 }
483
484 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
485 Exemption {
486 path: path.to_string(),
487 rules: rules.to_vec(),
488 reason: "deliberate".to_string(),
489 }
490 }
491
492 #[test]
493 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
494 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
495 let exemptions = [
496 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
497 exemption("pkg/gen.py", &[Rule::Coverage]),
498 exemption("loc_only.py", &[Rule::ColocatedTest]),
499 ];
500 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
501 assert_eq!(
502 coverage.into_iter().collect::<Vec<_>>(),
503 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
504 );
505 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
506 assert_eq!(
507 colocated_test.into_iter().collect::<Vec<_>>(),
508 vec!["cli.py".to_string(), "loc_only.py".to_string()],
509 );
510 }
511
512 #[test]
513 fn a_stale_exempt_path_is_an_error() {
514 let tree = TempTree::new(&["cli.py"]);
515 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
516 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
517 assert!(err.to_string().contains("matches no file"), "got: {err}");
518 }
519}