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)]
130#[serde(default, deny_unknown_fields)]
131pub struct RustCoverage {
132 pub regions: Option<u8>,
133 pub lines: u8,
134 pub functions: Option<u8>,
135 pub branch: Option<u8>,
136}
137
138impl Default for RustCoverage {
147 fn default() -> Self {
148 Self {
149 regions: None,
150 lines: 100,
151 functions: None,
152 branch: None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160pub enum Rule {
161 ColocatedTest,
163 Coverage,
165 CoChange,
168 NoMonkeypatch,
170 NoInlinePatch,
172 NoEnvironMutation,
174 NoConstantPatch,
176 NoFirstPartyPatch,
178 NoOutOfModuleCall,
180 NoOutOfModuleImport,
182 NoFirstPartyDouble,
184 UnmockedCollaborator,
186 UntypedMock,
188 NoFirstPartyMock,
190 Mutation,
192}
193
194impl Rule {
195 pub fn is_line_scopable(self) -> bool {
200 matches!(self, Rule::Coverage | Rule::Mutation)
201 }
202
203 pub fn id(self) -> &'static str {
206 match self {
207 Rule::ColocatedTest => "colocated-test",
208 Rule::Coverage => "coverage",
209 Rule::CoChange => "co-change",
210 Rule::NoMonkeypatch => "no-monkeypatch",
211 Rule::NoInlinePatch => "no-inline-patch",
212 Rule::NoEnvironMutation => "no-environ-mutation",
213 Rule::NoConstantPatch => "no-constant-patch",
214 Rule::NoFirstPartyPatch => "no-first-party-patch",
215 Rule::NoOutOfModuleCall => "no-out-of-module-call",
216 Rule::NoOutOfModuleImport => "no-out-of-module-import",
217 Rule::NoFirstPartyDouble => "no-first-party-double",
218 Rule::UnmockedCollaborator => "unmocked-collaborator",
219 Rule::UntypedMock => "untyped-mock",
220 Rule::NoFirstPartyMock => "no-first-party-mock",
221 Rule::Mutation => "mutation",
222 }
223 }
224
225 pub fn from_id(id: &str) -> Option<Rule> {
227 [
228 Rule::ColocatedTest,
229 Rule::Coverage,
230 Rule::CoChange,
231 Rule::NoMonkeypatch,
232 Rule::NoInlinePatch,
233 Rule::NoEnvironMutation,
234 Rule::NoConstantPatch,
235 Rule::NoFirstPartyPatch,
236 Rule::NoOutOfModuleCall,
237 Rule::NoOutOfModuleImport,
238 Rule::NoFirstPartyDouble,
239 Rule::UnmockedCollaborator,
240 Rule::UntypedMock,
241 Rule::NoFirstPartyMock,
242 Rule::Mutation,
243 ]
244 .into_iter()
245 .find(|rule| rule.id() == id)
246 }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum LineSpec {
258 Single(u32),
260 Range(u32, u32),
262}
263
264impl LineSpec {
265 fn parse_str(s: &str) -> Result<LineSpec, String> {
269 let parse = |part: &str| {
270 part.trim()
271 .parse::<u32>()
272 .map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
273 };
274 match s.split_once('-') {
275 Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
276 None => Ok(LineSpec::Single(parse(s)?)),
277 }
278 }
279
280 fn extend_into(self, set: &mut BTreeSet<u32>) {
282 match self {
283 LineSpec::Single(n) => {
284 set.insert(n);
285 }
286 LineSpec::Range(start, end) => {
287 for n in start..=end {
288 set.insert(n);
289 }
290 }
291 }
292 }
293}
294
295impl<'de> Deserialize<'de> for LineSpec {
296 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
297 where
298 D: serde::Deserializer<'de>,
299 {
300 struct SpecVisitor;
301 impl serde::de::Visitor<'_> for SpecVisitor {
302 type Value = LineSpec;
303
304 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
305 f.write_str("a line number or a \"start-end\" range string")
306 }
307
308 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
309 u32::try_from(v)
310 .map(LineSpec::Single)
311 .map_err(|_| E::custom(format!("line number {v} is out of range")))
312 }
313
314 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
316 u64::try_from(v)
317 .map_err(|_| E::custom(format!("line number {v} must be positive")))
318 .and_then(|v| self.visit_u64(v))
319 }
320
321 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
322 LineSpec::parse_str(v).map_err(E::custom)
323 }
324 }
325 deserializer.deserialize_any(SpecVisitor)
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct Exemption {
339 pub path: String,
341 pub rules: Vec<Rule>,
343 #[serde(default)]
348 pub lines: Vec<LineSpec>,
349 pub reason: String,
351}
352
353impl Exemption {
354 pub fn line_set(&self) -> BTreeSet<u32> {
357 let mut set = BTreeSet::new();
358 for spec in &self.lines {
359 spec.extend_into(&mut set);
360 }
361 set
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
371pub enum LineScope {
372 WholeFile,
374 Lines(BTreeSet<u32>),
376}
377
378impl LineScope {
379 fn merged_with(self, other: LineScope) -> LineScope {
383 match (self, other) {
384 (LineScope::WholeFile, _) | (_, LineScope::WholeFile) => LineScope::WholeFile,
385 (LineScope::Lines(mut a), LineScope::Lines(b)) => {
386 a.extend(b);
387 LineScope::Lines(a)
388 }
389 }
390 }
391}
392
393pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
401 let path = path.as_ref();
402 let contents = std::fs::read_to_string(path)
403 .with_context(|| format!("reading config file `{}`", path.display()))?;
404 let config: Config = toml::from_str(&contents)
405 .with_context(|| format!("parsing config file `{}`", path.display()))?;
406 config
407 .validate()
408 .with_context(|| format!("validating config file `{}`", path.display()))?;
409 Ok(config)
410}
411
412impl Config {
413 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
415 match language {
416 crate::colocated_test::Language::Python => {
417 self.python.as_ref().map_or(&[], |c| &c.exempt)
418 }
419 crate::colocated_test::Language::TypeScript => {
420 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
421 }
422 crate::colocated_test::Language::Rust => self.rust_exemptions(),
423 }
424 }
425
426 pub fn rust_exemptions(&self) -> &[Exemption] {
430 self.rust.as_ref().map_or(&[], |c| &c.exempt)
431 }
432
433 fn validate(&self) -> Result<()> {
436 let tables = [
437 ("python", self.python.as_ref().map(|c| &c.exempt)),
438 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
439 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
440 ];
441 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
442 for entry in exempt {
443 if entry.rules.is_empty() {
444 bail!(
445 "[{table}].exempt entry for `{}` names no rules — set \
446 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
447 entry.path
448 );
449 }
450 if entry.reason.trim().is_empty() {
451 bail!(
452 "[{table}].exempt entry for `{}` has an empty reason — \
453 every exemption must say why the file is exempt",
454 entry.path
455 );
456 }
457 let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
464 let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
465 if entry.lines.is_empty() {
466 if has_scopable {
467 let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
468 bail!(
469 "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
470 a `coverage` / `mutation` exemption must name the exact lines it \
471 covers (whole-file exemptions are for presence / lint rules only)",
472 entry.path,
473 rule.id()
474 );
475 }
476 } else {
477 if has_whole_file {
478 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
479 bail!(
480 "[{table}].exempt entry for `{}` has `lines` alongside rule \
481 `{}` — line-scoped exemptions apply only to `coverage` and \
482 `mutation`; move the whole-file rules to a separate entry",
483 entry.path,
484 rule.id()
485 );
486 }
487 for spec in &entry.lines {
488 let invalid = match spec {
489 LineSpec::Single(n) => *n == 0,
490 LineSpec::Range(start, end) => *start == 0 || start > end,
491 };
492 if invalid {
493 bail!(
494 "[{table}].exempt entry for `{}` has an invalid line spec — \
495 line numbers are 1-based and a range's start must not exceed \
496 its end",
497 entry.path
498 );
499 }
500 }
501 }
502 }
503 }
504 Ok(())
505 }
506}
507
508pub fn resolve_exempt(
516 root: &Path,
517 exemptions: &[Exemption],
518 rule: Rule,
519) -> Result<BTreeSet<String>> {
520 Ok(resolve_exempt_scoped(root, exemptions, rule)?
521 .into_keys()
522 .collect())
523}
524
525pub fn resolve_exempt_scoped(
534 root: &Path,
535 exemptions: &[Exemption],
536 rule: Rule,
537) -> Result<std::collections::BTreeMap<String, LineScope>> {
538 let mut scopes: std::collections::BTreeMap<String, LineScope> =
539 std::collections::BTreeMap::new();
540 for entry in exemptions {
541 if !entry.rules.contains(&rule) {
542 continue;
543 }
544 if !root.join(&entry.path).is_file() {
545 bail!(
546 "exempt entry `{}` matches no file under `{}` — remove the stale \
547 entry or fix the path",
548 entry.path,
549 root.display()
550 );
551 }
552 let key = entry.path.replace('\\', "/");
553 let scope = if entry.lines.is_empty() {
554 LineScope::WholeFile
555 } else {
556 LineScope::Lines(entry.line_set())
557 };
558 let merged = match scopes.remove(&key) {
559 Some(existing) => existing.merged_with(scope),
560 None => scope,
561 };
562 scopes.insert(key, merged);
563 }
564 Ok(scopes)
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use std::sync::atomic::{AtomicU64, Ordering};
571
572 fn parse(toml_src: &str) -> Result<Config> {
573 let config: Config = toml::from_str(toml_src)?;
574 config.validate()?;
575 Ok(config)
576 }
577
578 #[test]
579 fn an_exemption_with_no_rules_is_rejected() {
580 let err = parse(
581 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
582 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
583 )
584 .unwrap_err();
585 assert!(err.to_string().contains("names no rules"), "got: {err}");
586 }
587
588 #[test]
589 fn an_exemption_with_an_empty_reason_is_rejected() {
590 let err = parse(
591 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
592 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
593 )
594 .unwrap_err();
595 assert!(err.to_string().contains("empty reason"), "got: {err}");
596 }
597
598 #[test]
599 fn an_unknown_rule_is_rejected() {
600 assert!(parse(
601 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
602 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
603 )
604 .is_err());
605 }
606
607 #[test]
608 fn default_python_coverage_is_the_strict_floor() {
609 assert_eq!(
612 PythonCoverage::default(),
613 PythonCoverage {
614 branch: true,
615 fail_under: 100,
616 }
617 );
618 }
619
620 #[test]
621 fn default_typescript_coverage_is_the_strict_floor() {
622 assert_eq!(
625 TypeScriptCoverage::default(),
626 TypeScriptCoverage {
627 lines: 100,
628 branches: 100,
629 functions: 100,
630 statements: 100,
631 }
632 );
633 }
634
635 #[test]
636 fn default_rust_coverage_is_the_strict_line_floor() {
637 assert_eq!(
642 RustCoverage::default(),
643 RustCoverage {
644 regions: None,
645 lines: 100,
646 functions: None,
647 branch: None,
648 }
649 );
650 }
651
652 #[test]
653 fn rust_coverage_table_parses_with_regions_omitted() {
654 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
657 let coverage = config.rust.unwrap().coverage.unwrap();
658 assert_eq!(coverage.regions, None);
659 assert_eq!(coverage.lines, 90);
660 }
661
662 #[test]
663 fn a_valid_exemption_parses() {
664 let config = parse(
666 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
667 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
668 reason = \"thin launcher\"\n",
669 )
670 .unwrap();
671 let exempt = &config.python.unwrap().exempt;
672 assert_eq!(exempt.len(), 1);
673 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
674 assert!(exempt[0].lines.is_empty());
675 }
676
677 #[test]
678 fn exemptions_reads_the_rust_table() {
679 let config = parse(
680 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
681 reason = \"generated\"\n",
682 )
683 .unwrap();
684 let rust = config.exemptions(crate::colocated_test::Language::Rust);
685 assert_eq!(rust.len(), 1);
686 assert_eq!(rust[0].path, "build.rs");
687 }
688
689 struct TempTree(std::path::PathBuf);
691
692 impl TempTree {
693 fn new(files: &[&str]) -> Self {
694 static COUNTER: AtomicU64 = AtomicU64::new(0);
695 let root = std::env::temp_dir().join(format!(
696 "tc-exempt-{}-{}",
697 std::process::id(),
698 COUNTER.fetch_add(1, Ordering::Relaxed),
699 ));
700 for rel in files {
701 let path = root.join(rel);
702 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
703 std::fs::write(path, "x = 1\n").unwrap();
704 }
705 TempTree(root)
706 }
707 }
708
709 impl Drop for TempTree {
710 fn drop(&mut self) {
711 let _ = std::fs::remove_dir_all(&self.0);
712 }
713 }
714
715 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
716 Exemption {
717 path: path.to_string(),
718 rules: rules.to_vec(),
719 lines: vec![],
720 reason: "deliberate".to_string(),
721 }
722 }
723
724 #[test]
725 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
726 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
727 let exemptions = [
728 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
729 exemption("pkg/gen.py", &[Rule::Coverage]),
730 exemption("loc_only.py", &[Rule::ColocatedTest]),
731 ];
732 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
733 assert_eq!(
734 coverage.into_iter().collect::<Vec<_>>(),
735 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
736 );
737 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
738 assert_eq!(
739 colocated_test.into_iter().collect::<Vec<_>>(),
740 vec!["cli.py".to_string(), "loc_only.py".to_string()],
741 );
742 }
743
744 #[test]
745 fn a_stale_exempt_path_is_an_error() {
746 let tree = TempTree::new(&["cli.py"]);
747 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
748 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
749 assert!(err.to_string().contains("matches no file"), "got: {err}");
750 }
751
752 #[test]
755 fn line_specs_parse_from_ints_and_range_strings() {
756 let config = parse(
759 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
760 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
761 )
762 .unwrap();
763 let exempt = &config.python.unwrap().exempt[0];
764 assert_eq!(
765 exempt.lines,
766 vec![
767 LineSpec::Single(9),
768 LineSpec::Single(10),
769 LineSpec::Range(12, 13),
770 ]
771 );
772 assert_eq!(
774 exempt.line_set().into_iter().collect::<Vec<_>>(),
775 vec![9, 10, 12, 13]
776 );
777 }
778
779 #[test]
780 fn a_coverage_exemption_without_lines_is_rejected() {
781 let err = parse(
784 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
785 )
786 .unwrap_err();
787 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
788 }
789
790 #[test]
791 fn a_mutation_exemption_without_lines_is_rejected() {
792 let err = parse(
793 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
794 )
795 .unwrap_err();
796 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
797 }
798
799 #[test]
800 fn lines_on_a_whole_file_rule_is_rejected() {
801 let err = parse(
804 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
805 lines = [3]\nreason = \"shim\"\n",
806 )
807 .unwrap_err();
808 assert!(
809 err.to_string()
810 .contains("line-scoped exemptions apply only"),
811 "got: {err}"
812 );
813 }
814
815 #[test]
816 fn a_zero_line_is_rejected() {
817 let err = parse(
818 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
819 lines = [0]\nreason = \"x\"\n",
820 )
821 .unwrap_err();
822 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
823 }
824
825 #[test]
826 fn a_reversed_range_is_rejected() {
827 let err = parse(
828 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
829 lines = [\"13-12\"]\nreason = \"x\"\n",
830 )
831 .unwrap_err();
832 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
833 }
834
835 #[test]
836 fn a_non_numeric_line_spec_is_a_parse_error() {
837 assert!(parse(
839 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
840 lines = [\"oops\"]\nreason = \"x\"\n",
841 )
842 .is_err());
843 }
844
845 #[test]
846 fn resolve_scoped_distinguishes_whole_file_from_lines() {
847 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
850 let exemptions = [
851 exemption("barrel.py", &[Rule::ColocatedTest]),
852 Exemption {
853 path: "scoped.py".to_string(),
854 rules: vec![Rule::Coverage],
855 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
856 reason: "dead branch".to_string(),
857 },
858 ];
859 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
860 assert_eq!(
861 coverage["scoped.py"],
862 LineScope::Lines([2, 4, 5].into_iter().collect())
863 );
864 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
865 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
866 }
867
868 #[test]
869 fn resolve_scoped_merges_two_entries_for_one_file() {
870 let tree = TempTree::new(&["a.py", "b.py"]);
873 let line = |n: u32| Exemption {
874 path: "a.py".to_string(),
875 rules: vec![Rule::Mutation],
876 lines: vec![LineSpec::Single(n)],
877 reason: "equivalent mutant".to_string(),
878 };
879 let mutation = [line(3), line(7)];
880 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
881 assert_eq!(
882 scopes["a.py"],
883 LineScope::Lines([3, 7].into_iter().collect())
884 );
885
886 let presence = [
887 exemption("b.py", &[Rule::ColocatedTest]),
888 exemption("b.py", &[Rule::ColocatedTest]),
889 ];
890 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
891 assert_eq!(scopes["b.py"], LineScope::WholeFile);
892 }
893}