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