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/// The migration record: every key a release renamed or removed, alongside its replacement.
453/// An unknown-key rejection points here — a stale key left by a rename reads identically to a
454/// typo at parse time, so the record is the only thing that distinguishes upgrade from mistake.
455const MIGRATIONS_URL: &str =
456 "https://github.com/thekevinscott/testing-conventions/blob/main/packages/rust/MIGRATIONS.md";
457
458/// Append the migration pointer to a `deny_unknown_fields` rejection, passing every other TOML
459/// parse error through untouched. The `serde` message already names the key and lists the
460/// accepted ones; the pointer supplies the upgrade path serde can't know — the key may be one a
461/// release renamed or removed, and [`MIGRATIONS_URL`] is where that mapping lives.
462fn annotate_toml_error(err: toml::de::Error) -> anyhow::Error {
463 if err.message().contains("unknown field") {
464 anyhow::anyhow!(err).context(format!(
465 "an unrecognized key can be a typo or a key a release renamed or removed — see {MIGRATIONS_URL}"
466 ))
467 } else {
468 anyhow::anyhow!(err)
469 }
470}
471
472/// Read one config file at `path` into a [`Config`], validating it on the way.
473///
474/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
475/// rejects keys that aren't part of the schema, missing required keys and
476/// wrong-typed values are type errors, malformed TOML fails to parse, and every
477/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
478/// surfaces as an `Err` rather than a silently-accepted default. An unknown-key
479/// rejection additionally points at `MIGRATIONS.md` (see [`annotate_toml_error`]).
480pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
481 let path = path.as_ref();
482 let contents = std::fs::read_to_string(path)
483 .with_context(|| format!("reading config file `{}`", path.display()))?;
484 let config: Config = toml::from_str(&contents)
485 .map_err(annotate_toml_error)
486 .with_context(|| format!("parsing config file `{}`", path.display()))?;
487 config
488 .validate()
489 .with_context(|| format!("validating config file `{}`", path.display()))?;
490 Ok(config)
491}
492
493impl Config {
494 /// The `exempt` list for `language` (empty when the table is absent).
495 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
496 match language {
497 crate::colocated_test::Language::Python => {
498 self.python.as_ref().map_or(&[], |c| &c.exempt)
499 }
500 crate::colocated_test::Language::TypeScript => {
501 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
502 }
503 crate::colocated_test::Language::Rust => self.rust_exemptions(),
504 }
505 }
506
507 /// The `[[rust.exempt]]` list (empty when the table is absent). The named
508 /// accessor the Rust isolation rules waive through; equivalent to
509 /// [`Self::exemptions`]`(Language::Rust)`.
510 pub fn rust_exemptions(&self) -> &[Exemption] {
511 self.rust.as_ref().map_or(&[], |c| &c.exempt)
512 }
513
514 /// Reject any `exempt` entry that names no rule or carries an empty reason —
515 /// a reasonless or scopeless exemption can never be a silent pass.
516 fn validate(&self) -> Result<()> {
517 // `build_command` carries no reason requirement: it *supplies* a necessary fact —
518 // how to build a package the ecosystem doesn't build for you — rather than *waiving* a
519 // check the way an exemption does, so there is nothing to justify. (An `exempt` entry
520 // still requires a reason below; that one really does waive a gate.)
521 let tables = [
522 ("python", self.python.as_ref().map(|c| &c.exempt)),
523 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
524 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
525 ];
526 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
527 for entry in exempt {
528 if entry.rules.is_empty() {
529 bail!(
530 "[{table}].exempt entry for `{}` names no rules — set \
531 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
532 entry.path
533 );
534 }
535 if entry.reason.trim().is_empty() {
536 bail!(
537 "[{table}].exempt entry for `{}` has an empty reason — \
538 every exemption must say why the file is exempt",
539 entry.path
540 );
541 }
542 // Line-scoping and whole-file exemptions don't mix. The
543 // measured-line rules (`coverage` / `mutation`) **require** `lines` —
544 // an exemption may not lift a whole file from coverage or mutation, only
545 // the exact lines it can prove are failing. The whole-file rules
546 // (presence, lints) **reject** `lines`. So an entry is either all
547 // line-scopable rules with `lines`, or all whole-file rules without.
548 let has_scopable = entry.rules.iter().any(|rule| rule.is_line_scopable());
549 let has_whole_file = entry.rules.iter().any(|rule| !rule.is_line_scopable());
550 if entry.lines.is_empty() {
551 if has_scopable {
552 let rule = entry.rules.iter().find(|r| r.is_line_scopable()).unwrap();
553 bail!(
554 "[{table}].exempt entry for `{}` names `{}` but lists no `lines` — \
555 a `coverage` / `mutation` exemption must name the exact lines it \
556 covers (whole-file exemptions are for presence / lint rules only)",
557 entry.path,
558 rule.id()
559 );
560 }
561 } else {
562 if has_whole_file {
563 let rule = entry.rules.iter().find(|r| !r.is_line_scopable()).unwrap();
564 bail!(
565 "[{table}].exempt entry for `{}` has `lines` alongside rule \
566 `{}` — line-scoped exemptions apply only to `coverage` and \
567 `mutation`; move the whole-file rules to a separate entry",
568 entry.path,
569 rule.id()
570 );
571 }
572 for spec in &entry.lines {
573 let invalid = match spec {
574 LineSpec::Single(n) => *n == 0,
575 LineSpec::Range(start, end) => *start == 0 || start > end,
576 };
577 if invalid {
578 bail!(
579 "[{table}].exempt entry for `{}` has an invalid line spec — \
580 line numbers are 1-based and a range's start must not exceed \
581 its end",
582 entry.path
583 );
584 }
585 }
586 }
587 }
588 }
589 Ok(())
590 }
591}
592
593/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
594/// each still points to a file under `root`.
595///
596/// A stale entry — a path that no longer exists — is an error, so the exempt
597/// list can't silently rot (the auditable counterpart to an ignore-glob, which
598/// would just stop matching). Returns the matching paths as `/`-joined,
599/// `root`-relative strings, sorted and de-duplicated.
600pub fn resolve_exempt(
601 root: &Path,
602 exemptions: &[Exemption],
603 rule: Rule,
604) -> Result<BTreeSet<String>> {
605 Ok(resolve_exempt_scoped(root, exemptions, rule)?
606 .into_keys()
607 .collect())
608}
609
610/// Resolve the per-file exempt **scope** for `rule` — whole-file or line-scoped.
611///
612/// Like [`resolve_exempt`], a stale path is a hard error so the list can't rot. An
613/// entry with no `lines` resolves to [`LineScope::WholeFile`] (today's behavior); one
614/// with `lines` to [`LineScope::Lines`]. Two entries naming the same file for the same
615/// rule merge ([`LineScope::merged_with`]). The `coverage` / `mutation` rules read this
616/// to apply a file-level omit or a line-level guard; the file-level rules go through the
617/// [`resolve_exempt`] shim above, which keeps only the keys.
618pub fn resolve_exempt_scoped(
619 root: &Path,
620 exemptions: &[Exemption],
621 rule: Rule,
622) -> Result<std::collections::BTreeMap<String, LineScope>> {
623 let mut scopes: std::collections::BTreeMap<String, LineScope> =
624 std::collections::BTreeMap::new();
625 for entry in exemptions {
626 if !entry.rules.contains(&rule) {
627 continue;
628 }
629 if !root.join(&entry.path).is_file() {
630 bail!(
631 "exempt entry `{}` matches no file under `{}` — remove the stale \
632 entry or fix the path",
633 entry.path,
634 root.display()
635 );
636 }
637 let key = entry.path.replace('\\', "/");
638 let scope = if entry.lines.is_empty() {
639 LineScope::WholeFile
640 } else {
641 LineScope::Lines(entry.line_set())
642 };
643 let merged = match scopes.remove(&key) {
644 Some(existing) => existing.merged_with(scope),
645 None => scope,
646 };
647 scopes.insert(key, merged);
648 }
649 Ok(scopes)
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use std::sync::atomic::{AtomicU64, Ordering};
656
657 fn parse(toml_src: &str) -> Result<Config> {
658 let config: Config = toml::from_str(toml_src)?;
659 config.validate()?;
660 Ok(config)
661 }
662
663 /// The `toml::de::Error` from parsing `toml_src` into a [`Config`] — the exact error type
664 /// [`annotate_toml_error`] receives from `load_config`.
665 fn toml_error(toml_src: &str) -> toml::de::Error {
666 toml::from_str::<Config>(toml_src).expect_err("the source should fail to parse")
667 }
668
669 #[test]
670 fn annotate_points_an_unknown_key_error_at_migrations() {
671 let annotated = annotate_toml_error(toml_error("[python]\nbogus = true\n"));
672 let chain = format!("{annotated:#}");
673 assert!(chain.contains("MIGRATIONS.md"), "got: {chain}");
674 // The serde message — the rejected key and the accepted set — survives underneath.
675 assert!(chain.contains("unknown field `bogus`"), "got: {chain}");
676 }
677
678 #[test]
679 fn annotate_leaves_a_non_unknown_key_error_untouched() {
680 // A type error is not an unknown-key rejection, so no migration pointer is appended —
681 // the record has nothing to say about a well-named key given the wrong type.
682 let annotated = annotate_toml_error(toml_error(
683 "[python]\ncoverage = { fail_under = \"lots\" }\n",
684 ));
685 assert!(
686 !format!("{annotated:#}").contains("MIGRATIONS.md"),
687 "got: {annotated:#}"
688 );
689 }
690
691 #[test]
692 fn an_exemption_with_no_rules_is_rejected() {
693 let err = parse(
694 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
695 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
696 )
697 .unwrap_err();
698 assert!(err.to_string().contains("names no rules"), "got: {err}");
699 }
700
701 #[test]
702 fn an_exemption_with_an_empty_reason_is_rejected() {
703 let err = parse(
704 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
705 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
706 )
707 .unwrap_err();
708 assert!(err.to_string().contains("empty reason"), "got: {err}");
709 }
710
711 #[test]
712 fn an_unknown_rule_is_rejected() {
713 assert!(parse(
714 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
715 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
716 )
717 .is_err());
718 }
719
720 #[test]
721 fn default_python_coverage_is_the_strict_floor() {
722 // The zero-config floor is strict by default: branch on, 100.
723 // Locked here so it can't silently drift from the Defaults reference.
724 assert_eq!(
725 PythonCoverage::default(),
726 PythonCoverage {
727 branch: true,
728 fail_under: 100,
729 }
730 );
731 }
732
733 #[test]
734 fn default_typescript_coverage_is_the_strict_floor() {
735 // The zero-config floor is strict by default: all four metrics
736 // at 100. Locked here so it can't silently drift from the Defaults reference.
737 assert_eq!(
738 TypeScriptCoverage::default(),
739 TypeScriptCoverage {
740 lines: 100,
741 branches: 100,
742 functions: 100,
743 statements: 100,
744 }
745 );
746 }
747
748 #[test]
749 fn default_rust_coverage_is_the_strict_line_floor() {
750 // The zero-config Rust floor is `lines = 100` — matching Python/TS — with
751 // every other metric opt-in (None): `regions` (a Rust-only sub-line metric),
752 // `functions`, and `branch` (nightly-only instrumentation). Locked here
753 // so it can't silently drift from the Defaults reference.
754 assert_eq!(
755 RustCoverage::default(),
756 RustCoverage {
757 regions: None,
758 lines: 100,
759 functions: None,
760 branch: None,
761 }
762 );
763 }
764
765 #[test]
766 fn rust_coverage_table_parses_with_regions_omitted() {
767 // `regions` is opt-in: a `[rust].coverage` table may set `lines` alone,
768 // leaving the region check off.
769 let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
770 let coverage = config.rust.unwrap().coverage.unwrap();
771 assert_eq!(coverage.regions, None);
772 assert_eq!(coverage.lines, 90);
773 }
774
775 #[test]
776 fn a_python_build_command_with_an_optional_reason_parses() {
777 // The build_command survives, and an optional `reason` note is retained.
778 let config = parse(
779 "[python]\nbuild_command = \"uv run maturin develop\"\n\
780 reason = \"maturin's PEP 517 backend has no pre-build shell hook\"\n",
781 )
782 .unwrap();
783 let python = config.python.unwrap();
784 assert_eq!(
785 python.build_command.as_deref(),
786 Some("uv run maturin develop")
787 );
788 assert_eq!(
789 python.reason,
790 "maturin's PEP 517 backend has no pre-build shell hook"
791 );
792 }
793
794 #[test]
795 fn a_python_build_command_with_no_reason_loads() {
796 // `build_command` needs no reason — it supplies a necessary fact, it doesn't waive
797 // a check. A bare command (reason serde-defaulted to empty) loads.
798 let config = parse("[python]\nbuild_command = \"uv run maturin develop\"\n").unwrap();
799 let python = config.python.unwrap();
800 assert_eq!(
801 python.build_command.as_deref(),
802 Some("uv run maturin develop")
803 );
804 assert!(python.reason.is_empty());
805 }
806
807 #[test]
808 fn a_typescript_build_command_with_no_reason_loads() {
809 // The common TS case: a compile-before-pack named with no boilerplate justification.
810 let config = parse("[typescript]\nbuild_command = \"pnpm build\"\n").unwrap();
811 assert_eq!(
812 config.typescript.unwrap().build_command.as_deref(),
813 Some("pnpm build")
814 );
815 }
816
817 #[test]
818 fn a_valid_exemption_parses() {
819 // A whole-file presence exemption (a launcher shim with no colocated test).
820 let config = parse(
821 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
822 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\n\
823 reason = \"thin launcher\"\n",
824 )
825 .unwrap();
826 let exempt = &config.python.unwrap().exempt;
827 assert_eq!(exempt.len(), 1);
828 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest]);
829 assert!(exempt[0].lines.is_empty());
830 }
831
832 #[test]
833 fn exemptions_reads_the_rust_table() {
834 let config = parse(
835 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
836 reason = \"generated\"\n",
837 )
838 .unwrap();
839 let rust = config.exemptions(crate::colocated_test::Language::Rust);
840 assert_eq!(rust.len(), 1);
841 assert_eq!(rust[0].path, "build.rs");
842 }
843
844 /// A throwaway directory tree, removed on drop.
845 struct TempTree(std::path::PathBuf);
846
847 impl TempTree {
848 fn new(files: &[&str]) -> Self {
849 static COUNTER: AtomicU64 = AtomicU64::new(0);
850 let root = std::env::temp_dir().join(format!(
851 "tc-exempt-{}-{}",
852 std::process::id(),
853 COUNTER.fetch_add(1, Ordering::Relaxed),
854 ));
855 for rel in files {
856 let path = root.join(rel);
857 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
858 std::fs::write(path, "x = 1\n").unwrap();
859 }
860 TempTree(root)
861 }
862 }
863
864 impl Drop for TempTree {
865 fn drop(&mut self) {
866 let _ = std::fs::remove_dir_all(&self.0);
867 }
868 }
869
870 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
871 Exemption {
872 path: path.to_string(),
873 rules: rules.to_vec(),
874 lines: vec![],
875 reason: "deliberate".to_string(),
876 }
877 }
878
879 #[test]
880 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
881 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
882 let exemptions = [
883 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
884 exemption("pkg/gen.py", &[Rule::Coverage]),
885 exemption("loc_only.py", &[Rule::ColocatedTest]),
886 ];
887 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
888 assert_eq!(
889 coverage.into_iter().collect::<Vec<_>>(),
890 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
891 );
892 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
893 assert_eq!(
894 colocated_test.into_iter().collect::<Vec<_>>(),
895 vec!["cli.py".to_string(), "loc_only.py".to_string()],
896 );
897 }
898
899 #[test]
900 fn a_stale_exempt_path_is_an_error() {
901 let tree = TempTree::new(&["cli.py"]);
902 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
903 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
904 assert!(err.to_string().contains("matches no file"), "got: {err}");
905 }
906
907 #[test]
908 fn line_specs_parse_from_ints_and_range_strings() {
909 // `lines = [9, 10, "12-13"]` — a TOML integer is a single line, a "start-end"
910 // string is an inclusive range.
911 let config = parse(
912 "[[python.exempt]]\npath = \"shim.py\"\nrules = [\"coverage\"]\n\
913 lines = [9, 10, \"12-13\"]\nreason = \"dead branch\"\n",
914 )
915 .unwrap();
916 let exempt = &config.python.unwrap().exempt[0];
917 assert_eq!(
918 exempt.lines,
919 vec![
920 LineSpec::Single(9),
921 LineSpec::Single(10),
922 LineSpec::Range(12, 13),
923 ]
924 );
925 // `line_set` expands the range and de-duplicates into a sorted set.
926 assert_eq!(
927 exempt.line_set().into_iter().collect::<Vec<_>>(),
928 vec![9, 10, 12, 13]
929 );
930 }
931
932 #[test]
933 fn a_coverage_exemption_without_lines_is_rejected() {
934 // `lines` is required for the measured-line rules: an exemption can't
935 // lift a whole file from coverage, only the lines it can prove are uncovered.
936 let err = parse(
937 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\nreason = \"gen\"\n",
938 )
939 .unwrap_err();
940 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
941 }
942
943 #[test]
944 fn a_mutation_exemption_without_lines_is_rejected() {
945 let err = parse(
946 "[[rust.exempt]]\npath = \"src/lib.rs\"\nrules = [\"mutation\"]\nreason = \"eq\"\n",
947 )
948 .unwrap_err();
949 assert!(err.to_string().contains("lists no `lines`"), "got: {err}");
950 }
951
952 #[test]
953 fn lines_on_a_whole_file_rule_is_rejected() {
954 // `colocated-test` is whole-file presence, so a `lines` key alongside it can't
955 // mean anything — rejected on load.
956 let err = parse(
957 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
958 lines = [3]\nreason = \"shim\"\n",
959 )
960 .unwrap_err();
961 assert!(
962 err.to_string()
963 .contains("line-scoped exemptions apply only"),
964 "got: {err}"
965 );
966 }
967
968 #[test]
969 fn a_zero_line_is_rejected() {
970 let err = parse(
971 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
972 lines = [0]\nreason = \"x\"\n",
973 )
974 .unwrap_err();
975 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
976 }
977
978 #[test]
979 fn a_reversed_range_is_rejected() {
980 let err = parse(
981 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
982 lines = [\"13-12\"]\nreason = \"x\"\n",
983 )
984 .unwrap_err();
985 assert!(err.to_string().contains("invalid line spec"), "got: {err}");
986 }
987
988 #[test]
989 fn a_non_numeric_line_spec_is_a_parse_error() {
990 // Not a line number or range at all — rejected by the deserializer.
991 assert!(parse(
992 "[[python.exempt]]\npath = \"cli.py\"\nrules = [\"coverage\"]\n\
993 lines = [\"oops\"]\nreason = \"x\"\n",
994 )
995 .is_err());
996 }
997
998 #[test]
999 fn resolve_scoped_distinguishes_whole_file_from_lines() {
1000 // A `coverage` entry resolves to its lines; a `colocated-test` entry (whole-file
1001 // presence) to the whole file.
1002 let tree = TempTree::new(&["barrel.py", "scoped.py"]);
1003 let exemptions = [
1004 exemption("barrel.py", &[Rule::ColocatedTest]),
1005 Exemption {
1006 path: "scoped.py".to_string(),
1007 rules: vec![Rule::Coverage],
1008 lines: vec![LineSpec::Single(2), LineSpec::Range(4, 5)],
1009 reason: "dead branch".to_string(),
1010 },
1011 ];
1012 let coverage = resolve_exempt_scoped(&tree.0, &exemptions, Rule::Coverage).unwrap();
1013 assert_eq!(
1014 coverage["scoped.py"],
1015 LineScope::Lines([2, 4, 5].into_iter().collect())
1016 );
1017 let presence = resolve_exempt_scoped(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
1018 assert_eq!(presence["barrel.py"], LineScope::WholeFile);
1019 }
1020
1021 #[test]
1022 fn resolve_scoped_merges_two_entries_for_one_file() {
1023 // Two line-scoped entries for one file union their lines; two whole-file entries
1024 // stay whole-file.
1025 let tree = TempTree::new(&["a.py", "b.py"]);
1026 let line = |n: u32| Exemption {
1027 path: "a.py".to_string(),
1028 rules: vec![Rule::Mutation],
1029 lines: vec![LineSpec::Single(n)],
1030 reason: "equivalent mutant".to_string(),
1031 };
1032 let mutation = [line(3), line(7)];
1033 let scopes = resolve_exempt_scoped(&tree.0, &mutation, Rule::Mutation).unwrap();
1034 assert_eq!(
1035 scopes["a.py"],
1036 LineScope::Lines([3, 7].into_iter().collect())
1037 );
1038
1039 let presence = [
1040 exemption("b.py", &[Rule::ColocatedTest]),
1041 exemption("b.py", &[Rule::ColocatedTest]),
1042 ];
1043 let scopes = resolve_exempt_scoped(&tree.0, &presence, Rule::ColocatedTest).unwrap();
1044 assert_eq!(scopes["b.py"], LineScope::WholeFile);
1045 }
1046}