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)]
63 pub features: Vec<String>,
64 #[serde(default)]
65 pub exempt: Vec<Exemption>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
72#[serde(default, deny_unknown_fields)]
73pub struct PythonCoverage {
74 pub branch: bool,
75 pub fail_under: u8,
76}
77
78impl Default for PythonCoverage {
85 fn default() -> Self {
86 Self {
87 branch: true,
88 fail_under: 100,
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct TypeScriptCoverage {
100 pub lines: u8,
101 pub branches: u8,
102 pub functions: u8,
103 pub statements: u8,
104}
105
106impl Default for TypeScriptCoverage {
112 fn default() -> Self {
113 Self {
114 lines: 100,
115 branches: 100,
116 functions: 100,
117 statements: 100,
118 }
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
128#[serde(default, deny_unknown_fields)]
129pub struct RustCoverage {
130 pub regions: Option<u8>,
131 pub lines: u8,
132}
133
134impl Default for RustCoverage {
144 fn default() -> Self {
145 Self {
146 regions: None,
147 lines: 100,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
154#[serde(rename_all = "kebab-case")]
155pub enum Rule {
156 ColocatedTest,
158 Coverage,
160 CoChange,
163 NoMonkeypatch,
165 NoInlinePatch,
167 NoEnvironMutation,
169 NoConstantPatch,
171 NoFirstPartyPatch,
173 NoOutOfModuleCall,
175 NoOutOfModuleImport,
177 NoFirstPartyDouble,
179 UnmockedCollaborator,
181 UntypedMock,
183 NoFirstPartyMock,
185 Mutation,
187}
188
189impl Rule {
190 pub fn is_line_scopable(self) -> bool {
195 matches!(self, Rule::Coverage | Rule::Mutation)
196 }
197
198 pub fn id(self) -> &'static str {
201 match self {
202 Rule::ColocatedTest => "colocated-test",
203 Rule::Coverage => "coverage",
204 Rule::CoChange => "co-change",
205 Rule::NoMonkeypatch => "no-monkeypatch",
206 Rule::NoInlinePatch => "no-inline-patch",
207 Rule::NoEnvironMutation => "no-environ-mutation",
208 Rule::NoConstantPatch => "no-constant-patch",
209 Rule::NoFirstPartyPatch => "no-first-party-patch",
210 Rule::NoOutOfModuleCall => "no-out-of-module-call",
211 Rule::NoOutOfModuleImport => "no-out-of-module-import",
212 Rule::NoFirstPartyDouble => "no-first-party-double",
213 Rule::UnmockedCollaborator => "unmocked-collaborator",
214 Rule::UntypedMock => "untyped-mock",
215 Rule::NoFirstPartyMock => "no-first-party-mock",
216 Rule::Mutation => "mutation",
217 }
218 }
219
220 pub fn from_id(id: &str) -> Option<Rule> {
222 [
223 Rule::ColocatedTest,
224 Rule::Coverage,
225 Rule::CoChange,
226 Rule::NoMonkeypatch,
227 Rule::NoInlinePatch,
228 Rule::NoEnvironMutation,
229 Rule::NoConstantPatch,
230 Rule::NoFirstPartyPatch,
231 Rule::NoOutOfModuleCall,
232 Rule::NoOutOfModuleImport,
233 Rule::NoFirstPartyDouble,
234 Rule::UnmockedCollaborator,
235 Rule::UntypedMock,
236 Rule::NoFirstPartyMock,
237 Rule::Mutation,
238 ]
239 .into_iter()
240 .find(|rule| rule.id() == id)
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum LineSpec {
253 Single(u32),
255 Range(u32, u32),
257}
258
259impl LineSpec {
260 fn parse_str(s: &str) -> Result<LineSpec, String> {
264 let parse = |part: &str| {
265 part.trim()
266 .parse::<u32>()
267 .map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
268 };
269 match s.split_once('-') {
270 Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
271 None => Ok(LineSpec::Single(parse(s)?)),
272 }
273 }
274
275 fn extend_into(self, set: &mut BTreeSet<u32>) {
277 match self {
278 LineSpec::Single(n) => {
279 set.insert(n);
280 }
281 LineSpec::Range(start, end) => {
282 for n in start..=end {
283 set.insert(n);
284 }
285 }
286 }
287 }
288}
289
290impl<'de> Deserialize<'de> for LineSpec {
291 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
292 where
293 D: serde::Deserializer<'de>,
294 {
295 struct SpecVisitor;
296 impl serde::de::Visitor<'_> for SpecVisitor {
297 type Value = LineSpec;
298
299 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
300 f.write_str("a line number or a \"start-end\" range string")
301 }
302
303 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
304 u32::try_from(v)
305 .map(LineSpec::Single)
306 .map_err(|_| E::custom(format!("line number {v} is out of range")))
307 }
308
309 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
311 u64::try_from(v)
312 .map_err(|_| E::custom(format!("line number {v} must be positive")))
313 .and_then(|v| self.visit_u64(v))
314 }
315
316 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
317 LineSpec::parse_str(v).map_err(E::custom)
318 }
319 }
320 deserializer.deserialize_any(SpecVisitor)
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
332#[serde(deny_unknown_fields)]
333pub struct Exemption {
334 pub path: String,
336 pub rules: Vec<Rule>,
338 #[serde(default)]
343 pub lines: Vec<LineSpec>,
344 pub reason: String,
346}
347
348impl Exemption {
349 pub fn line_set(&self) -> BTreeSet<u32> {
352 let mut set = BTreeSet::new();
353 for spec in &self.lines {
354 spec.extend_into(&mut set);
355 }
356 set
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum LineScope {
367 WholeFile,
369 Lines(BTreeSet<u32>),
371}
372
373impl LineScope {
374 fn merged_with(self, other: LineScope) -> LineScope {
378 match (self, other) {
379 (LineScope::WholeFile, _) | (_, LineScope::WholeFile) => LineScope::WholeFile,
380 (LineScope::Lines(mut a), LineScope::Lines(b)) => {
381 a.extend(b);
382 LineScope::Lines(a)
383 }
384 }
385 }
386}
387
388pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
396 let path = path.as_ref();
397 let contents = std::fs::read_to_string(path)
398 .with_context(|| format!("reading config file `{}`", path.display()))?;
399 let config: Config = toml::from_str(&contents)
400 .with_context(|| format!("parsing config file `{}`", path.display()))?;
401 config
402 .validate()
403 .with_context(|| format!("validating config file `{}`", path.display()))?;
404 Ok(config)
405}
406
407impl Config {
408 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
410 match language {
411 crate::colocated_test::Language::Python => {
412 self.python.as_ref().map_or(&[], |c| &c.exempt)
413 }
414 crate::colocated_test::Language::TypeScript => {
415 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
416 }
417 crate::colocated_test::Language::Rust => self.rust_exemptions(),
418 }
419 }
420
421 pub fn rust_exemptions(&self) -> &[Exemption] {
425 self.rust.as_ref().map_or(&[], |c| &c.exempt)
426 }
427
428 fn validate(&self) -> Result<()> {
431 let tables = [
432 ("python", self.python.as_ref().map(|c| &c.exempt)),
433 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
434 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
435 ];
436 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
437 for entry in exempt {
438 if entry.rules.is_empty() {
439 bail!(
440 "[{table}].exempt entry for `{}` names no rules — set \
441 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
442 entry.path
443 );
444 }
445 if entry.reason.trim().is_empty() {
446 bail!(
447 "[{table}].exempt entry for `{}` has an empty reason — \
448 every exemption must say why the file is exempt",
449 entry.path
450 );
451 }
452 let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
459 let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
460 if entry.lines.is_empty() {
461 if has_scopable {
462 let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
463 bail!(
464 "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
465 a `coverage` / `mutation` exemption must name the exact lines it \
466 covers (whole-file exemptions are for presence / lint rules only)",
467 entry.path,
468 rule.id()
469 );
470 }
471 } else {
472 if has_whole_file {
473 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
474 bail!(
475 "[{table}].exempt entry for `{}` has `lines` alongside rule \
476 `{}` — line-scoped exemptions apply only to `coverage` and \
477 `mutation`; move the whole-file rules to a separate entry",
478 entry.path,
479 rule.id()
480 );
481 }
482 for spec in &entry.lines {
483 let invalid = match spec {
484 LineSpec::Single(n) => *n == 0,
485 LineSpec::Range(start, end) => *start == 0 || start > end,
486 };
487 if invalid {
488 bail!(
489 "[{table}].exempt entry for `{}` has an invalid line spec — \
490 line numbers are 1-based and a range's start must not exceed \
491 its end",
492 entry.path
493 );
494 }
495 }
496 }
497 }
498 }
499 Ok(())
500 }
501}
502
503pub fn resolve_exempt(
511 root: &Path,
512 exemptions: &[Exemption],
513 rule: Rule,
514) -> Result<BTreeSet<String>> {
515 Ok(resolve_exempt_scoped(root, exemptions, rule)?
516 .into_keys()
517 .collect())
518}
519
520pub fn resolve_exempt_scoped(
529 root: &Path,
530 exemptions: &[Exemption],
531 rule: Rule,
532) -> Result<std::collections::BTreeMap<String, LineScope>> {
533 let mut scopes: std::collections::BTreeMap<String, LineScope> =
534 std::collections::BTreeMap::new();
535 for entry in exemptions {
536 if !entry.rules.contains(&rule) {
537 continue;
538 }
539 if !root.join(&entry.path).is_file() {
540 bail!(
541 "exempt entry `{}` matches no file under `{}` — remove the stale \
542 entry or fix the path",
543 entry.path,
544 root.display()
545 );
546 }
547 let key = entry.path.replace('\\', "/");
548 let scope = if entry.lines.is_empty() {
549 LineScope::WholeFile
550 } else {
551 LineScope::Lines(entry.line_set())
552 };
553 let merged = match scopes.remove(&key) {
554 Some(existing) => existing.merged_with(scope),
555 None => scope,
556 };
557 scopes.insert(key, merged);
558 }
559 Ok(scopes)
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use std::sync::atomic::{AtomicU64, Ordering};
566
567 fn parse(toml_src: &str) -> Result<Config> {
568 let config: Config = toml::from_str(toml_src)?;
569 config.validate()?;
570 Ok(config)
571 }
572
573 #[test]
574 fn an_exemption_with_no_rules_is_rejected() {
575 let err = parse(
576 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
577 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
578 )
579 .unwrap_err();
580 assert!(err.to_string().contains("names no rules"), "got: {err}");
581 }
582
583 #[test]
584 fn an_exemption_with_an_empty_reason_is_rejected() {
585 let err = parse(
586 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
587 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
588 )
589 .unwrap_err();
590 assert!(err.to_string().contains("empty reason"), "got: {err}");
591 }
592
593 #[test]
594 fn an_unknown_rule_is_rejected() {
595 assert!(parse(
596 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
597 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
598 )
599 .is_err());
600 }
601
602 #[test]
603 fn default_python_coverage_is_the_strict_floor() {
604 assert_eq!(
607 PythonCoverage::default(),
608 PythonCoverage {
609 branch: true,
610 fail_under: 100,
611 }
612 );
613 }
614
615 #[test]
616 fn default_typescript_coverage_is_the_strict_floor() {
617 assert_eq!(
620 TypeScriptCoverage::default(),
621 TypeScriptCoverage {
622 lines: 100,
623 branches: 100,
624 functions: 100,
625 statements: 100,
626 }
627 );
628 }
629
630 #[test]
631 fn default_rust_coverage_is_the_strict_line_floor() {
632 assert_eq!(
637 RustCoverage::default(),
638 RustCoverage {
639 regions: None,
640 lines: 100,
641 }
642 );
643 }
644
645 #[test]
646 fn rust_coverage_table_parses_with_regions_omitted() {
647 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
650 let coverage = config.rust.unwrap().coverage.unwrap();
651 assert_eq!(coverage.regions, None);
652 assert_eq!(coverage.lines, 90);
653 }
654
655 #[test]
656 fn a_valid_exemption_parses() {
657 let config = parse(
659 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
660 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
661 reason = \"thin launcher\"\n",
662 )
663 .unwrap();
664 let exempt = &config.python.unwrap().exempt;
665 assert_eq!(exempt.len(), 1);
666 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
667 assert!(exempt[0].lines.is_empty());
668 }
669
670 #[test]
671 fn exemptions_reads_the_rust_table() {
672 let config = parse(
673 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
674 reason = \"generated\"\n",
675 )
676 .unwrap();
677 let rust = config.exemptions(crate::colocated_test::Language::Rust);
678 assert_eq!(rust.len(), 1);
679 assert_eq!(rust[0].path, "build.rs");
680 }
681
682 struct TempTree(std::path::PathBuf);
684
685 impl TempTree {
686 fn new(files: &[&str]) -> Self {
687 static COUNTER: AtomicU64 = AtomicU64::new(0);
688 let root = std::env::temp_dir().join(format!(
689 "tc-exempt-{}-{}",
690 std::process::id(),
691 COUNTER.fetch_add(1, Ordering::Relaxed),
692 ));
693 for rel in files {
694 let path = root.join(rel);
695 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
696 std::fs::write(path, "x = 1\n").unwrap();
697 }
698 TempTree(root)
699 }
700 }
701
702 impl Drop for TempTree {
703 fn drop(&mut self) {
704 let _ = std::fs::remove_dir_all(&self.0);
705 }
706 }
707
708 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
709 Exemption {
710 path: path.to_string(),
711 rules: rules.to_vec(),
712 lines: vec![],
713 reason: "deliberate".to_string(),
714 }
715 }
716
717 #[test]
718 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
719 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
720 let exemptions = [
721 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
722 exemption("pkg/gen.py", &[Rule::Coverage]),
723 exemption("loc_only.py", &[Rule::ColocatedTest]),
724 ];
725 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
726 assert_eq!(
727 coverage.into_iter().collect::<Vec<_>>(),
728 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
729 );
730 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
731 assert_eq!(
732 colocated_test.into_iter().collect::<Vec<_>>(),
733 vec!["cli.py".to_string(), "loc_only.py".to_string()],
734 );
735 }
736
737 #[test]
738 fn a_stale_exempt_path_is_an_error() {
739 let tree = TempTree::new(&["cli.py"]);
740 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
741 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
742 assert!(err.to_string().contains("matches no file"), "got: {err}");
743 }
744
745 #[test]
748 fn line_specs_parse_from_ints_and_range_strings() {
749 let config = parse(
752 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
753 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
754 )
755 .unwrap();
756 let exempt = &config.python.unwrap().exempt[0];
757 assert_eq!(
758 exempt.lines,
759 vec![
760 LineSpec::Single(9),
761 LineSpec::Single(10),
762 LineSpec::Range(12, 13),
763 ]
764 );
765 assert_eq!(
767 exempt.line_set().into_iter().collect::<Vec<_>>(),
768 vec![9, 10, 12, 13]
769 );
770 }
771
772 #[test]
773 fn a_coverage_exemption_without_lines_is_rejected() {
774 let err = parse(
777 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
778 )
779 .unwrap_err();
780 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
781 }
782
783 #[test]
784 fn a_mutation_exemption_without_lines_is_rejected() {
785 let err = parse(
786 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
787 )
788 .unwrap_err();
789 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
790 }
791
792 #[test]
793 fn lines_on_a_whole_file_rule_is_rejected() {
794 let err = parse(
797 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
798 lines = [3]\nreason = \"shim\"\n",
799 )
800 .unwrap_err();
801 assert!(
802 err.to_string()
803 .contains("line-scoped exemptions apply only"),
804 "got: {err}"
805 );
806 }
807
808 #[test]
809 fn a_zero_line_is_rejected() {
810 let err = parse(
811 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
812 lines = [0]\nreason = \"x\"\n",
813 )
814 .unwrap_err();
815 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
816 }
817
818 #[test]
819 fn a_reversed_range_is_rejected() {
820 let err = parse(
821 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
822 lines = [\"13-12\"]\nreason = \"x\"\n",
823 )
824 .unwrap_err();
825 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
826 }
827
828 #[test]
829 fn a_non_numeric_line_spec_is_a_parse_error() {
830 assert!(parse(
832 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
833 lines = [\"oops\"]\nreason = \"x\"\n",
834 )
835 .is_err());
836 }
837
838 #[test]
839 fn resolve_scoped_distinguishes_whole_file_from_lines() {
840 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
843 let exemptions = [
844 exemption("barrel.py", &[Rule::ColocatedTest]),
845 Exemption {
846 path: "scoped.py".to_string(),
847 rules: vec![Rule::Coverage],
848 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
849 reason: "dead branch".to_string(),
850 },
851 ];
852 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
853 assert_eq!(
854 coverage["scoped.py"],
855 LineScope::Lines([2, 4, 5].into_iter().collect())
856 );
857 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
858 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
859 }
860
861 #[test]
862 fn resolve_scoped_merges_two_entries_for_one_file() {
863 let tree = TempTree::new(&["a.py", "b.py"]);
866 let line = |n: u32| Exemption {
867 path: "a.py".to_string(),
868 rules: vec![Rule::Mutation],
869 lines: vec![LineSpec::Single(n)],
870 reason: "equivalent mutant".to_string(),
871 };
872 let mutation = [line(3), line(7)];
873 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
874 assert_eq!(
875 scopes["a.py"],
876 LineScope::Lines([3, 7].into_iter().collect())
877 );
878
879 let presence = [
880 exemption("b.py", &[Rule::ColocatedTest]),
881 exemption("b.py", &[Rule::ColocatedTest]),
882 ];
883 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
884 assert_eq!(scopes["b.py"], LineScope::WholeFile);
885 }
886}