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 (only `coverage` and `mutation` are line-scoped; every \
489 other check or rule is whole-file)",
490 entry.path,
491 rule.id()
492 );
493 }
494 } else {
495 if has_whole_file {
496 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
497 bail!(
498 "[{table}].exempt entry for `{}` has `lines` alongside rule \
499 `{}` — line-scoped exemptions apply only to `coverage` and \
500 `mutation`; move the rest to a separate entry",
501 entry.path,
502 rule.id()
503 );
504 }
505 for spec in &entry.lines {
506 let invalid = match spec {
507 LineSpec::Single(n) => *n == 0,
508 LineSpec::Range(start, end) => *start == 0 || start > end,
509 };
510 if invalid {
511 bail!(
512 "[{table}].exempt entry for `{}` has an invalid line spec — \
513 line numbers are 1-based and a range's start must not exceed \
514 its end",
515 entry.path
516 );
517 }
518 }
519 }
520 }
521 }
522 Ok(())
523 }
524}
525
526pub fn resolve_exempt(
529 root: &Path,
530 exemptions: &[Exemption],
531 rule: Rule,
532) -> Result<BTreeSet<String>> {
533 Ok(resolve_exempt_scoped(root, exemptions, rule)?
534 .into_keys()
535 .collect())
536}
537
538pub fn resolve_exempt_scoped(
541 root: &Path,
542 exemptions: &[Exemption],
543 rule: Rule,
544) -> Result<std::collections::BTreeMap<String, LineScope>> {
545 let mut scopes: std::collections::BTreeMap<String, LineScope> =
546 std::collections::BTreeMap::new();
547 for entry in exemptions {
548 if !entry.rules.contains(&rule) {
549 continue;
550 }
551 if !root.join(&entry.path).is_file() {
552 bail!(
553 "exempt entry `{}` matches no file under `{}` — remove the stale \
554 entry or fix the path",
555 entry.path,
556 root.display()
557 );
558 }
559 let key = entry.path.replace('\\', "/");
560 let scope = if entry.lines.is_empty() {
561 LineScope::WholeFile
562 } else {
563 LineScope::Lines(entry.line_set())
564 };
565 let merged = match scopes.remove(&key) {
566 Some(existing) => existing.merged_with(scope),
567 None => scope,
568 };
569 scopes.insert(key, merged);
570 }
571 Ok(scopes)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use std::sync::atomic::{AtomicU64, Ordering};
578
579 fn parse(toml_src: &str) -> Result<Config> {
580 let config: Config = toml::from_str(toml_src)?;
581 config.validate()?;
582 Ok(config)
583 }
584
585 fn toml_error(toml_src: &str) -> toml::de::Error {
588 toml::from_str::<Config>(toml_src).expect_err("the source should fail to parse")
589 }
590
591 #[test]
592 fn annotate_points_an_unknown_key_error_at_migrations() {
593 let annotated = annotate_toml_error(toml_error("[python]\nbogus = true\n"));
594 let chain = format!("{annotated:#}");
595 assert!(chain.contains("MIGRATIONS.md"), "got: {chain}");
596 assert!(chain.contains("unknown field `bogus`"), "got: {chain}");
598 }
599
600 #[test]
601 fn annotate_leaves_a_non_unknown_key_error_untouched() {
602 let annotated = annotate_toml_error(toml_error(
605 "[python]\ncoverage = { fail_under = \"lots\" }\n",
606 ));
607 assert!(
608 !format!("{annotated:#}").contains("MIGRATIONS.md"),
609 "got: {annotated:#}"
610 );
611 }
612
613 #[test]
614 fn an_exemption_with_no_rules_is_rejected() {
615 let err = parse(
616 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
617 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
618 )
619 .unwrap_err();
620 assert!(err.to_string().contains("names no rules"), "got: {err}");
621 }
622
623 #[test]
624 fn an_exemption_with_an_empty_reason_is_rejected() {
625 let err = parse(
626 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
627 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
628 )
629 .unwrap_err();
630 assert!(err.to_string().contains("empty reason"), "got: {err}");
631 }
632
633 #[test]
634 fn an_unknown_rule_is_rejected() {
635 assert!(parse(
636 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
637 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
638 )
639 .is_err());
640 }
641
642 #[test]
643 fn a_wrong_typed_line_spec_names_the_expected_forms() {
644 let err = parse(
645 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
646 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
647 lines = [true]\nreason = \"x\"\n",
648 )
649 .unwrap_err();
650 assert!(
651 err.to_string()
652 .contains("a line number or a \"start-end\" range string"),
653 "got: {err}"
654 );
655 }
656
657 #[test]
658 fn default_python_coverage_is_the_strict_floor() {
659 assert_eq!(
662 PythonCoverage::default(),
663 PythonCoverage {
664 branch: true,
665 fail_under: 100,
666 }
667 );
668 }
669
670 #[test]
671 fn default_typescript_coverage_is_the_strict_floor() {
672 assert_eq!(
675 TypeScriptCoverage::default(),
676 TypeScriptCoverage {
677 lines: 100,
678 branches: 100,
679 functions: 100,
680 statements: 100,
681 }
682 );
683 }
684
685 #[test]
686 fn default_rust_coverage_is_the_strict_line_floor() {
687 assert_eq!(
688 RustCoverage::default(),
689 RustCoverage {
690 regions: None,
691 lines: 100,
692 functions: None,
693 branch: None,
694 }
695 );
696 }
697
698 #[test]
699 fn rust_coverage_table_parses_with_regions_omitted() {
700 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
703 let coverage = config.rust.unwrap().coverage.unwrap();
704 assert_eq!(coverage.regions, None);
705 assert_eq!(coverage.lines, 90);
706 }
707
708 #[test]
709 fn a_python_build_command_with_an_optional_reason_parses() {
710 let config = parse(
712 "[python]\nbuild_command = \"uv run maturin develop\"\n\
713 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
714 )
715 .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_eq!(
722 python.reason,
723 "maturin's PEP 517 backend has no pre-build shell hook"
724 );
725 }
726
727 #[test]
728 fn a_python_build_command_with_no_reason_loads() {
729 let config = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap();
732 let python = config.python.unwrap();
733 assert_eq!(
734 python.build_command.as_deref(),
735 Some("uv run maturin develop")
736 );
737 assert!(python.reason.is_empty());
738 }
739
740 #[test]
741 fn a_typescript_build_command_with_no_reason_loads() {
742 let config = parse("[typescript]\nbuild_command = \"pnpm build\"\n").unwrap();
744 assert_eq!(
745 config.typescript.unwrap().build_command.as_deref(),
746 Some("pnpm build")
747 );
748 }
749
750 #[test]
751 fn a_valid_exemption_parses() {
752 let config = parse(
754 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
755 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
756 reason = \"thin launcher\"\n",
757 )
758 .unwrap();
759 let exempt = &config.python.unwrap().exempt;
760 assert_eq!(exempt.len(), 1);
761 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
762 assert!(exempt[0].lines.is_empty());
763 }
764
765 #[test]
766 fn exemptions_reads_the_rust_table() {
767 let config = parse(
768 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
769 reason = \"generated\"\n",
770 )
771 .unwrap();
772 let rust = config.exemptions(crate::colocated_test::Language::Rust);
773 assert_eq!(rust.len(), 1);
774 assert_eq!(rust[0].path, "build.rs");
775 }
776
777 #[test]
778 fn exemptions_reads_the_typescript_table() {
779 let config = parse(
780 "[[typescript.exempt]]\npath = \"cli.ts\"\nrules = [\"colocated-test\"]\n\
781 reason = \"thin launcher\"\n",
782 )
783 .unwrap();
784 let ts = config.exemptions(crate::colocated_test::Language::TypeScript);
785 assert_eq!(ts.len(), 1);
786 assert_eq!(ts[0].path, "cli.ts");
787 }
788
789 #[test]
790 fn a_line_number_past_u32_is_rejected() {
791 let err = toml_error(
792 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
793 lines = [4294967296]\nreason = \"dead branch\"\n",
794 );
795 assert!(err.to_string().contains("out of range"), "got: {err}");
796 }
797
798 #[test]
799 fn a_negative_line_number_is_rejected() {
800 let err = toml_error(
801 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
802 lines = [-1]\nreason = \"dead branch\"\n",
803 );
804 assert!(err.to_string().contains("must be positive"), "got: {err}");
805 }
806
807 struct TempTree(std::path::PathBuf);
809
810 impl TempTree {
811 fn new(files: &[&str]) -> Self {
812 static COUNTER: AtomicU64 = AtomicU64::new(0);
813 let root = std::env::temp_dir().join(format!(
814 "tc-exempt-{}-{}",
815 std::process::id(),
816 COUNTER.fetch_add(1, Ordering::Relaxed),
817 ));
818 for rel in files {
819 let path = root.join(rel);
820 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
821 std::fs::write(path, "x = 1\n").unwrap();
822 }
823 TempTree(root)
824 }
825 }
826
827 impl Drop for TempTree {
828 fn drop(&mut self) {
829 let _ = std::fs::remove_dir_all(&self.0);
830 }
831 }
832
833 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
834 Exemption {
835 path: path.to_string(),
836 rules: rules.to_vec(),
837 lines: vec![],
838 reason: "deliberate".to_string(),
839 }
840 }
841
842 #[test]
843 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
844 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
845 let exemptions = [
846 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
847 exemption("pkg/gen.py", &[Rule::Coverage]),
848 exemption("loc_only.py", &[Rule::ColocatedTest]),
849 ];
850 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
851 assert_eq!(
852 coverage.into_iter().collect::<Vec<_>>(),
853 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
854 );
855 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
856 assert_eq!(
857 colocated_test.into_iter().collect::<Vec<_>>(),
858 vec!["cli.py".to_string(), "loc_only.py".to_string()],
859 );
860 }
861
862 #[test]
863 fn a_stale_exempt_path_is_an_error() {
864 let tree = TempTree::new(&["cli.py"]);
865 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
866 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
867 assert!(err.to_string().contains("matches no file"), "got: {err}");
868 }
869
870 #[test]
871 fn line_specs_parse_from_ints_and_range_strings() {
872 let config = parse(
875 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
876 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
877 )
878 .unwrap();
879 let exempt = &config.python.unwrap().exempt[0];
880 assert_eq!(
881 exempt.lines,
882 vec![
883 LineSpec::Single(9),
884 LineSpec::Single(10),
885 LineSpec::Range(12, 13),
886 ]
887 );
888 assert_eq!(
890 exempt.line_set().into_iter().collect::<Vec<_>>(),
891 vec![9, 10, 12, 13]
892 );
893 }
894
895 #[test]
896 fn a_coverage_exemption_without_lines_is_rejected() {
897 let err = parse(
900 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
901 )
902 .unwrap_err();
903 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
904 assert!(
905 err.to_string().contains(
906 "only `coverage` and `mutation` are line-scoped; every other check or rule is \
907 whole-file"
908 ),
909 "got: {err}"
910 );
911 }
912
913 #[test]
914 fn a_mutation_exemption_without_lines_is_rejected() {
915 let err = parse(
916 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
917 )
918 .unwrap_err();
919 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
920 }
921
922 #[test]
923 fn lines_on_a_whole_file_rule_is_rejected() {
924 let err = parse(
927 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
928 lines = [3]\nreason = \"shim\"\n",
929 )
930 .unwrap_err();
931 assert!(
932 err.to_string()
933 .contains("line-scoped exemptions apply only"),
934 "got: {err}"
935 );
936 assert!(
937 err.to_string()
938 .contains("move the rest to a separate entry"),
939 "got: {err}"
940 );
941 }
942
943 #[test]
944 fn a_zero_line_is_rejected() {
945 let err = parse(
946 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
947 lines = [0]\nreason = \"x\"\n",
948 )
949 .unwrap_err();
950 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
951 }
952
953 #[test]
954 fn a_reversed_range_is_rejected() {
955 let err = parse(
956 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
957 lines = [\"13-12\"]\nreason = \"x\"\n",
958 )
959 .unwrap_err();
960 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
961 }
962
963 #[test]
964 fn a_non_numeric_line_spec_is_a_parse_error() {
965 assert!(parse(
967 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
968 lines = [\"oops\"]\nreason = \"x\"\n",
969 )
970 .is_err());
971 }
972
973 #[test]
974 fn resolve_scoped_distinguishes_whole_file_from_lines() {
975 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
978 let exemptions = [
979 exemption("barrel.py", &[Rule::ColocatedTest]),
980 Exemption {
981 path: "scoped.py".to_string(),
982 rules: vec![Rule::Coverage],
983 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
984 reason: "dead branch".to_string(),
985 },
986 ];
987 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
988 assert_eq!(
989 coverage["scoped.py"],
990 LineScope::Lines([2, 4, 5].into_iter().collect())
991 );
992 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
993 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
994 }
995
996 #[test]
997 fn resolve_scoped_merges_two_entries_for_one_file() {
998 let tree = TempTree::new(&["a.py", "b.py"]);
1001 let line = |n: u32| Exemption {
1002 path: "a.py".to_string(),
1003 rules: vec![Rule::Mutation],
1004 lines: vec![LineSpec::Single(n)],
1005 reason: "equivalent mutant".to_string(),
1006 };
1007 let mutation = [line(3), line(7)];
1008 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
1009 assert_eq!(
1010 scopes["a.py"],
1011 LineScope::Lines([3, 7].into_iter().collect())
1012 );
1013
1014 let presence = [
1015 exemption("b.py", &[Rule::ColocatedTest]),
1016 exemption("b.py", &[Rule::ColocatedTest]),
1017 ];
1018 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
1019 assert_eq!(scopes["b.py"], LineScope::WholeFile);
1020 }
1021}