Skip to main content

testing_conventions/
config.rs

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