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 pub build_command: Option<String>,
50 #[serde(default)]
54 pub reason: String,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct TypeScriptConfig {
61 pub coverage: Option<TypeScriptCoverage>,
62 #[serde(default)]
63 pub exempt: Vec<Exemption>,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct RustConfig {
70 pub coverage: Option<RustCoverage>,
71 #[serde(default)]
78 pub features: Vec<String>,
79 #[serde(default)]
80 pub exempt: Vec<Exemption>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
87#[serde(default, deny_unknown_fields)]
88pub struct PythonCoverage {
89 pub branch: bool,
90 pub fail_under: u8,
91}
92
93impl Default for PythonCoverage {
100 fn default() -> Self {
101 Self {
102 branch: true,
103 fail_under: 100,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
113#[serde(default, deny_unknown_fields)]
114pub struct TypeScriptCoverage {
115 pub lines: u8,
116 pub branches: u8,
117 pub functions: u8,
118 pub statements: u8,
119}
120
121impl Default for TypeScriptCoverage {
127 fn default() -> Self {
128 Self {
129 lines: 100,
130 branches: 100,
131 functions: 100,
132 statements: 100,
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
145#[serde(default, deny_unknown_fields)]
146pub struct RustCoverage {
147 pub regions: Option<u8>,
148 pub lines: u8,
149 pub functions: Option<u8>,
150 pub branch: Option<u8>,
151}
152
153impl Default for RustCoverage {
162 fn default() -> Self {
163 Self {
164 regions: None,
165 lines: 100,
166 functions: None,
167 branch: None,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum Rule {
176 ColocatedTest,
178 Coverage,
180 CoChange,
183 NoMonkeypatch,
185 NoInlinePatch,
187 NoEnvironMutation,
189 NoConstantPatch,
191 NoFirstPartyPatch,
193 NoOutOfModuleCall,
195 NoOutOfModuleImport,
197 NoFirstPartyDouble,
199 UnmockedCollaborator,
201 UntypedMock,
203 NoFirstPartyMock,
205 Mutation,
207}
208
209impl Rule {
210 pub fn is_line_scopable(self) -> bool {
215 matches!(self, Rule::Coverage | Rule::Mutation)
216 }
217
218 pub fn id(self) -> &'static str {
221 match self {
222 Rule::ColocatedTest => "colocated-test",
223 Rule::Coverage => "coverage",
224 Rule::CoChange => "co-change",
225 Rule::NoMonkeypatch => "no-monkeypatch",
226 Rule::NoInlinePatch => "no-inline-patch",
227 Rule::NoEnvironMutation => "no-environ-mutation",
228 Rule::NoConstantPatch => "no-constant-patch",
229 Rule::NoFirstPartyPatch => "no-first-party-patch",
230 Rule::NoOutOfModuleCall => "no-out-of-module-call",
231 Rule::NoOutOfModuleImport => "no-out-of-module-import",
232 Rule::NoFirstPartyDouble => "no-first-party-double",
233 Rule::UnmockedCollaborator => "unmocked-collaborator",
234 Rule::UntypedMock => "untyped-mock",
235 Rule::NoFirstPartyMock => "no-first-party-mock",
236 Rule::Mutation => "mutation",
237 }
238 }
239
240 pub fn from_id(id: &str) -> Option<Rule> {
242 [
243 Rule::ColocatedTest,
244 Rule::Coverage,
245 Rule::CoChange,
246 Rule::NoMonkeypatch,
247 Rule::NoInlinePatch,
248 Rule::NoEnvironMutation,
249 Rule::NoConstantPatch,
250 Rule::NoFirstPartyPatch,
251 Rule::NoOutOfModuleCall,
252 Rule::NoOutOfModuleImport,
253 Rule::NoFirstPartyDouble,
254 Rule::UnmockedCollaborator,
255 Rule::UntypedMock,
256 Rule::NoFirstPartyMock,
257 Rule::Mutation,
258 ]
259 .into_iter()
260 .find(|rule| rule.id() == id)
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum LineSpec {
273 Single(u32),
275 Range(u32, u32),
277}
278
279impl LineSpec {
280 fn parse_str(s: &str) -> Result<LineSpec, String> {
284 let parse = |part: &str| {
285 part.trim()
286 .parse::<u32>()
287 .map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
288 };
289 match s.split_once('-') {
290 Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
291 None => Ok(LineSpec::Single(parse(s)?)),
292 }
293 }
294
295 fn extend_into(self, set: &mut BTreeSet<u32>) {
297 match self {
298 LineSpec::Single(n) => {
299 set.insert(n);
300 }
301 LineSpec::Range(start, end) => {
302 for n in start..=end {
303 set.insert(n);
304 }
305 }
306 }
307 }
308}
309
310impl<'de> Deserialize<'de> for LineSpec {
311 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
312 where
313 D: serde::Deserializer<'de>,
314 {
315 struct SpecVisitor;
316 impl serde::de::Visitor<'_> for SpecVisitor {
317 type Value = LineSpec;
318
319 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
320 f.write_str("a line number or a \"start-end\" range string")
321 }
322
323 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
324 u32::try_from(v)
325 .map(LineSpec::Single)
326 .map_err(|_| E::custom(format!("line number {v} is out of range")))
327 }
328
329 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
331 u64::try_from(v)
332 .map_err(|_| E::custom(format!("line number {v} must be positive")))
333 .and_then(|v| self.visit_u64(v))
334 }
335
336 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
337 LineSpec::parse_str(v).map_err(E::custom)
338 }
339 }
340 deserializer.deserialize_any(SpecVisitor)
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct Exemption {
354 pub path: String,
356 pub rules: Vec<Rule>,
358 #[serde(default)]
363 pub lines: Vec<LineSpec>,
364 pub reason: String,
366}
367
368impl Exemption {
369 pub fn line_set(&self) -> BTreeSet<u32> {
372 let mut set = BTreeSet::new();
373 for spec in &self.lines {
374 spec.extend_into(&mut set);
375 }
376 set
377 }
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
386pub enum LineScope {
387 WholeFile,
389 Lines(BTreeSet<u32>),
391}
392
393impl LineScope {
394 fn merged_with(self, other: LineScope) -> LineScope {
398 match (self, other) {
399 (LineScope::WholeFile, _) | (_, LineScope::WholeFile) => LineScope::WholeFile,
400 (LineScope::Lines(mut a), LineScope::Lines(b)) => {
401 a.extend(b);
402 LineScope::Lines(a)
403 }
404 }
405 }
406}
407
408pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
416 let path = path.as_ref();
417 let contents = std::fs::read_to_string(path)
418 .with_context(|| format!("reading config file `{}`", path.display()))?;
419 let config: Config = toml::from_str(&contents)
420 .with_context(|| format!("parsing config file `{}`", path.display()))?;
421 config
422 .validate()
423 .with_context(|| format!("validating config file `{}`", path.display()))?;
424 Ok(config)
425}
426
427impl Config {
428 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
430 match language {
431 crate::colocated_test::Language::Python => {
432 self.python.as_ref().map_or(&[], |c| &c.exempt)
433 }
434 crate::colocated_test::Language::TypeScript => {
435 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
436 }
437 crate::colocated_test::Language::Rust => self.rust_exemptions(),
438 }
439 }
440
441 pub fn rust_exemptions(&self) -> &[Exemption] {
445 self.rust.as_ref().map_or(&[], |c| &c.exempt)
446 }
447
448 fn validate(&self) -> Result<()> {
451 if let Some(python) = &self.python {
455 if python.build_command.is_some() && python.reason.trim().is_empty() {
456 bail!(
457 "[python].build_command has an empty reason — say why the manifest can't \
458 express this build (maturin/PyO3's PEP 517 backend has no pre-build shell \
459 hook; npm's prepare/postinstall and Cargo's build.rs already do)"
460 );
461 }
462 }
463 let tables = [
464 ("python", self.python.as_ref().map(|c| &c.exempt)),
465 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
466 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
467 ];
468 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
469 for entry in exempt {
470 if entry.rules.is_empty() {
471 bail!(
472 "[{table}].exempt entry for `{}` names no rules — set \
473 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
474 entry.path
475 );
476 }
477 if entry.reason.trim().is_empty() {
478 bail!(
479 "[{table}].exempt entry for `{}` has an empty reason — \
480 every exemption must say why the file is exempt",
481 entry.path
482 );
483 }
484 let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
491 let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
492 if entry.lines.is_empty() {
493 if has_scopable {
494 let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
495 bail!(
496 "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
497 a `coverage` / `mutation` exemption must name the exact lines it \
498 covers (whole-file exemptions are for presence / lint rules only)",
499 entry.path,
500 rule.id()
501 );
502 }
503 } else {
504 if has_whole_file {
505 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
506 bail!(
507 "[{table}].exempt entry for `{}` has `lines` alongside rule \
508 `{}` — line-scoped exemptions apply only to `coverage` and \
509 `mutation`; move the whole-file rules to a separate entry",
510 entry.path,
511 rule.id()
512 );
513 }
514 for spec in &entry.lines {
515 let invalid = match spec {
516 LineSpec::Single(n) => *n == 0,
517 LineSpec::Range(start, end) => *start == 0 || start > end,
518 };
519 if invalid {
520 bail!(
521 "[{table}].exempt entry for `{}` has an invalid line spec — \
522 line numbers are 1-based and a range's start must not exceed \
523 its end",
524 entry.path
525 );
526 }
527 }
528 }
529 }
530 }
531 Ok(())
532 }
533}
534
535pub fn resolve_exempt(
543 root: &Path,
544 exemptions: &[Exemption],
545 rule: Rule,
546) -> Result<BTreeSet<String>> {
547 Ok(resolve_exempt_scoped(root, exemptions, rule)?
548 .into_keys()
549 .collect())
550}
551
552pub fn resolve_exempt_scoped(
561 root: &Path,
562 exemptions: &[Exemption],
563 rule: Rule,
564) -> Result<std::collections::BTreeMap<String, LineScope>> {
565 let mut scopes: std::collections::BTreeMap<String, LineScope> =
566 std::collections::BTreeMap::new();
567 for entry in exemptions {
568 if !entry.rules.contains(&rule) {
569 continue;
570 }
571 if !root.join(&entry.path).is_file() {
572 bail!(
573 "exempt entry `{}` matches no file under `{}` — remove the stale \
574 entry or fix the path",
575 entry.path,
576 root.display()
577 );
578 }
579 let key = entry.path.replace('\\', "/");
580 let scope = if entry.lines.is_empty() {
581 LineScope::WholeFile
582 } else {
583 LineScope::Lines(entry.line_set())
584 };
585 let merged = match scopes.remove(&key) {
586 Some(existing) => existing.merged_with(scope),
587 None => scope,
588 };
589 scopes.insert(key, merged);
590 }
591 Ok(scopes)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use std::sync::atomic::{AtomicU64, Ordering};
598
599 fn parse(toml_src: &str) -> Result<Config> {
600 let config: Config = toml::from_str(toml_src)?;
601 config.validate()?;
602 Ok(config)
603 }
604
605 #[test]
606 fn an_exemption_with_no_rules_is_rejected() {
607 let err = parse(
608 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
609 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
610 )
611 .unwrap_err();
612 assert!(err.to_string().contains("names no rules"), "got: {err}");
613 }
614
615 #[test]
616 fn an_exemption_with_an_empty_reason_is_rejected() {
617 let err = parse(
618 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
619 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
620 )
621 .unwrap_err();
622 assert!(err.to_string().contains("empty reason"), "got: {err}");
623 }
624
625 #[test]
626 fn an_unknown_rule_is_rejected() {
627 assert!(parse(
628 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
629 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
630 )
631 .is_err());
632 }
633
634 #[test]
635 fn default_python_coverage_is_the_strict_floor() {
636 assert_eq!(
639 PythonCoverage::default(),
640 PythonCoverage {
641 branch: true,
642 fail_under: 100,
643 }
644 );
645 }
646
647 #[test]
648 fn default_typescript_coverage_is_the_strict_floor() {
649 assert_eq!(
652 TypeScriptCoverage::default(),
653 TypeScriptCoverage {
654 lines: 100,
655 branches: 100,
656 functions: 100,
657 statements: 100,
658 }
659 );
660 }
661
662 #[test]
663 fn default_rust_coverage_is_the_strict_line_floor() {
664 assert_eq!(
669 RustCoverage::default(),
670 RustCoverage {
671 regions: None,
672 lines: 100,
673 functions: None,
674 branch: None,
675 }
676 );
677 }
678
679 #[test]
680 fn rust_coverage_table_parses_with_regions_omitted() {
681 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
684 let coverage = config.rust.unwrap().coverage.unwrap();
685 assert_eq!(coverage.regions, None);
686 assert_eq!(coverage.lines, 90);
687 }
688
689 #[test]
690 fn a_python_build_command_with_a_reason_parses() {
691 let config = parse(
693 "[python]\nbuild_command = \"uv run maturin develop\"\n\
694 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
695 )
696 .unwrap();
697 let python = config.python.unwrap();
698 assert_eq!(
699 python.build_command.as_deref(),
700 Some("uv run maturin develop")
701 );
702 assert_eq!(
703 python.reason,
704 "maturin's PEP 517 backend has no pre-build shell hook"
705 );
706 }
707
708 #[test]
709 fn a_python_build_command_with_an_empty_reason_is_rejected() {
710 let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\nreason = \" \"\n")
711 .unwrap_err();
712 assert!(err.to_string().contains("empty reason"), "got: {err}");
713 }
714
715 #[test]
716 fn a_python_build_command_with_no_reason_is_rejected() {
717 let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap_err();
719 assert!(err.to_string().contains("empty reason"), "got: {err}");
720 }
721
722 #[test]
723 fn a_python_table_without_a_build_command_needs_no_reason() {
724 let config = parse("[python]\ncoverage = { branch = true, fail_under = 90 }\n").unwrap();
727 let python = config.python.unwrap();
728 assert!(python.build_command.is_none());
729 assert!(python.reason.is_empty());
730 }
731
732 #[test]
733 fn a_valid_exemption_parses() {
734 let config = parse(
736 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
737 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
738 reason = \"thin launcher\"\n",
739 )
740 .unwrap();
741 let exempt = &config.python.unwrap().exempt;
742 assert_eq!(exempt.len(), 1);
743 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
744 assert!(exempt[0].lines.is_empty());
745 }
746
747 #[test]
748 fn exemptions_reads_the_rust_table() {
749 let config = parse(
750 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
751 reason = \"generated\"\n",
752 )
753 .unwrap();
754 let rust = config.exemptions(crate::colocated_test::Language::Rust);
755 assert_eq!(rust.len(), 1);
756 assert_eq!(rust[0].path, "build.rs");
757 }
758
759 struct TempTree(std::path::PathBuf);
761
762 impl TempTree {
763 fn new(files: &[&str]) -> Self {
764 static COUNTER: AtomicU64 = AtomicU64::new(0);
765 let root = std::env::temp_dir().join(format!(
766 "tc-exempt-{}-{}",
767 std::process::id(),
768 COUNTER.fetch_add(1, Ordering::Relaxed),
769 ));
770 for rel in files {
771 let path = root.join(rel);
772 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
773 std::fs::write(path, "x = 1\n").unwrap();
774 }
775 TempTree(root)
776 }
777 }
778
779 impl Drop for TempTree {
780 fn drop(&mut self) {
781 let _ = std::fs::remove_dir_all(&self.0);
782 }
783 }
784
785 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
786 Exemption {
787 path: path.to_string(),
788 rules: rules.to_vec(),
789 lines: vec![],
790 reason: "deliberate".to_string(),
791 }
792 }
793
794 #[test]
795 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
796 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
797 let exemptions = [
798 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
799 exemption("pkg/gen.py", &[Rule::Coverage]),
800 exemption("loc_only.py", &[Rule::ColocatedTest]),
801 ];
802 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
803 assert_eq!(
804 coverage.into_iter().collect::<Vec<_>>(),
805 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
806 );
807 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
808 assert_eq!(
809 colocated_test.into_iter().collect::<Vec<_>>(),
810 vec!["cli.py".to_string(), "loc_only.py".to_string()],
811 );
812 }
813
814 #[test]
815 fn a_stale_exempt_path_is_an_error() {
816 let tree = TempTree::new(&["cli.py"]);
817 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
818 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
819 assert!(err.to_string().contains("matches no file"), "got: {err}");
820 }
821
822 #[test]
825 fn line_specs_parse_from_ints_and_range_strings() {
826 let config = parse(
829 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
830 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
831 )
832 .unwrap();
833 let exempt = &config.python.unwrap().exempt[0];
834 assert_eq!(
835 exempt.lines,
836 vec![
837 LineSpec::Single(9),
838 LineSpec::Single(10),
839 LineSpec::Range(12, 13),
840 ]
841 );
842 assert_eq!(
844 exempt.line_set().into_iter().collect::<Vec<_>>(),
845 vec![9, 10, 12, 13]
846 );
847 }
848
849 #[test]
850 fn a_coverage_exemption_without_lines_is_rejected() {
851 let err = parse(
854 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
855 )
856 .unwrap_err();
857 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
858 }
859
860 #[test]
861 fn a_mutation_exemption_without_lines_is_rejected() {
862 let err = parse(
863 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
864 )
865 .unwrap_err();
866 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
867 }
868
869 #[test]
870 fn lines_on_a_whole_file_rule_is_rejected() {
871 let err = parse(
874 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
875 lines = [3]\nreason = \"shim\"\n",
876 )
877 .unwrap_err();
878 assert!(
879 err.to_string()
880 .contains("line-scoped exemptions apply only"),
881 "got: {err}"
882 );
883 }
884
885 #[test]
886 fn a_zero_line_is_rejected() {
887 let err = parse(
888 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
889 lines = [0]\nreason = \"x\"\n",
890 )
891 .unwrap_err();
892 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
893 }
894
895 #[test]
896 fn a_reversed_range_is_rejected() {
897 let err = parse(
898 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
899 lines = [\"13-12\"]\nreason = \"x\"\n",
900 )
901 .unwrap_err();
902 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
903 }
904
905 #[test]
906 fn a_non_numeric_line_spec_is_a_parse_error() {
907 assert!(parse(
909 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
910 lines = [\"oops\"]\nreason = \"x\"\n",
911 )
912 .is_err());
913 }
914
915 #[test]
916 fn resolve_scoped_distinguishes_whole_file_from_lines() {
917 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
920 let exemptions = [
921 exemption("barrel.py", &[Rule::ColocatedTest]),
922 Exemption {
923 path: "scoped.py".to_string(),
924 rules: vec![Rule::Coverage],
925 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
926 reason: "dead branch".to_string(),
927 },
928 ];
929 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
930 assert_eq!(
931 coverage["scoped.py"],
932 LineScope::Lines([2, 4, 5].into_iter().collect())
933 );
934 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
935 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
936 }
937
938 #[test]
939 fn resolve_scoped_merges_two_entries_for_one_file() {
940 let tree = TempTree::new(&["a.py", "b.py"]);
943 let line = |n: u32| Exemption {
944 path: "a.py".to_string(),
945 rules: vec![Rule::Mutation],
946 lines: vec![LineSpec::Single(n)],
947 reason: "equivalent mutant".to_string(),
948 };
949 let mutation = [line(3), line(7)];
950 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
951 assert_eq!(
952 scopes["a.py"],
953 LineScope::Lines([3, 7].into_iter().collect())
954 );
955
956 let presence = [
957 exemption("b.py", &[Rule::ColocatedTest]),
958 exemption("b.py", &[Rule::ColocatedTest]),
959 ];
960 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
961 assert_eq!(scopes["b.py"], LineScope::WholeFile);
962 }
963}