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