Skip to main content

testing_conventions/
config.rs

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