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