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