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