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`.
61#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct PythonCoverage {
64    pub branch: bool,
65    pub fail_under: u8,
66}
67
68/// The default Python floor used when coverage isn't configured (#80): branch
69/// coverage on, `fail_under = 100` (#194). Strict by default — "100% of what you
70/// didn't explicitly exempt" — because the rule already honors `# pragma: no cover`,
71/// reason-required `[[python.exempt]]` entries, and the empty/comment-only
72/// auto-exemption, so trivia is excluded deliberately rather than by a slack floor.
73/// A config `[python].coverage` table lowers it when a project wants headroom.
74impl Default for PythonCoverage {
75    fn default() -> Self {
76        Self {
77            branch: true,
78            fail_under: 100,
79        }
80    }
81}
82
83/// `[typescript].coverage`.
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct TypeScriptCoverage {
87    pub lines: u8,
88    pub branches: u8,
89    pub functions: u8,
90    pub statements: u8,
91}
92
93/// The default TypeScript floors used when coverage isn't configured (#80): all
94/// four metrics at 100 (#194), matching the strict-by-default Python floor. As with
95/// Python, "100" means "100% of what you didn't explicitly exempt" — the rule honors
96/// reason-required `[[typescript.exempt]]` entries and skips declaration files
97/// (`*.d.ts`). A config `[typescript].coverage` table lowers any of the four.
98impl Default for TypeScriptCoverage {
99    fn default() -> Self {
100        Self {
101            lines: 100,
102            branches: 100,
103            functions: 100,
104            statements: 100,
105        }
106    }
107}
108
109/// `[rust].coverage`. Branch coverage is still experimental, so only
110/// regions/lines are configurable, and `regions` is opt-in (Rust-only, sub-line):
111/// `None` unless the table sets it, while `lines` always carries a value.
112#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct RustCoverage {
115    #[serde(default)]
116    pub regions: Option<u8>,
117    pub lines: u8,
118}
119
120/// The default Rust floor used when coverage isn't configured (#206): `lines = 100`,
121/// matching Python/TypeScript's line-level 100. Two deliberate asymmetries from the
122/// other languages, both forced by `cargo llvm-cov` on stable: there is **no branch
123/// component** (branch coverage is experimental), and **`regions` is opt-in** (a
124/// Rust-only sub-line metric, harsher than lines — `None` unless a config sets it),
125/// so the zero-config floor is lines only. As with Python/TypeScript, "100" means
126/// "100% of what you didn't explicitly exempt" — the rule honors reason-required
127/// `[[rust.exempt]]` entries. A config `[rust].coverage` table lowers the line floor
128/// or adds a `regions` floor.
129impl Default for RustCoverage {
130    fn default() -> Self {
131        Self {
132            regions: None,
133            lines: 100,
134        }
135    }
136}
137
138/// A rule a file can be exempted from (issue #32).
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum Rule {
142    /// The unit-test colocated-test check ([`crate::colocated_test`]).
143    ColocatedTest,
144    /// The unit-test coverage floor ([`crate::coverage`]).
145    Coverage,
146    /// The commit-scoped `co-change` check ([`crate::co_change`], #33) — a
147    /// changed source whose colocated test needn't co-change.
148    CoChange,
149    /// `integration lint` — a test/fixture takes pytest's `monkeypatch` fixture ([`crate::lint`], #49).
150    NoMonkeypatch,
151    /// `integration lint` — a `patch(...)` called inline in a Python test body ([`crate::lint`], #50).
152    NoInlinePatch,
153    /// `integration lint` — direct mutation of `os.environ` in a Python test ([`crate::lint`], #51).
154    NoEnvironMutation,
155    /// The `no-constant-patch` lint ([`crate::lint`], issue #52).
156    NoConstantPatch,
157    /// `integration lint` — patching a first-party target in a Python integration test ([`crate::lint`], #42).
158    NoFirstPartyPatch,
159    /// `unit lint` — a call out of a Rust unit's own module ([`crate::isolation`], #44).
160    NoOutOfModuleCall,
161    /// `unit lint` — a foreign `use` in a Rust unit test ([`crate::isolation`], #44).
162    NoOutOfModuleImport,
163    /// `integration lint` — doubling a first-party item in a Rust integration test (#44).
164    NoFirstPartyDouble,
165    /// `unit lint` — an un-mocked first-party/external import in a TS unit test ([`crate::ts`], #76).
166    UnmockedCollaborator,
167    /// `unit lint` — a `vi.mock` without a typed anchor in a TS unit test (#77).
168    UntypedMock,
169    /// `integration lint` — a `vi.mock` of a first-party module in a TS integration test (#75).
170    NoFirstPartyMock,
171    /// `unit mutation` — a surviving mutant the unit suite didn't catch ([`crate::mutation`], #201).
172    Mutation,
173}
174
175impl Rule {
176    /// The rule's kebab-case id — the string used in a `Violation` and in a config
177    /// `rules` value. Mirrors the `serde(rename_all = "kebab-case")` encoding.
178    pub fn id(self) -> &'static str {
179        match self {
180            Rule::ColocatedTest => "colocated-test",
181            Rule::Coverage => "coverage",
182            Rule::CoChange => "co-change",
183            Rule::NoMonkeypatch => "no-monkeypatch",
184            Rule::NoInlinePatch => "no-inline-patch",
185            Rule::NoEnvironMutation => "no-environ-mutation",
186            Rule::NoConstantPatch => "no-constant-patch",
187            Rule::NoFirstPartyPatch => "no-first-party-patch",
188            Rule::NoOutOfModuleCall => "no-out-of-module-call",
189            Rule::NoOutOfModuleImport => "no-out-of-module-import",
190            Rule::NoFirstPartyDouble => "no-first-party-double",
191            Rule::UnmockedCollaborator => "unmocked-collaborator",
192            Rule::UntypedMock => "untyped-mock",
193            Rule::NoFirstPartyMock => "no-first-party-mock",
194            Rule::Mutation => "mutation",
195        }
196    }
197
198    /// The [`Rule`] for a lint id, or `None` for an unknown / non-waivable id.
199    pub fn from_id(id: &str) -> Option<Rule> {
200        [
201            Rule::ColocatedTest,
202            Rule::Coverage,
203            Rule::CoChange,
204            Rule::NoMonkeypatch,
205            Rule::NoInlinePatch,
206            Rule::NoEnvironMutation,
207            Rule::NoConstantPatch,
208            Rule::NoFirstPartyPatch,
209            Rule::NoOutOfModuleCall,
210            Rule::NoOutOfModuleImport,
211            Rule::NoFirstPartyDouble,
212            Rule::UnmockedCollaborator,
213            Rule::UntypedMock,
214            Rule::NoFirstPartyMock,
215            Rule::Mutation,
216        ]
217        .into_iter()
218        .find(|rule| rule.id() == id)
219    }
220}
221
222/// One auditable per-file exemption — a `[[<language>.exempt]]` entry.
223///
224/// The opposite of a silent ignore-glob: an exemption is declared in the one
225/// config file, names the rules it lifts, and **must say why**. Empty
226/// (comment-only) files need no entry — they carry no logic and are not
227/// subjects — so this is for deliberate omissions the tool can't infer (a
228/// launcher shim, generated code, a re-export barrel).
229#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct Exemption {
232    /// Path to the exempt file, relative to the scanned root.
233    pub path: String,
234    /// Which rules the exemption lifts (`colocated-test`, `coverage`).
235    pub rules: Vec<Rule>,
236    /// Why the omission is deliberate — required, and never empty.
237    pub reason: String,
238}
239
240/// Read one config file at `path` into a [`Config`], validating it on the way.
241///
242/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
243/// rejects keys that aren't part of the schema, missing required keys and
244/// wrong-typed values are type errors, malformed TOML fails to parse, and every
245/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
246/// surfaces as an `Err` rather than a silently-accepted default.
247pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
248    let path = path.as_ref();
249    let contents = std::fs::read_to_string(path)
250        .with_context(|| format!("reading config file `{}`", path.display()))?;
251    let config: Config = toml::from_str(&contents)
252        .with_context(|| format!("parsing config file `{}`", path.display()))?;
253    config
254        .validate()
255        .with_context(|| format!("validating config file `{}`", path.display()))?;
256    Ok(config)
257}
258
259impl Config {
260    /// The `exempt` list for `language` (empty when the table is absent).
261    pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
262        match language {
263            crate::colocated_test::Language::Python => {
264                self.python.as_ref().map_or(&[], |c| &c.exempt)
265            }
266            crate::colocated_test::Language::TypeScript => {
267                self.typescript.as_ref().map_or(&[], |c| &c.exempt)
268            }
269            crate::colocated_test::Language::Rust => self.rust_exemptions(),
270        }
271    }
272
273    /// The `[[rust.exempt]]` list (empty when the table is absent). The named
274    /// accessor the Rust isolation rules (#44) waive through; equivalent to
275    /// [`Self::exemptions`]`(Language::Rust)`.
276    pub fn rust_exemptions(&self) -> &[Exemption] {
277        self.rust.as_ref().map_or(&[], |c| &c.exempt)
278    }
279
280    /// Reject any `exempt` entry that names no rule or carries an empty reason —
281    /// a reasonless or scopeless exemption can never be a silent pass.
282    fn validate(&self) -> Result<()> {
283        let tables = [
284            ("python", self.python.as_ref().map(|c| &c.exempt)),
285            ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
286            ("rust", self.rust.as_ref().map(|c| &c.exempt)),
287        ];
288        for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
289            for entry in exempt {
290                if entry.rules.is_empty() {
291                    bail!(
292                        "[{table}].exempt entry for `{}` names no rules — set \
293                         `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
294                        entry.path
295                    );
296                }
297                if entry.reason.trim().is_empty() {
298                    bail!(
299                        "[{table}].exempt entry for `{}` has an empty reason — \
300                         every exemption must say why the file is exempt",
301                        entry.path
302                    );
303                }
304            }
305        }
306        Ok(())
307    }
308}
309
310/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
311/// each still points to a file under `root`.
312///
313/// A stale entry — a path that no longer exists — is an error, so the exempt
314/// list can't silently rot (the auditable counterpart to an ignore-glob, which
315/// would just stop matching). Returns the matching paths as `/`-joined,
316/// `root`-relative strings, sorted and de-duplicated.
317pub fn resolve_exempt(
318    root: &Path,
319    exemptions: &[Exemption],
320    rule: Rule,
321) -> Result<BTreeSet<String>> {
322    let mut paths = BTreeSet::new();
323    for entry in exemptions {
324        if !entry.rules.contains(&rule) {
325            continue;
326        }
327        if !root.join(&entry.path).is_file() {
328            bail!(
329                "exempt entry `{}` matches no file under `{}` — remove the stale \
330                 entry or fix the path",
331                entry.path,
332                root.display()
333            );
334        }
335        paths.insert(entry.path.replace('\\', "/"));
336    }
337    Ok(paths)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use std::sync::atomic::{AtomicU64, Ordering};
344
345    fn parse(toml_src: &str) -> Result<Config> {
346        let config: Config = toml::from_str(toml_src)?;
347        config.validate()?;
348        Ok(config)
349    }
350
351    #[test]
352    fn an_exemption_with_no_rules_is_rejected() {
353        let err = parse(
354            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
355             [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
356        )
357        .unwrap_err();
358        assert!(err.to_string().contains("names no rules"), "got: {err}");
359    }
360
361    #[test]
362    fn an_exemption_with_an_empty_reason_is_rejected() {
363        let err = parse(
364            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
365             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \"  \"\n",
366        )
367        .unwrap_err();
368        assert!(err.to_string().contains("empty reason"), "got: {err}");
369    }
370
371    #[test]
372    fn an_unknown_rule_is_rejected() {
373        assert!(parse(
374            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
375             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
376        )
377        .is_err());
378    }
379
380    #[test]
381    fn default_python_coverage_is_the_strict_floor() {
382        // The zero-config floor (#80, #194) is strict by default: branch on, 100.
383        // Locked here so it can't silently drift from the Defaults reference.
384        assert_eq!(
385            PythonCoverage::default(),
386            PythonCoverage {
387                branch: true,
388                fail_under: 100,
389            }
390        );
391    }
392
393    #[test]
394    fn default_typescript_coverage_is_the_strict_floor() {
395        // The zero-config floor (#80, #194) is strict by default: all four metrics
396        // at 100. Locked here so it can't silently drift from the Defaults reference.
397        assert_eq!(
398            TypeScriptCoverage::default(),
399            TypeScriptCoverage {
400                lines: 100,
401                branches: 100,
402                functions: 100,
403                statements: 100,
404            }
405        );
406    }
407
408    #[test]
409    fn default_rust_coverage_is_the_strict_line_floor() {
410        // The zero-config Rust floor (#206) is `lines = 100` — matching Python/TS — with
411        // `regions` opt-in (None) and no branch component, two deliberate asymmetries
412        // forced by `cargo llvm-cov` on stable. Locked here so it can't silently drift
413        // from the Defaults reference.
414        assert_eq!(
415            RustCoverage::default(),
416            RustCoverage {
417                regions: None,
418                lines: 100,
419            }
420        );
421    }
422
423    #[test]
424    fn rust_coverage_table_parses_with_regions_omitted() {
425        // `regions` is opt-in (#206): a `[rust].coverage` table may set `lines` alone,
426        // leaving the region check off.
427        let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
428        let coverage = config.rust.unwrap().coverage.unwrap();
429        assert_eq!(coverage.regions, None);
430        assert_eq!(coverage.lines, 90);
431    }
432
433    #[test]
434    fn a_valid_exemption_parses() {
435        let config = parse(
436            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
437             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
438             reason = \"thin launcher\"\n",
439        )
440        .unwrap();
441        let exempt = &config.python.unwrap().exempt;
442        assert_eq!(exempt.len(), 1);
443        assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
444    }
445
446    #[test]
447    fn exemptions_reads_the_rust_table() {
448        let config = parse(
449            "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
450             reason = \"generated\"\n",
451        )
452        .unwrap();
453        let rust = config.exemptions(crate::colocated_test::Language::Rust);
454        assert_eq!(rust.len(), 1);
455        assert_eq!(rust[0].path, "build.rs");
456    }
457
458    /// A throwaway directory tree, removed on drop.
459    struct TempTree(std::path::PathBuf);
460
461    impl TempTree {
462        fn new(files: &[&str]) -> Self {
463            static COUNTER: AtomicU64 = AtomicU64::new(0);
464            let root = std::env::temp_dir().join(format!(
465                "tc-exempt-{}-{}",
466                std::process::id(),
467                COUNTER.fetch_add(1, Ordering::Relaxed),
468            ));
469            for rel in files {
470                let path = root.join(rel);
471                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
472                std::fs::write(path, "x = 1\n").unwrap();
473            }
474            TempTree(root)
475        }
476    }
477
478    impl Drop for TempTree {
479        fn drop(&mut self) {
480            let _ = std::fs::remove_dir_all(&self.0);
481        }
482    }
483
484    fn exemption(path: &str, rules: &[Rule]) -> Exemption {
485        Exemption {
486            path: path.to_string(),
487            rules: rules.to_vec(),
488            reason: "deliberate".to_string(),
489        }
490    }
491
492    #[test]
493    fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
494        let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
495        let exemptions = [
496            exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
497            exemption("pkg/gen.py", &[Rule::Coverage]),
498            exemption("loc_only.py", &[Rule::ColocatedTest]),
499        ];
500        let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
501        assert_eq!(
502            coverage.into_iter().collect::<Vec<_>>(),
503            vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
504        );
505        let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
506        assert_eq!(
507            colocated_test.into_iter().collect::<Vec<_>>(),
508            vec!["cli.py".to_string(), "loc_only.py".to_string()],
509        );
510    }
511
512    #[test]
513    fn a_stale_exempt_path_is_an_error() {
514        let tree = TempTree::new(&["cli.py"]);
515        let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
516        let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
517        assert!(err.to_string().contains("matches no file"), "got: {err}");
518    }
519}