Skip to main content

testing_conventions/
config.rs

1//! The testing-conventions config schema and loader.
2//!
3//! One config file is read into the in-memory [`Config`] below. The loader
4//! parses *and* validates the config itself (the "self-guard" from issue #12):
5//! a malformed or unknown-key config is an error, never a silently-accepted
6//! default. Validation also covers the per-file [`Exemption`] list (issue #32):
7//! every exemption must name at least one rule and carry a non-empty reason.
8
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use anyhow::{bail, Context, Result};
13use serde::Deserialize;
14
15/// A fully-parsed testing-conventions config file.
16///
17/// Holds the per-language coverage thresholds — the `[python]` / `[typescript]`
18/// / `[rust]` tables from the README's "Configuration" section — and the
19/// per-language `exempt` lists. Each table is optional so a repo can configure
20/// only the languages it ships. Test locations follow convention, not config, so
21/// there are no location keys here.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Config {
25    pub python: Option<PythonConfig>,
26    pub typescript: Option<TypeScriptConfig>,
27    pub rust: Option<RustConfig>,
28}
29
30/// The `[python]` table. Both keys are optional, so a repo can configure just
31/// coverage, just exemptions, or both. `Default` (no coverage table, no
32/// exemptions) backs the zero-config path: an absent `[python]` table means the
33/// rule runs against the default floor with nothing exempt (#80).
34#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct PythonConfig {
37    pub coverage: Option<PythonCoverage>,
38    #[serde(default)]
39    pub exempt: Vec<Exemption>,
40    /// The Python-only build escape hatch (#289): a shell command the suite-executing
41    /// jobs run after toolchain + dependency setup and before the suite, for a package
42    /// whose unit suite imports a compiled module (a maturin/PyO3 extension) the build
43    /// backend produces. `detect` reads it from the package's own config and the workflow
44    /// jobs run it; the binary never runs it, but the schema must accept the key so a
45    /// consumer's config still loads under `deny_unknown_fields`. Absent (`None`) means no
46    /// build step. Python-only because PEP 517 backends expose only sandboxed
47    /// `build_wheel`/`build_sdist` hooks — npm's `prepare`/`postinstall` and Cargo's
48    /// `build.rs` are already manifest-native, a documented asymmetry under the parity rule.
49    pub build_command: Option<String>,
50    /// Why the manifest can't express this build — required alongside `build_command` and
51    /// validated non-empty in [`Config::validate`], mirroring the exemption-reason bar. An
52    /// unreasoned escape hatch can never be a silent pass.
53    #[serde(default)]
54    pub reason: String,
55}
56
57/// The `[typescript]` table.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct TypeScriptConfig {
61    pub coverage: Option<TypeScriptCoverage>,
62    #[serde(default)]
63    pub exempt: Vec<Exemption>,
64}
65
66/// The `[rust]` table.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct RustConfig {
70    pub coverage: Option<RustCoverage>,
71    /// Cargo features the suite-running Rust rules enable (#266): `unit coverage`
72    /// passes them to `cargo llvm-cov` (`--features`) and `unit mutation` forwards
73    /// them to cargo-mutants' build/test runs, so `#[cfg(feature = ...)]` code is
74    /// compiled, measured, and mutated. Cargo features are Rust's build-system
75    /// concept with no Python/TypeScript analog, so the key is deliberately
76    /// Rust-only (a documented asymmetry under the parity rule).
77    #[serde(default)]
78    pub features: Vec<String>,
79    #[serde(default)]
80    pub exempt: Vec<Exemption>,
81}
82
83/// `[python].coverage`. A **partial override** — `#[serde(default)]` fills any missing
84/// field from [`PythonCoverage::default`], so a table that sets only one threshold keeps
85/// our defaults for the rest (#216); `deny_unknown_fields` still rejects a typo'd key.
86#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
87#[serde(default, deny_unknown_fields)]
88pub struct PythonCoverage {
89    pub branch: bool,
90    pub fail_under: u8,
91}
92
93/// The default Python floor used when coverage isn't configured (#80): branch
94/// coverage on, `fail_under = 100` (#194). Strict by default — "100% of what you
95/// didn't explicitly exempt" — because the rule already honors `# pragma: no cover`,
96/// reason-required `[[python.exempt]]` entries, and the empty/comment-only
97/// auto-exemption, so trivia is excluded deliberately rather than by a slack floor.
98/// A config `[python].coverage` table lowers it when a project wants headroom.
99impl Default for PythonCoverage {
100    fn default() -> Self {
101        Self {
102            branch: true,
103            fail_under: 100,
104        }
105    }
106}
107
108/// `[typescript].coverage`. A **partial override** — `#[serde(default)]` fills any
109/// missing field from [`TypeScriptCoverage::default`], so a table that sets only one of
110/// the four metrics keeps our defaults for the rest (#216); `deny_unknown_fields` still
111/// rejects a typo'd key.
112#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
113#[serde(default, deny_unknown_fields)]
114pub struct TypeScriptCoverage {
115    pub lines: u8,
116    pub branches: u8,
117    pub functions: u8,
118    pub statements: u8,
119}
120
121/// The default TypeScript floors used when coverage isn't configured (#80): all
122/// four metrics at 100 (#194), matching the strict-by-default Python floor. As with
123/// Python, "100" means "100% of what you didn't explicitly exempt" — the rule honors
124/// reason-required `[[typescript.exempt]]` entries and skips declaration files
125/// (`*.d.ts`). A config `[typescript].coverage` table lowers any of the four.
126impl Default for TypeScriptCoverage {
127    fn default() -> Self {
128        Self {
129            lines: 100,
130            branches: 100,
131            functions: 100,
132            statements: 100,
133        }
134    }
135}
136
137/// `[rust].coverage`. A **partial override** — `#[serde(default)]` fills any missing
138/// field from [`RustCoverage::default`] (`lines = 100`, everything else `None`), so a
139/// table that sets only `regions` keeps `lines = 100` (#216); `deny_unknown_fields`
140/// still rejects a typo'd key. Three opt-in floors sit alongside `lines` (#267):
141/// `regions` (a Rust-only sub-line metric), `functions` (the export's functions
142/// total, stable toolchain), and `branch` (adds `--branch` to the run, which
143/// instruments only on a nightly toolchain).
144#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
145#[serde(default, deny_unknown_fields)]
146pub struct RustCoverage {
147    pub regions: Option<u8>,
148    pub lines: u8,
149    pub functions: Option<u8>,
150    pub branch: Option<u8>,
151}
152
153/// The default Rust floor used when coverage isn't configured (#206): `lines = 100`,
154/// matching Python/TypeScript's line-level 100. The other metrics are opt-in (`None`
155/// unless a config sets them): `regions` is a Rust-only sub-line metric harsher than
156/// lines, `functions` keeps the default line-shaped like Python's, and `branch`
157/// requires a nightly toolchain (#267) — so the zero-config floor is lines only. As
158/// with Python/TypeScript, "100" means "100% of what you didn't explicitly exempt" —
159/// the rule honors reason-required `[[rust.exempt]]` entries. A config
160/// `[rust].coverage` table lowers the line floor or adds the opt-in floors.
161impl Default for RustCoverage {
162    fn default() -> Self {
163        Self {
164            regions: None,
165            lines: 100,
166            functions: None,
167            branch: None,
168        }
169    }
170}
171
172/// A rule a file can be exempted from (issue #32).
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum Rule {
176    /// The unit-test colocated-test check ([`crate::colocated_test`]).
177    ColocatedTest,
178    /// The unit-test coverage floor ([`crate::coverage`]).
179    Coverage,
180    /// The commit-scoped `co-change` check ([`crate::co_change`], #33) — a
181    /// changed source whose colocated test needn't co-change.
182    CoChange,
183    /// `integration lint` — a test/fixture takes pytest's `monkeypatch` fixture ([`crate::lint`], #49).
184    NoMonkeypatch,
185    /// `integration lint` — a `patch(...)` called inline in a Python test body ([`crate::lint`], #50).
186    NoInlinePatch,
187    /// `integration lint` — direct mutation of `os.environ` in a Python test ([`crate::lint`], #51).
188    NoEnvironMutation,
189    /// The `no-constant-patch` lint ([`crate::lint`], issue #52).
190    NoConstantPatch,
191    /// `integration lint` — patching a first-party target in a Python integration test ([`crate::lint`], #42).
192    NoFirstPartyPatch,
193    /// `unit lint` — a call out of a Rust unit's own module ([`crate::isolation`], #44).
194    NoOutOfModuleCall,
195    /// `unit lint` — a foreign `use` in a Rust unit test ([`crate::isolation`], #44).
196    NoOutOfModuleImport,
197    /// `integration lint` — doubling a first-party item in a Rust integration test (#44).
198    NoFirstPartyDouble,
199    /// `unit lint` — an un-mocked first-party/external import in a TS unit test ([`crate::ts`], #76).
200    UnmockedCollaborator,
201    /// `unit lint` — a `vi.mock` without a typed anchor in a TS unit test (#77).
202    UntypedMock,
203    /// `integration lint` — a `vi.mock` of a first-party module in a TS integration test (#75).
204    NoFirstPartyMock,
205    /// `unit mutation` — a surviving mutant the unit suite didn't catch ([`crate::mutation`], #201).
206    Mutation,
207}
208
209impl Rule {
210    /// Whether a `lines` list may scope this rule (#226). The measured-line rules —
211    /// `coverage` and `mutation` — judge individual lines, so an exemption can name
212    /// the exact lines it lifts. Every other rule is whole-file (presence, a lint, a
213    /// folder convention), so a `lines` key alongside it is a config error.
214    pub fn is_line_scopable(self) -> bool {
215        matches!(self, Rule::Coverage | Rule::Mutation)
216    }
217
218    /// The rule's kebab-case id — the string used in a `Violation` and in a config
219    /// `rules` value. Mirrors the `serde(rename_all = "kebab-case")` encoding.
220    pub fn id(self) -> &'static str {
221        match self {
222            Rule::ColocatedTest => "colocated-test",
223            Rule::Coverage => "coverage",
224            Rule::CoChange => "co-change",
225            Rule::NoMonkeypatch => "no-monkeypatch",
226            Rule::NoInlinePatch => "no-inline-patch",
227            Rule::NoEnvironMutation => "no-environ-mutation",
228            Rule::NoConstantPatch => "no-constant-patch",
229            Rule::NoFirstPartyPatch => "no-first-party-patch",
230            Rule::NoOutOfModuleCall => "no-out-of-module-call",
231            Rule::NoOutOfModuleImport => "no-out-of-module-import",
232            Rule::NoFirstPartyDouble => "no-first-party-double",
233            Rule::UnmockedCollaborator => "unmocked-collaborator",
234            Rule::UntypedMock => "untyped-mock",
235            Rule::NoFirstPartyMock => "no-first-party-mock",
236            Rule::Mutation => "mutation",
237        }
238    }
239
240    /// The [`Rule`] for a lint id, or `None` for an unknown / non-waivable id.
241    pub fn from_id(id: &str) -> Option<Rule> {
242        [
243            Rule::ColocatedTest,
244            Rule::Coverage,
245            Rule::CoChange,
246            Rule::NoMonkeypatch,
247            Rule::NoInlinePatch,
248            Rule::NoEnvironMutation,
249            Rule::NoConstantPatch,
250            Rule::NoFirstPartyPatch,
251            Rule::NoOutOfModuleCall,
252            Rule::NoOutOfModuleImport,
253            Rule::NoFirstPartyDouble,
254            Rule::UnmockedCollaborator,
255            Rule::UntypedMock,
256            Rule::NoFirstPartyMock,
257            Rule::Mutation,
258        ]
259        .into_iter()
260        .find(|rule| rule.id() == id)
261    }
262}
263
264/// One element of an exemption's `lines` list (#226): a single 1-based line, or an
265/// inclusive `"start-end"` range.
266///
267/// Parses from a TOML integer (`9`) or a string range (`"12-13"`). Semantic checks
268/// (a line ≥ 1, a range's start ≤ end) live in [`Config::validate`] so the error can
269/// name the offending exemption; the deserializer only rejects what isn't a line spec
270/// at all (a non-integer, a malformed range).
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum LineSpec {
273    /// A single line.
274    Single(u32),
275    /// An inclusive line range, `start..=end`.
276    Range(u32, u32),
277}
278
279impl LineSpec {
280    /// Parse a string spec: `"12-13"` → a range, `"9"` → a single line. The two parts
281    /// of a range are trimmed, so `"12 - 13"` is accepted. A part that isn't a
282    /// non-negative integer (or a range with more than one `-`) is an error.
283    fn parse_str(s: &str) -> Result<LineSpec, String> {
284        let parse = |part: &str| {
285            part.trim()
286                .parse::<u32>()
287                .map_err(|_| format!("`{s}` is not a line number or \"start-end\" range"))
288        };
289        match s.split_once('-') {
290            Some((start, end)) => Ok(LineSpec::Range(parse(start)?, parse(end)?)),
291            None => Ok(LineSpec::Single(parse(s)?)),
292        }
293    }
294
295    /// The lines this spec expands to, pushed into `set`.
296    fn extend_into(self, set: &mut BTreeSet<u32>) {
297        match self {
298            LineSpec::Single(n) => {
299                set.insert(n);
300            }
301            LineSpec::Range(start, end) => {
302                for n in start..=end {
303                    set.insert(n);
304                }
305            }
306        }
307    }
308}
309
310impl<'de> Deserialize<'de> for LineSpec {
311    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
312    where
313        D: serde::Deserializer<'de>,
314    {
315        struct SpecVisitor;
316        impl serde::de::Visitor<'_> for SpecVisitor {
317            type Value = LineSpec;
318
319            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
320                f.write_str("a line number or a \"start-end\" range string")
321            }
322
323            fn visit_u64<E: serde::de::Error>(self, v: u64) -> std::result::Result<LineSpec, E> {
324                u32::try_from(v)
325                    .map(LineSpec::Single)
326                    .map_err(|_| E::custom(format!("line number {v} is out of range")))
327            }
328
329            // TOML integers arrive as i64; a negative line number is nonsense.
330            fn visit_i64<E: serde::de::Error>(self, v: i64) -> std::result::Result<LineSpec, E> {
331                u64::try_from(v)
332                    .map_err(|_| E::custom(format!("line number {v} must be positive")))
333                    .and_then(|v| self.visit_u64(v))
334            }
335
336            fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<LineSpec, E> {
337                LineSpec::parse_str(v).map_err(E::custom)
338            }
339        }
340        deserializer.deserialize_any(SpecVisitor)
341    }
342}
343
344/// One auditable per-file exemption — a `[[<language>.exempt]]` entry.
345///
346/// The opposite of a silent ignore-glob: an exemption is declared in the one
347/// config file, names the rules it lifts, and **must say why**. Empty
348/// (comment-only) files need no entry — they carry no logic and are not
349/// subjects — so this is for deliberate omissions the tool can't infer (a
350/// launcher shim, generated code, a re-export barrel).
351#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
352#[serde(deny_unknown_fields)]
353pub struct Exemption {
354    /// Path to the exempt file, relative to the scanned root.
355    pub path: String,
356    /// Which rules the exemption lifts (`colocated-test`, `coverage`).
357    pub rules: Vec<Rule>,
358    /// Lines this exemption is scoped to (#226). Empty (the default, the `lines` key
359    /// omitted) is a **whole-file** exemption — today's behavior. A non-empty list
360    /// narrows a `coverage` / `mutation` exemption to exactly those lines, guarded so
361    /// every listed line must actually be failing.
362    #[serde(default)]
363    pub lines: Vec<LineSpec>,
364    /// Why the omission is deliberate — required, and never empty.
365    pub reason: String,
366}
367
368impl Exemption {
369    /// The 1-based line numbers this exemption is scoped to, with ranges expanded.
370    /// Empty when the entry carries no `lines` (a whole-file exemption).
371    pub fn line_set(&self) -> BTreeSet<u32> {
372        let mut set = BTreeSet::new();
373        for spec in &self.lines {
374            spec.extend_into(&mut set);
375        }
376        set
377    }
378}
379
380/// What an exemption lifts for one file (#226): the whole file, or only specific lines.
381///
382/// The resolved counterpart of [`Exemption::lines`] — [`resolve_exempt_scoped`] turns
383/// each entry into one of these, so the `coverage` / `mutation` rules can apply a
384/// file-level omit or a line-level guard uniformly.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub enum LineScope {
387    /// The whole file is exempt (no `lines` key) — today's behavior.
388    WholeFile,
389    /// Only these 1-based lines are exempt.
390    Lines(BTreeSet<u32>),
391}
392
393impl LineScope {
394    /// Merge two scopes for the same path: a whole-file exemption subsumes any
395    /// line-scoped one (the file is wholly lifted either way), otherwise the line sets
396    /// union. Lets two entries naming the same file for the same rule combine cleanly.
397    fn merged_with(self, other: LineScope) -> LineScope {
398        match (self, other) {
399            (LineScope::WholeFile, _) | (_, LineScope::WholeFile) => LineScope::WholeFile,
400            (LineScope::Lines(mut a), LineScope::Lines(b)) => {
401                a.extend(b);
402                LineScope::Lines(a)
403            }
404        }
405    }
406}
407
408/// Read one config file at `path` into a [`Config`], validating it on the way.
409///
410/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
411/// rejects keys that aren't part of the schema, missing required keys and
412/// wrong-typed values are type errors, malformed TOML fails to parse, and every
413/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
414/// surfaces as an `Err` rather than a silently-accepted default.
415pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
416    let path = path.as_ref();
417    let contents = std::fs::read_to_string(path)
418        .with_context(|| format!("reading config file `{}`", path.display()))?;
419    let config: Config = toml::from_str(&contents)
420        .with_context(|| format!("parsing config file `{}`", path.display()))?;
421    config
422        .validate()
423        .with_context(|| format!("validating config file `{}`", path.display()))?;
424    Ok(config)
425}
426
427impl Config {
428    /// The `exempt` list for `language` (empty when the table is absent).
429    pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
430        match language {
431            crate::colocated_test::Language::Python => {
432                self.python.as_ref().map_or(&[], |c| &c.exempt)
433            }
434            crate::colocated_test::Language::TypeScript => {
435                self.typescript.as_ref().map_or(&[], |c| &c.exempt)
436            }
437            crate::colocated_test::Language::Rust => self.rust_exemptions(),
438        }
439    }
440
441    /// The `[[rust.exempt]]` list (empty when the table is absent). The named
442    /// accessor the Rust isolation rules (#44) waive through; equivalent to
443    /// [`Self::exemptions`]`(Language::Rust)`.
444    pub fn rust_exemptions(&self) -> &[Exemption] {
445        self.rust.as_ref().map_or(&[], |c| &c.exempt)
446    }
447
448    /// Reject any `exempt` entry that names no rule or carries an empty reason —
449    /// a reasonless or scopeless exemption can never be a silent pass.
450    fn validate(&self) -> Result<()> {
451        // The `[python].build_command` escape hatch (#289) is held to the exemption bar: a
452        // reasonless build command can never be a silent pass, so a present command with an
453        // empty reason fails to load, in the same style as the exemption reason check below.
454        if let Some(python) = &self.python {
455            if python.build_command.is_some() && python.reason.trim().is_empty() {
456                bail!(
457                    "[python].build_command has an empty reason — say why the manifest can't \
458                     express this build (maturin/PyO3's PEP 517 backend has no pre-build shell \
459                     hook; npm's prepare/postinstall and Cargo's build.rs already do)"
460                );
461            }
462        }
463        let tables = [
464            ("python", self.python.as_ref().map(|c| &c.exempt)),
465            ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
466            ("rust", self.rust.as_ref().map(|c| &c.exempt)),
467        ];
468        for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
469            for entry in exempt {
470                if entry.rules.is_empty() {
471                    bail!(
472                        "[{table}].exempt entry for `{}` names no rules — set \
473                         `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
474                        entry.path
475                    );
476                }
477                if entry.reason.trim().is_empty() {
478                    bail!(
479                        "[{table}].exempt entry for `{}` has an empty reason — \
480                         every exemption must say why the file is exempt",
481                        entry.path
482                    );
483                }
484                // Line-scoping and whole-file exemptions don't mix (#226). The
485                // measured-line rules (`coverage` / `mutation`) **require** `lines` —
486                // an exemption may not lift a whole file from coverage or mutation, only
487                // the exact lines it can prove are failing. The whole-file rules
488                // (presence, lints) **reject** `lines`. So an entry is either all
489                // line-scopable rules with `lines`, or all whole-file rules without.
490                let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
491                let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
492                if entry.lines.is_empty() {
493                    if has_scopable {
494                        let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
495                        bail!(
496                            "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
497                             a `coverage` / `mutation` exemption must name the exact lines it \
498                             covers (whole-file exemptions are for presence / lint rules only)",
499                            entry.path,
500                            rule.id()
501                        );
502                    }
503                } else {
504                    if has_whole_file {
505                        let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
506                        bail!(
507                            "[{table}].exempt entry for `{}` has `lines` alongside rule \
508                             `{}` — line-scoped exemptions apply only to `coverage` and \
509                             `mutation`; move the whole-file rules to a separate entry",
510                            entry.path,
511                            rule.id()
512                        );
513                    }
514                    for spec in &entry.lines {
515                        let invalid = match spec {
516                            LineSpec::Single(n) => *n == 0,
517                            LineSpec::Range(start, end) => *start == 0 || start > end,
518                        };
519                        if invalid {
520                            bail!(
521                                "[{table}].exempt entry for `{}` has an invalid line spec — \
522                                 line numbers are 1-based and a range's start must not exceed \
523                                 its end",
524                                entry.path
525                            );
526                        }
527                    }
528                }
529            }
530        }
531        Ok(())
532    }
533}
534
535/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
536/// each still points to a file under `root`.
537///
538/// A stale entry — a path that no longer exists — is an error, so the exempt
539/// list can't silently rot (the auditable counterpart to an ignore-glob, which
540/// would just stop matching). Returns the matching paths as `/`-joined,
541/// `root`-relative strings, sorted and de-duplicated.
542pub fn resolve_exempt(
543    root: &Path,
544    exemptions: &[Exemption],
545    rule: Rule,
546) -> Result<BTreeSet<String>> {
547    Ok(resolve_exempt_scoped(root, exemptions, rule)?
548        .into_keys()
549        .collect())
550}
551
552/// Resolve the per-file exempt **scope** for `rule` (#226) — whole-file or line-scoped.
553///
554/// Like [`resolve_exempt`], a stale path is a hard error so the list can't rot. An
555/// entry with no `lines` resolves to [`LineScope::WholeFile`] (today's behavior); one
556/// with `lines` to [`LineScope::Lines`]. Two entries naming the same file for the same
557/// rule merge ([`LineScope::merged_with`]). The `coverage` / `mutation` rules read this
558/// to apply a file-level omit or a line-level guard; the file-level rules go through the
559/// [`resolve_exempt`] shim above, which keeps only the keys.
560pub fn resolve_exempt_scoped(
561    root: &Path,
562    exemptions: &[Exemption],
563    rule: Rule,
564) -> Result<std::collections::BTreeMap<String, LineScope>> {
565    let mut scopes: std::collections::BTreeMap<String, LineScope> =
566        std::collections::BTreeMap::new();
567    for entry in exemptions {
568        if !entry.rules.contains(&rule) {
569            continue;
570        }
571        if !root.join(&entry.path).is_file() {
572            bail!(
573                "exempt entry `{}` matches no file under `{}` — remove the stale \
574                 entry or fix the path",
575                entry.path,
576                root.display()
577            );
578        }
579        let key = entry.path.replace('\\', "/");
580        let scope = if entry.lines.is_empty() {
581            LineScope::WholeFile
582        } else {
583            LineScope::Lines(entry.line_set())
584        };
585        let merged = match scopes.remove(&key) {
586            Some(existing) => existing.merged_with(scope),
587            None => scope,
588        };
589        scopes.insert(key, merged);
590    }
591    Ok(scopes)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use std::sync::atomic::{AtomicU64, Ordering};
598
599    fn parse(toml_src: &str) -> Result<Config> {
600        let config: Config = toml::from_str(toml_src)?;
601        config.validate()?;
602        Ok(config)
603    }
604
605    #[test]
606    fn an_exemption_with_no_rules_is_rejected() {
607        let err = parse(
608            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
609             [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
610        )
611        .unwrap_err();
612        assert!(err.to_string().contains("names no rules"), "got: {err}");
613    }
614
615    #[test]
616    fn an_exemption_with_an_empty_reason_is_rejected() {
617        let err = parse(
618            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
619             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \"  \"\n",
620        )
621        .unwrap_err();
622        assert!(err.to_string().contains("empty reason"), "got: {err}");
623    }
624
625    #[test]
626    fn an_unknown_rule_is_rejected() {
627        assert!(parse(
628            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
629             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
630        )
631        .is_err());
632    }
633
634    #[test]
635    fn default_python_coverage_is_the_strict_floor() {
636        // The zero-config floor (#80, #194) is strict by default: branch on, 100.
637        // Locked here so it can't silently drift from the Defaults reference.
638        assert_eq!(
639            PythonCoverage::default(),
640            PythonCoverage {
641                branch: true,
642                fail_under: 100,
643            }
644        );
645    }
646
647    #[test]
648    fn default_typescript_coverage_is_the_strict_floor() {
649        // The zero-config floor (#80, #194) is strict by default: all four metrics
650        // at 100. Locked here so it can't silently drift from the Defaults reference.
651        assert_eq!(
652            TypeScriptCoverage::default(),
653            TypeScriptCoverage {
654                lines: 100,
655                branches: 100,
656                functions: 100,
657                statements: 100,
658            }
659        );
660    }
661
662    #[test]
663    fn default_rust_coverage_is_the_strict_line_floor() {
664        // The zero-config Rust floor (#206) is `lines = 100` — matching Python/TS — with
665        // every other metric opt-in (None): `regions` (a Rust-only sub-line metric),
666        // `functions`, and `branch` (nightly-only instrumentation, #267). Locked here
667        // so it can't silently drift from the Defaults reference.
668        assert_eq!(
669            RustCoverage::default(),
670            RustCoverage {
671                regions: None,
672                lines: 100,
673                functions: None,
674                branch: None,
675            }
676        );
677    }
678
679    #[test]
680    fn rust_coverage_table_parses_with_regions_omitted() {
681        // `regions` is opt-in (#206): a `[rust].coverage` table may set `lines` alone,
682        // leaving the region check off.
683        let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
684        let coverage = config.rust.unwrap().coverage.unwrap();
685        assert_eq!(coverage.regions, None);
686        assert_eq!(coverage.lines, 90);
687    }
688
689    #[test]
690    fn a_python_build_command_with_a_reason_parses() {
691        // #289: the escape hatch and its required reason both survive into the [python] table.
692        let config = parse(
693            "[python]\nbuild_command = \"uv run maturin develop\"\n\
694             reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
695        )
696        .unwrap();
697        let python = config.python.unwrap();
698        assert_eq!(
699            python.build_command.as_deref(),
700            Some("uv run maturin develop")
701        );
702        assert_eq!(
703            python.reason,
704            "maturin's PEP 517 backend has no pre-build shell hook"
705        );
706    }
707
708    #[test]
709    fn a_python_build_command_with_an_empty_reason_is_rejected() {
710        let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\nreason = \"  \"\n")
711            .unwrap_err();
712        assert!(err.to_string().contains("empty reason"), "got: {err}");
713    }
714
715    #[test]
716    fn a_python_build_command_with_no_reason_is_rejected() {
717        // The `reason` key is absent entirely (serde-defaulted to empty) — still rejected.
718        let err = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap_err();
719        assert!(err.to_string().contains("empty reason"), "got: {err}");
720    }
721
722    #[test]
723    fn a_python_table_without_a_build_command_needs_no_reason() {
724        // The reason is required *only* alongside a build_command: a coverage-only [python]
725        // table (no build_command) loads with an empty defaulted reason, byte-identical to today.
726        let config = parse("[python]\ncoverage = { branch = true, fail_under = 90 }\n").unwrap();
727        let python = config.python.unwrap();
728        assert!(python.build_command.is_none());
729        assert!(python.reason.is_empty());
730    }
731
732    #[test]
733    fn a_valid_exemption_parses() {
734        // A whole-file presence exemption (a launcher shim with no colocated test).
735        let config = parse(
736            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
737             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
738             reason = \"thin launcher\"\n",
739        )
740        .unwrap();
741        let exempt = &config.python.unwrap().exempt;
742        assert_eq!(exempt.len(), 1);
743        assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
744        assert!(exempt[0].lines.is_empty());
745    }
746
747    #[test]
748    fn exemptions_reads_the_rust_table() {
749        let config = parse(
750            "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
751             reason = \"generated\"\n",
752        )
753        .unwrap();
754        let rust = config.exemptions(crate::colocated_test::Language::Rust);
755        assert_eq!(rust.len(), 1);
756        assert_eq!(rust[0].path, "build.rs");
757    }
758
759    /// A throwaway directory tree, removed on drop.
760    struct TempTree(std::path::PathBuf);
761
762    impl TempTree {
763        fn new(files: &[&str]) -> Self {
764            static COUNTER: AtomicU64 = AtomicU64::new(0);
765            let root = std::env::temp_dir().join(format!(
766                "tc-exempt-{}-{}",
767                std::process::id(),
768                COUNTER.fetch_add(1, Ordering::Relaxed),
769            ));
770            for rel in files {
771                let path = root.join(rel);
772                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
773                std::fs::write(path, "x = 1\n").unwrap();
774            }
775            TempTree(root)
776        }
777    }
778
779    impl Drop for TempTree {
780        fn drop(&mut self) {
781            let _ = std::fs::remove_dir_all(&self.0);
782        }
783    }
784
785    fn exemption(path: &str, rules: &[Rule]) -> Exemption {
786        Exemption {
787            path: path.to_string(),
788            rules: rules.to_vec(),
789            lines: vec![],
790            reason: "deliberate".to_string(),
791        }
792    }
793
794    #[test]
795    fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
796        let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
797        let exemptions = [
798            exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
799            exemption("pkg/gen.py", &[Rule::Coverage]),
800            exemption("loc_only.py", &[Rule::ColocatedTest]),
801        ];
802        let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
803        assert_eq!(
804            coverage.into_iter().collect::<Vec<_>>(),
805            vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
806        );
807        let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
808        assert_eq!(
809            colocated_test.into_iter().collect::<Vec<_>>(),
810            vec!["cli.py".to_string(), "loc_only.py".to_string()],
811        );
812    }
813
814    #[test]
815    fn a_stale_exempt_path_is_an_error() {
816        let tree = TempTree::new(&["cli.py"]);
817        let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
818        let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
819        assert!(err.to_string().contains("matches no file"), "got: {err}");
820    }
821
822    // --- line-scoped exemptions (#226) ---
823
824    #[test]
825    fn line_specs_parse_from_ints_and_range_strings() {
826        // `lines = [9, 10, "12-13"]` — a TOML integer is a single line, a "start-end"
827        // string is an inclusive range.
828        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        // `line_set` expands the range and de-duplicates into a sorted set.
843        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        // `lines` is required for the measured-line rules (#226): an exemption can't
852        // lift a whole file from coverage, only the lines it can prove are uncovered.
853        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        // `colocated-test` is whole-file presence, so a `lines` key alongside it can't
872        // mean anything — rejected on load.
873        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        // Not a line number or range at all — rejected by the deserializer.
908        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        // A `coverage` entry resolves to its lines; a `colocated-test` entry (whole-file
918        // presence) to the whole file.
919        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        // Two line-scoped entries for one file union their lines; two whole-file entries
941        // stay whole-file.
942        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}