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!(
676 RustCoverage::default(),
677 RustCoverage {
678 regions: None,
679 lines: 100,
680 functions: None,
681 branch: None,
682 }
683 );
684 }
685
686 #[test]
687 fn rust_coverage_table_parses_with_regions_omitted() {
688 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
691 let coverage = config.rust.unwrap().coverage.unwrap();
692 assert_eq!(coverage.regions, None);
693 assert_eq!(coverage.lines, 90);
694 }
695
696 #[test]
697 fn a_python_build_command_with_an_optional_reason_parses() {
698 let config = parse(
700 "[python]\nbuild_command = \"uv run maturin develop\"\n\
701 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
702 )
703 .unwrap();
704 let python = config.python.unwrap();
705 assert_eq!(
706 python.build_command.as_deref(),
707 Some("uv run maturin develop")
708 );
709 assert_eq!(
710 python.reason,
711 "maturin's PEP 517 backend has no pre-build shell hook"
712 );
713 }
714
715 #[test]
716 fn a_python_build_command_with_no_reason_loads() {
717 let config = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap();
720 let python = config.python.unwrap();
721 assert_eq!(
722 python.build_command.as_deref(),
723 Some("uv run maturin develop")
724 );
725 assert!(python.reason.is_empty());
726 }
727
728 #[test]
729 fn a_typescript_build_command_with_no_reason_loads() {
730 let config = parse("[typescript]\nbuild_command = \"pnpm build\"\n").unwrap();
732 assert_eq!(
733 config.typescript.unwrap().build_command.as_deref(),
734 Some("pnpm build")
735 );
736 }
737
738 #[test]
739 fn a_valid_exemption_parses() {
740 let config = parse(
742 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
743 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
744 reason = \"thin launcher\"\n",
745 )
746 .unwrap();
747 let exempt = &config.python.unwrap().exempt;
748 assert_eq!(exempt.len(), 1);
749 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
750 assert!(exempt[0].lines.is_empty());
751 }
752
753 #[test]
754 fn exemptions_reads_the_rust_table() {
755 let config = parse(
756 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
757 reason = \"generated\"\n",
758 )
759 .unwrap();
760 let rust = config.exemptions(crate::colocated_test::Language::Rust);
761 assert_eq!(rust.len(), 1);
762 assert_eq!(rust[0].path, "build.rs");
763 }
764
765 struct TempTree(std::path::PathBuf);
767
768 impl TempTree {
769 fn new(files: &[&str]) -> Self {
770 static COUNTER: AtomicU64 = AtomicU64::new(0);
771 let root = std::env::temp_dir().join(format!(
772 "tc-exempt-{}-{}",
773 std::process::id(),
774 COUNTER.fetch_add(1, Ordering::Relaxed),
775 ));
776 for rel in files {
777 let path = root.join(rel);
778 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
779 std::fs::write(path, "x = 1\n").unwrap();
780 }
781 TempTree(root)
782 }
783 }
784
785 impl Drop for TempTree {
786 fn drop(&mut self) {
787 let _ = std::fs::remove_dir_all(&self.0);
788 }
789 }
790
791 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
792 Exemption {
793 path: path.to_string(),
794 rules: rules.to_vec(),
795 lines: vec![],
796 reason: "deliberate".to_string(),
797 }
798 }
799
800 #[test]
801 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
802 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
803 let exemptions = [
804 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
805 exemption("pkg/gen.py", &[Rule::Coverage]),
806 exemption("loc_only.py", &[Rule::ColocatedTest]),
807 ];
808 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
809 assert_eq!(
810 coverage.into_iter().collect::<Vec<_>>(),
811 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
812 );
813 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
814 assert_eq!(
815 colocated_test.into_iter().collect::<Vec<_>>(),
816 vec!["cli.py".to_string(), "loc_only.py".to_string()],
817 );
818 }
819
820 #[test]
821 fn a_stale_exempt_path_is_an_error() {
822 let tree = TempTree::new(&["cli.py"]);
823 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
824 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
825 assert!(err.to_string().contains("matches no file"), "got: {err}");
826 }
827
828 #[test]
829 fn line_specs_parse_from_ints_and_range_strings() {
830 let config = parse(
833 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
834 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
835 )
836 .unwrap();
837 let exempt = &config.python.unwrap().exempt[0];
838 assert_eq!(
839 exempt.lines,
840 vec![
841 LineSpec::Single(9),
842 LineSpec::Single(10),
843 LineSpec::Range(12, 13),
844 ]
845 );
846 assert_eq!(
848 exempt.line_set().into_iter().collect::<Vec<_>>(),
849 vec![9, 10, 12, 13]
850 );
851 }
852
853 #[test]
854 fn a_coverage_exemption_without_lines_is_rejected() {
855 let err = parse(
858 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
859 )
860 .unwrap_err();
861 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
862 }
863
864 #[test]
865 fn a_mutation_exemption_without_lines_is_rejected() {
866 let err = parse(
867 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
868 )
869 .unwrap_err();
870 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
871 }
872
873 #[test]
874 fn lines_on_a_whole_file_rule_is_rejected() {
875 let err = parse(
878 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
879 lines = [3]\nreason = \"shim\"\n",
880 )
881 .unwrap_err();
882 assert!(
883 err.to_string()
884 .contains("line-scoped exemptions apply only"),
885 "got: {err}"
886 );
887 }
888
889 #[test]
890 fn a_zero_line_is_rejected() {
891 let err = parse(
892 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
893 lines = [0]\nreason = \"x\"\n",
894 )
895 .unwrap_err();
896 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
897 }
898
899 #[test]
900 fn a_reversed_range_is_rejected() {
901 let err = parse(
902 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
903 lines = [\"13-12\"]\nreason = \"x\"\n",
904 )
905 .unwrap_err();
906 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
907 }
908
909 #[test]
910 fn a_non_numeric_line_spec_is_a_parse_error() {
911 assert!(parse(
913 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
914 lines = [\"oops\"]\nreason = \"x\"\n",
915 )
916 .is_err());
917 }
918
919 #[test]
920 fn resolve_scoped_distinguishes_whole_file_from_lines() {
921 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
924 let exemptions = [
925 exemption("barrel.py", &[Rule::ColocatedTest]),
926 Exemption {
927 path: "scoped.py".to_string(),
928 rules: vec![Rule::Coverage],
929 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
930 reason: "dead branch".to_string(),
931 },
932 ];
933 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
934 assert_eq!(
935 coverage["scoped.py"],
936 LineScope::Lines([2, 4, 5].into_iter().collect())
937 );
938 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
939 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
940 }
941
942 #[test]
943 fn resolve_scoped_merges_two_entries_for_one_file() {
944 let tree = TempTree::new(&["a.py", "b.py"]);
947 let line = |n: u32| Exemption {
948 path: "a.py".to_string(),
949 rules: vec![Rule::Mutation],
950 lines: vec![LineSpec::Single(n)],
951 reason: "equivalent mutant".to_string(),
952 };
953 let mutation = [line(3), line(7)];
954 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
955 assert_eq!(
956 scopes["a.py"],
957 LineScope::Lines([3, 7].into_iter().collect())
958 );
959
960 let presence = [
961 exemption("b.py", &[Rule::ColocatedTest]),
962 exemption("b.py", &[Rule::ColocatedTest]),
963 ];
964 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
965 assert_eq!(scopes["b.py"], LineScope::WholeFile);
966 }
967}