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