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