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 a_wrong_typed_line_spec_names_the_expected_forms() {
643 let err = parse(
644 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
645 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
646 lines = [true]\nreason = \"x\"\n",
647 )
648 .unwrap_err();
649 assert!(
650 err.to_string()
651 .contains("a line number or a \"start-end\" range string"),
652 "got: {err}"
653 );
654 }
655
656 #[test]
657 fn default_python_coverage_is_the_strict_floor() {
658 assert_eq!(
661 PythonCoverage::default(),
662 PythonCoverage {
663 branch: true,
664 fail_under: 100,
665 }
666 );
667 }
668
669 #[test]
670 fn default_typescript_coverage_is_the_strict_floor() {
671 assert_eq!(
674 TypeScriptCoverage::default(),
675 TypeScriptCoverage {
676 lines: 100,
677 branches: 100,
678 functions: 100,
679 statements: 100,
680 }
681 );
682 }
683
684 #[test]
685 fn default_rust_coverage_is_the_strict_line_floor() {
686 assert_eq!(
687 RustCoverage::default(),
688 RustCoverage {
689 regions: None,
690 lines: 100,
691 functions: None,
692 branch: None,
693 }
694 );
695 }
696
697 #[test]
698 fn rust_coverage_table_parses_with_regions_omitted() {
699 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
702 let coverage = config.rust.unwrap().coverage.unwrap();
703 assert_eq!(coverage.regions, None);
704 assert_eq!(coverage.lines, 90);
705 }
706
707 #[test]
708 fn a_python_build_command_with_an_optional_reason_parses() {
709 let config = parse(
711 "[python]\nbuild_command = \"uv run maturin develop\"\n\
712 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
713 )
714 .unwrap();
715 let python = config.python.unwrap();
716 assert_eq!(
717 python.build_command.as_deref(),
718 Some("uv run maturin develop")
719 );
720 assert_eq!(
721 python.reason,
722 "maturin's PEP 517 backend has no pre-build shell hook"
723 );
724 }
725
726 #[test]
727 fn a_python_build_command_with_no_reason_loads() {
728 let config = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap();
731 let python = config.python.unwrap();
732 assert_eq!(
733 python.build_command.as_deref(),
734 Some("uv run maturin develop")
735 );
736 assert!(python.reason.is_empty());
737 }
738
739 #[test]
740 fn a_typescript_build_command_with_no_reason_loads() {
741 let config = parse("[typescript]\nbuild_command = \"pnpm build\"\n").unwrap();
743 assert_eq!(
744 config.typescript.unwrap().build_command.as_deref(),
745 Some("pnpm build")
746 );
747 }
748
749 #[test]
750 fn a_valid_exemption_parses() {
751 let config = parse(
753 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
754 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
755 reason = \"thin launcher\"\n",
756 )
757 .unwrap();
758 let exempt = &config.python.unwrap().exempt;
759 assert_eq!(exempt.len(), 1);
760 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
761 assert!(exempt[0].lines.is_empty());
762 }
763
764 #[test]
765 fn exemptions_reads_the_rust_table() {
766 let config = parse(
767 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
768 reason = \"generated\"\n",
769 )
770 .unwrap();
771 let rust = config.exemptions(crate::colocated_test::Language::Rust);
772 assert_eq!(rust.len(), 1);
773 assert_eq!(rust[0].path, "build.rs");
774 }
775
776 #[test]
777 fn exemptions_reads_the_typescript_table() {
778 let config = parse(
779 "[[typescript.exempt]]\npath = \"cli.ts\"\nrules = [\"colocated-test\"]\n\
780 reason = \"thin launcher\"\n",
781 )
782 .unwrap();
783 let ts = config.exemptions(crate::colocated_test::Language::TypeScript);
784 assert_eq!(ts.len(), 1);
785 assert_eq!(ts[0].path, "cli.ts");
786 }
787
788 #[test]
789 fn a_line_number_past_u32_is_rejected() {
790 let err = toml_error(
791 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
792 lines = [4294967296]\nreason = \"dead branch\"\n",
793 );
794 assert!(err.to_string().contains("out of range"), "got: {err}");
795 }
796
797 #[test]
798 fn a_negative_line_number_is_rejected() {
799 let err = toml_error(
800 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
801 lines = [-1]\nreason = \"dead branch\"\n",
802 );
803 assert!(err.to_string().contains("must be positive"), "got: {err}");
804 }
805
806 struct TempTree(std::path::PathBuf);
808
809 impl TempTree {
810 fn new(files: &[&str]) -> Self {
811 static COUNTER: AtomicU64 = AtomicU64::new(0);
812 let root = std::env::temp_dir().join(format!(
813 "tc-exempt-{}-{}",
814 std::process::id(),
815 COUNTER.fetch_add(1, Ordering::Relaxed),
816 ));
817 for rel in files {
818 let path = root.join(rel);
819 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
820 std::fs::write(path, "x = 1\n").unwrap();
821 }
822 TempTree(root)
823 }
824 }
825
826 impl Drop for TempTree {
827 fn drop(&mut self) {
828 let _ = std::fs::remove_dir_all(&self.0);
829 }
830 }
831
832 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
833 Exemption {
834 path: path.to_string(),
835 rules: rules.to_vec(),
836 lines: vec![],
837 reason: "deliberate".to_string(),
838 }
839 }
840
841 #[test]
842 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
843 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
844 let exemptions = [
845 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
846 exemption("pkg/gen.py", &[Rule::Coverage]),
847 exemption("loc_only.py", &[Rule::ColocatedTest]),
848 ];
849 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
850 assert_eq!(
851 coverage.into_iter().collect::<Vec<_>>(),
852 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
853 );
854 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
855 assert_eq!(
856 colocated_test.into_iter().collect::<Vec<_>>(),
857 vec!["cli.py".to_string(), "loc_only.py".to_string()],
858 );
859 }
860
861 #[test]
862 fn a_stale_exempt_path_is_an_error() {
863 let tree = TempTree::new(&["cli.py"]);
864 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
865 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
866 assert!(err.to_string().contains("matches no file"), "got: {err}");
867 }
868
869 #[test]
870 fn line_specs_parse_from_ints_and_range_strings() {
871 let config = parse(
874 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
875 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
876 )
877 .unwrap();
878 let exempt = &config.python.unwrap().exempt[0];
879 assert_eq!(
880 exempt.lines,
881 vec![
882 LineSpec::Single(9),
883 LineSpec::Single(10),
884 LineSpec::Range(12, 13),
885 ]
886 );
887 assert_eq!(
889 exempt.line_set().into_iter().collect::<Vec<_>>(),
890 vec![9, 10, 12, 13]
891 );
892 }
893
894 #[test]
895 fn a_coverage_exemption_without_lines_is_rejected() {
896 let err = parse(
899 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
900 )
901 .unwrap_err();
902 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
903 }
904
905 #[test]
906 fn a_mutation_exemption_without_lines_is_rejected() {
907 let err = parse(
908 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
909 )
910 .unwrap_err();
911 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
912 }
913
914 #[test]
915 fn lines_on_a_whole_file_rule_is_rejected() {
916 let err = parse(
919 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
920 lines = [3]\nreason = \"shim\"\n",
921 )
922 .unwrap_err();
923 assert!(
924 err.to_string()
925 .contains("line-scoped exemptions apply only"),
926 "got: {err}"
927 );
928 }
929
930 #[test]
931 fn a_zero_line_is_rejected() {
932 let err = parse(
933 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
934 lines = [0]\nreason = \"x\"\n",
935 )
936 .unwrap_err();
937 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
938 }
939
940 #[test]
941 fn a_reversed_range_is_rejected() {
942 let err = parse(
943 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
944 lines = [\"13-12\"]\nreason = \"x\"\n",
945 )
946 .unwrap_err();
947 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
948 }
949
950 #[test]
951 fn a_non_numeric_line_spec_is_a_parse_error() {
952 assert!(parse(
954 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
955 lines = [\"oops\"]\nreason = \"x\"\n",
956 )
957 .is_err());
958 }
959
960 #[test]
961 fn resolve_scoped_distinguishes_whole_file_from_lines() {
962 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
965 let exemptions = [
966 exemption("barrel.py", &[Rule::ColocatedTest]),
967 Exemption {
968 path: "scoped.py".to_string(),
969 rules: vec![Rule::Coverage],
970 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
971 reason: "dead branch".to_string(),
972 },
973 ];
974 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
975 assert_eq!(
976 coverage["scoped.py"],
977 LineScope::Lines([2, 4, 5].into_iter().collect())
978 );
979 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
980 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
981 }
982
983 #[test]
984 fn resolve_scoped_merges_two_entries_for_one_file() {
985 let tree = TempTree::new(&["a.py", "b.py"]);
988 let line = |n: u32| Exemption {
989 path: "a.py".to_string(),
990 rules: vec![Rule::Mutation],
991 lines: vec![LineSpec::Single(n)],
992 reason: "equivalent mutant".to_string(),
993 };
994 let mutation = [line(3), line(7)];
995 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
996 assert_eq!(
997 scopes["a.py"],
998 LineScope::Lines([3, 7].into_iter().collect())
999 );
1000
1001 let presence = [
1002 exemption("b.py", &[Rule::ColocatedTest]),
1003 exemption("b.py", &[Rule::ColocatedTest]),
1004 ];
1005 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
1006 assert_eq!(scopes["b.py"], LineScope::WholeFile);
1007 }
1008}