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