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