1use std::collections::BTreeSet;
5use std::path::Path;
6
7use anyhow::{bail, Context, Result};
8use serde::Deserialize;
9
10#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct Config {
14 pub python: Option<PythonConfig>,
15 pub typescript: Option<TypeScriptConfig>,
16 pub rust: Option<RustConfig>,
17 pub e2e: Option<E2eConfig>,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct PythonConfig {
24 pub coverage: Option<PythonCoverage>,
25 pub one_function_per_file: Option<OneFunctionPerFile>,
27 #[serde(default)]
28 pub exempt: Vec<Exemption>,
29 pub build_command: Option<String>,
32 #[serde(default)]
34 pub reason: String,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct E2eConfig {
42 #[serde(default)]
43 pub extra_scope: Vec<String>,
44 #[serde(default)]
45 pub exclude: Vec<String>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct TypeScriptConfig {
52 pub coverage: Option<TypeScriptCoverage>,
53 pub one_function_per_file: Option<OneFunctionPerFile>,
55 #[serde(default)]
56 pub exempt: Vec<Exemption>,
57 pub build_command: Option<String>,
59 #[serde(default)]
61 pub reason: String,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct RustConfig {
68 pub coverage: Option<RustCoverage>,
69 pub one_function_per_file: Option<OneFunctionPerFile>,
71 #[serde(default)]
73 pub features: Vec<String>,
74 #[serde(default)]
75 pub exempt: Vec<Exemption>,
76 pub build_command: Option<String>,
78 #[serde(default)]
80 pub reason: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(default, deny_unknown_fields)]
87pub struct PythonCoverage {
88 pub branch: bool,
89 pub fail_under: u8,
90}
91
92impl Default for PythonCoverage {
94 fn default() -> Self {
95 Self {
96 branch: true,
97 fail_under: 100,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct TypeScriptCoverage {
107 pub lines: u8,
108 pub branches: u8,
109 pub functions: u8,
110 pub statements: u8,
111}
112
113impl Default for TypeScriptCoverage {
115 fn default() -> Self {
116 Self {
117 lines: 100,
118 branches: 100,
119 functions: 100,
120 statements: 100,
121 }
122 }
123}
124
125#[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 pub functions: Option<u8>,
133 pub branch: Option<u8>,
134}
135
136impl Default for RustCoverage {
138 fn default() -> Self {
139 Self {
140 regions: None,
141 lines: 100,
142 functions: None,
143 branch: None,
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct OneFunctionPerFile {
153 pub max_lines: u32,
156}
157
158impl Default for OneFunctionPerFile {
160 fn default() -> Self {
161 Self { max_lines: 1 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum Rule {
169 ColocatedTest,
171 Coverage,
173 CoChange,
175 NoMonkeypatch,
177 NoInlinePatch,
179 NoEnvironMutation,
181 NoConstantPatch,
183 NoFirstPartyPatch,
185 NoOutOfModuleCall,
187 NoOutOfModuleImport,
189 NoFirstPartyDouble,
191 UnmockedCollaborator,
193 UntypedMock,
195 NoFirstPartyMock,
197 UnknownTier,
199 Mutation,
201 OneFunctionPerFile,
203}
204
205impl Rule {
206 pub fn is_line_scopable(self) -> bool {
208 matches!(self, Rule::Coverage | Rule::Mutation)
209 }
210
211 pub fn id(self) -> &'static str {
213 match self {
214 Rule::ColocatedTest => "colocated-test",
215 Rule::Coverage => "coverage",
216 Rule::CoChange => "co-change",
217 Rule::NoMonkeypatch => "no-monkeypatch",
218 Rule::NoInlinePatch => "no-inline-patch",
219 Rule::NoEnvironMutation => "no-environ-mutation",
220 Rule::NoConstantPatch => "no-constant-patch",
221 Rule::NoFirstPartyPatch => "no-first-party-patch",
222 Rule::NoOutOfModuleCall => "no-out-of-module-call",
223 Rule::NoOutOfModuleImport => "no-out-of-module-import",
224 Rule::NoFirstPartyDouble => "no-first-party-double",
225 Rule::UnmockedCollaborator => "unmocked-collaborator",
226 Rule::UntypedMock => "untyped-mock",
227 Rule::NoFirstPartyMock => "no-first-party-mock",
228 Rule::UnknownTier => "unknown-tier",
229 Rule::Mutation => "mutation",
230 Rule::OneFunctionPerFile => "one-function-per-file",
231 }
232 }
233
234 pub fn from_id(id: &str) -> Option<Rule> {
236 [
237 Rule::ColocatedTest,
238 Rule::Coverage,
239 Rule::CoChange,
240 Rule::NoMonkeypatch,
241 Rule::NoInlinePatch,
242 Rule::NoEnvironMutation,
243 Rule::NoConstantPatch,
244 Rule::NoFirstPartyPatch,
245 Rule::NoOutOfModuleCall,
246 Rule::NoOutOfModuleImport,
247 Rule::NoFirstPartyDouble,
248 Rule::UnmockedCollaborator,
249 Rule::UntypedMock,
250 Rule::NoFirstPartyMock,
251 Rule::UnknownTier,
252 Rule::Mutation,
253 Rule::OneFunctionPerFile,
254 ]
255 .into_iter()
256 .find(|rule| rule.id() == id)
257 }
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum LineSpec {
265 Single(u32),
266 Range(u32, u32),
268}
269
270impl LineSpec {
271 fn parse_str(s: &str) -> Result<LineSpec, String> {
273 let parse = |part: &str| {
274 part.trim()
275 .parse::<u32>()
276 .map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
277 };
278 match s.split_once('-') {
279 Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
280 None => Ok(LineSpec::Single(parse(s)?)),
281 }
282 }
283
284 fn extend_into(self, set: &mut BTreeSet<u32>) {
286 match self {
287 LineSpec::Single(n) => {
288 set.insert(n);
289 }
290 LineSpec::Range(start, end) => {
291 for n in start..=end {
292 set.insert(n);
293 }
294 }
295 }
296 }
297}
298
299impl<'de> Deserialize<'de> for LineSpec {
300 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
301 where
302 D: serde::Deserializer<'de>,
303 {
304 struct SpecVisitor;
305 impl serde::de::Visitor<'_> for SpecVisitor {
306 type Value = LineSpec;
307
308 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
309 f.write_str("a line number or a \"start-end\" range string")
310 }
311
312 fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
313 u32::try_from(v)
314 .map(LineSpec::Single)
315 .map_err(|_| E::custom(format!("line number {v} is out of range")))
316 }
317
318 fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
320 u64::try_from(v)
321 .map_err(|_| E::custom(format!("line number {v} must be positive")))
322 .and_then(|v| self.visit_u64(v))
323 }
324
325 fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
326 LineSpec::parse_str(v).map_err(E::custom)
327 }
328 }
329 deserializer.deserialize_any(SpecVisitor)
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct Exemption {
337 pub path: String,
339 pub rules: Vec<Rule>,
341 #[serde(default)]
343 pub lines: Vec<LineSpec>,
344 pub reason: String,
346}
347
348impl Exemption {
349 pub fn line_set(&self) -> BTreeSet<u32> {
351 let mut set = BTreeSet::new();
352 for spec in &self.lines {
353 spec.extend_into(&mut set);
354 }
355 set
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum LineScope {
362 WholeFile,
363 Lines(BTreeSet<u32>),
365}
366
367impl LineScope {
368 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
380const MIGRATIONS_URL: &str =
383 "https://github.com/thekevinscott/testing-conventions/blob/main/packages/rust/MIGRATIONS.md";
384
385fn annotate_toml_error(err: toml::de::Error) -> anyhow::Error {
388 if err.message().contains("unknown field") {
389 anyhow::anyhow!(err).context(format!(
390 "an unrecognized key can be a typo or a key a release renamed or removed — see {MIGRATIONS_URL}"
391 ))
392 } else {
393 anyhow::anyhow!(err)
394 }
395}
396
397pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
399 let path = path.as_ref();
400 let contents = std::fs::read_to_string(path)
401 .with_context(|| format!("reading config file `{}`", path.display()))?;
402 let config: Config = toml::from_str(&contents)
403 .map_err(annotate_toml_error)
404 .with_context(|| format!("parsing config file `{}`", path.display()))?;
405 config
406 .validate()
407 .with_context(|| format!("validating config file `{}`", path.display()))?;
408 Ok(config)
409}
410
411impl Config {
412 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
414 match language {
415 crate::colocated_test::Language::Python => {
416 self.python.as_ref().map_or(&[], |c| &c.exempt)
417 }
418 crate::colocated_test::Language::TypeScript => {
419 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
420 }
421 crate::colocated_test::Language::Rust => self.rust_exemptions(),
422 }
423 }
424
425 pub fn one_function_threshold(&self, language: crate::colocated_test::Language) -> Option<u32> {
429 match language {
430 crate::colocated_test::Language::Python => Some(
431 self.python
432 .as_ref()
433 .and_then(|c| c.one_function_per_file)
434 .unwrap_or_default()
435 .max_lines,
436 ),
437 crate::colocated_test::Language::TypeScript => Some(
438 self.typescript
439 .as_ref()
440 .and_then(|c| c.one_function_per_file)
441 .unwrap_or_default()
442 .max_lines,
443 ),
444 crate::colocated_test::Language::Rust => self
445 .rust
446 .as_ref()
447 .and_then(|c| c.one_function_per_file)
448 .map(|table| table.max_lines),
449 }
450 }
451
452 pub fn rust_exemptions(&self) -> &[Exemption] {
454 self.rust.as_ref().map_or(&[], |c| &c.exempt)
455 }
456
457 fn validate(&self) -> Result<()> {
459 let tables = [
460 ("python", self.python.as_ref().map(|c| &c.exempt)),
461 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
462 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
463 ];
464 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
465 for entry in exempt {
466 if entry.rules.is_empty() {
467 bail!(
468 "[{table}].exempt entry for `{}` names no rules — set \
469 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
470 entry.path
471 );
472 }
473 if entry.reason.trim().is_empty() {
474 bail!(
475 "[{table}].exempt entry for `{}` has an empty reason — \
476 every exemption must say why the file is exempt",
477 entry.path
478 );
479 }
480 let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
481 let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
482 if entry.lines.is_empty() {
483 if has_scopable {
484 let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
485 bail!(
486 "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
487 a `coverage` / `mutation` exemption must name the exact lines it \
488 covers (whole-file exemptions are for presence / lint rules only)",
489 entry.path,
490 rule.id()
491 );
492 }
493 } else {
494 if has_whole_file {
495 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
496 bail!(
497 "[{table}].exempt entry for `{}` has `lines` alongside rule \
498 `{}` — line-scoped exemptions apply only to `coverage` and \
499 `mutation`; move the whole-file rules to a separate entry",
500 entry.path,
501 rule.id()
502 );
503 }
504 for spec in &entry.lines {
505 let invalid = match spec {
506 LineSpec::Single(n) => *n == 0,
507 LineSpec::Range(start, end) => *start == 0 || start > end,
508 };
509 if invalid {
510 bail!(
511 "[{table}].exempt entry for `{}` has an invalid line spec — \
512 line numbers are 1-based and a range's start must not exceed \
513 its end",
514 entry.path
515 );
516 }
517 }
518 }
519 }
520 }
521 Ok(())
522 }
523}
524
525pub fn resolve_exempt(
528 root: &Path,
529 exemptions: &[Exemption],
530 rule: Rule,
531) -> Result<BTreeSet<String>> {
532 Ok(resolve_exempt_scoped(root, exemptions, rule)?
533 .into_keys()
534 .collect())
535}
536
537pub fn resolve_exempt_scoped(
540 root: &Path,
541 exemptions: &[Exemption],
542 rule: Rule,
543) -> Result<std::collections::BTreeMap<String, LineScope>> {
544 let mut scopes: std::collections::BTreeMap<String, LineScope> =
545 std::collections::BTreeMap::new();
546 for entry in exemptions {
547 if !entry.rules.contains(&rule) {
548 continue;
549 }
550 if !root.join(&entry.path).is_file() {
551 bail!(
552 "exempt entry `{}` matches no file under `{}` — remove the stale \
553 entry or fix the path",
554 entry.path,
555 root.display()
556 );
557 }
558 let key = entry.path.replace('\\', "/");
559 let scope = if entry.lines.is_empty() {
560 LineScope::WholeFile
561 } else {
562 LineScope::Lines(entry.line_set())
563 };
564 let merged = match scopes.remove(&key) {
565 Some(existing) => existing.merged_with(scope),
566 None => scope,
567 };
568 scopes.insert(key, merged);
569 }
570 Ok(scopes)
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::sync::atomic::{AtomicU64, Ordering};
577
578 fn parse(toml_src: &str) -> Result<Config> {
579 let config: Config = toml::from_str(toml_src)?;
580 config.validate()?;
581 Ok(config)
582 }
583
584 fn toml_error(toml_src: &str) -> toml::de::Error {
587 toml::from_str::<Config>(toml_src).expect_err("the source should fail to parse")
588 }
589
590 #[test]
591 fn annotate_points_an_unknown_key_error_at_migrations() {
592 let annotated = annotate_toml_error(toml_error("[python]\nbogus = true\n"));
593 let chain = format!("{annotated:#}");
594 assert!(chain.contains("MIGRATIONS.md"), "got: {chain}");
595 assert!(chain.contains("unknown field `bogus`"), "got: {chain}");
597 }
598
599 #[test]
600 fn annotate_leaves_a_non_unknown_key_error_untouched() {
601 let annotated = annotate_toml_error(toml_error(
604 "[python]\ncoverage = { fail_under = \"lots\" }\n",
605 ));
606 assert!(
607 !format!("{annotated:#}").contains("MIGRATIONS.md"),
608 "got: {annotated:#}"
609 );
610 }
611
612 #[test]
613 fn an_exemption_with_no_rules_is_rejected() {
614 let err = parse(
615 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
616 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
617 )
618 .unwrap_err();
619 assert!(err.to_string().contains("names no rules"), "got: {err}");
620 }
621
622 #[test]
623 fn an_exemption_with_an_empty_reason_is_rejected() {
624 let err = parse(
625 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
626 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
627 )
628 .unwrap_err();
629 assert!(err.to_string().contains("empty reason"), "got: {err}");
630 }
631
632 #[test]
633 fn an_unknown_rule_is_rejected() {
634 assert!(parse(
635 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
636 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
637 )
638 .is_err());
639 }
640
641 #[test]
642 fn default_python_coverage_is_the_strict_floor() {
643 assert_eq!(
646 PythonCoverage::default(),
647 PythonCoverage {
648 branch: true,
649 fail_under: 100,
650 }
651 );
652 }
653
654 #[test]
655 fn default_typescript_coverage_is_the_strict_floor() {
656 assert_eq!(
659 TypeScriptCoverage::default(),
660 TypeScriptCoverage {
661 lines: 100,
662 branches: 100,
663 functions: 100,
664 statements: 100,
665 }
666 );
667 }
668
669 #[test]
670 fn default_rust_coverage_is_the_strict_line_floor() {
671 assert_eq!(
672 RustCoverage::default(),
673 RustCoverage {
674 regions: None,
675 lines: 100,
676 functions: None,
677 branch: None,
678 }
679 );
680 }
681
682 #[test]
683 fn rust_coverage_table_parses_with_regions_omitted() {
684 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
687 let coverage = config.rust.unwrap().coverage.unwrap();
688 assert_eq!(coverage.regions, None);
689 assert_eq!(coverage.lines, 90);
690 }
691
692 #[test]
693 fn a_python_build_command_with_an_optional_reason_parses() {
694 let config = parse(
696 "[python]\nbuild_command = \"uv run maturin develop\"\n\
697 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
698 )
699 .unwrap();
700 let python = config.python.unwrap();
701 assert_eq!(
702 python.build_command.as_deref(),
703 Some("uv run maturin develop")
704 );
705 assert_eq!(
706 python.reason,
707 "maturin's PEP 517 backend has no pre-build shell hook"
708 );
709 }
710
711 #[test]
712 fn a_python_build_command_with_no_reason_loads() {
713 let config = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap();
716 let python = config.python.unwrap();
717 assert_eq!(
718 python.build_command.as_deref(),
719 Some("uv run maturin develop")
720 );
721 assert!(python.reason.is_empty());
722 }
723
724 #[test]
725 fn a_typescript_build_command_with_no_reason_loads() {
726 let config = parse("[typescript]\nbuild_command = \"pnpm build\"\n").unwrap();
728 assert_eq!(
729 config.typescript.unwrap().build_command.as_deref(),
730 Some("pnpm build")
731 );
732 }
733
734 #[test]
735 fn a_valid_exemption_parses() {
736 let config = parse(
738 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
739 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
740 reason = \"thin launcher\"\n",
741 )
742 .unwrap();
743 let exempt = &config.python.unwrap().exempt;
744 assert_eq!(exempt.len(), 1);
745 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
746 assert!(exempt[0].lines.is_empty());
747 }
748
749 #[test]
750 fn exemptions_reads_the_rust_table() {
751 let config = parse(
752 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
753 reason = \"generated\"\n",
754 )
755 .unwrap();
756 let rust = config.exemptions(crate::colocated_test::Language::Rust);
757 assert_eq!(rust.len(), 1);
758 assert_eq!(rust[0].path, "build.rs");
759 }
760
761 struct TempTree(std::path::PathBuf);
763
764 impl TempTree {
765 fn new(files: &[&str]) -> Self {
766 static COUNTER: AtomicU64 = AtomicU64::new(0);
767 let root = std::env::temp_dir().join(format!(
768 "tc-exempt-{}-{}",
769 std::process::id(),
770 COUNTER.fetch_add(1, Ordering::Relaxed),
771 ));
772 for rel in files {
773 let path = root.join(rel);
774 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
775 std::fs::write(path, "x = 1\n").unwrap();
776 }
777 TempTree(root)
778 }
779 }
780
781 impl Drop for TempTree {
782 fn drop(&mut self) {
783 let _ = std::fs::remove_dir_all(&self.0);
784 }
785 }
786
787 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
788 Exemption {
789 path: path.to_string(),
790 rules: rules.to_vec(),
791 lines: vec![],
792 reason: "deliberate".to_string(),
793 }
794 }
795
796 #[test]
797 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
798 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
799 let exemptions = [
800 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
801 exemption("pkg/gen.py", &[Rule::Coverage]),
802 exemption("loc_only.py", &[Rule::ColocatedTest]),
803 ];
804 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
805 assert_eq!(
806 coverage.into_iter().collect::<Vec<_>>(),
807 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
808 );
809 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
810 assert_eq!(
811 colocated_test.into_iter().collect::<Vec<_>>(),
812 vec!["cli.py".to_string(), "loc_only.py".to_string()],
813 );
814 }
815
816 #[test]
817 fn a_stale_exempt_path_is_an_error() {
818 let tree = TempTree::new(&["cli.py"]);
819 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
820 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
821 assert!(err.to_string().contains("matches no file"), "got: {err}");
822 }
823
824 #[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}