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}
172
173impl Rule {
174    /// The rule's kebab-case id — the string used in a `Violation` and in a config
175    /// `rules` value. Mirrors the `serde(rename_all = "kebab-case")` encoding.
176    pub fn id(self) -> &'static str {
177        match self {
178            Rule::ColocatedTest => "colocated-test",
179            Rule::Coverage => "coverage",
180            Rule::CoChange => "co-change",
181            Rule::NoMonkeypatch => "no-monkeypatch",
182            Rule::NoInlinePatch => "no-inline-patch",
183            Rule::NoEnvironMutation => "no-environ-mutation",
184            Rule::NoConstantPatch => "no-constant-patch",
185            Rule::NoFirstPartyPatch => "no-first-party-patch",
186            Rule::NoOutOfModuleCall => "no-out-of-module-call",
187            Rule::NoOutOfModuleImport => "no-out-of-module-import",
188            Rule::NoFirstPartyDouble => "no-first-party-double",
189            Rule::UnmockedCollaborator => "unmocked-collaborator",
190            Rule::UntypedMock => "untyped-mock",
191            Rule::NoFirstPartyMock => "no-first-party-mock",
192        }
193    }
194
195    /// The [`Rule`] for a lint id, or `None` for an unknown / non-waivable id.
196    pub fn from_id(id: &str) -> Option<Rule> {
197        [
198            Rule::ColocatedTest,
199            Rule::Coverage,
200            Rule::CoChange,
201            Rule::NoMonkeypatch,
202            Rule::NoInlinePatch,
203            Rule::NoEnvironMutation,
204            Rule::NoConstantPatch,
205            Rule::NoFirstPartyPatch,
206            Rule::NoOutOfModuleCall,
207            Rule::NoOutOfModuleImport,
208            Rule::NoFirstPartyDouble,
209            Rule::UnmockedCollaborator,
210            Rule::UntypedMock,
211            Rule::NoFirstPartyMock,
212        ]
213        .into_iter()
214        .find(|rule| rule.id() == id)
215    }
216}
217
218/// One auditable per-file exemption — a `[[<language>.exempt]]` entry.
219///
220/// The opposite of a silent ignore-glob: an exemption is declared in the one
221/// config file, names the rules it lifts, and **must say why**. Empty
222/// (comment-only) files need no entry — they carry no logic and are not
223/// subjects — so this is for deliberate omissions the tool can't infer (a
224/// launcher shim, generated code, a re-export barrel).
225#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct Exemption {
228    /// Path to the exempt file, relative to the scanned root.
229    pub path: String,
230    /// Which rules the exemption lifts (`colocated-test`, `coverage`).
231    pub rules: Vec<Rule>,
232    /// Why the omission is deliberate — required, and never empty.
233    pub reason: String,
234}
235
236/// Read one config file at `path` into a [`Config`], validating it on the way.
237///
238/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
239/// rejects keys that aren't part of the schema, missing required keys and
240/// wrong-typed values are type errors, malformed TOML fails to parse, and every
241/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
242/// surfaces as an `Err` rather than a silently-accepted default.
243pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
244    let path = path.as_ref();
245    let contents = std::fs::read_to_string(path)
246        .with_context(|| format!("reading config file `{}`", path.display()))?;
247    let config: Config = toml::from_str(&contents)
248        .with_context(|| format!("parsing config file `{}`", path.display()))?;
249    config
250        .validate()
251        .with_context(|| format!("validating config file `{}`", path.display()))?;
252    Ok(config)
253}
254
255impl Config {
256    /// The `exempt` list for `language` (empty when the table is absent).
257    pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
258        match language {
259            crate::colocated_test::Language::Python => {
260                self.python.as_ref().map_or(&[], |c| &c.exempt)
261            }
262            crate::colocated_test::Language::TypeScript => {
263                self.typescript.as_ref().map_or(&[], |c| &c.exempt)
264            }
265            crate::colocated_test::Language::Rust => self.rust_exemptions(),
266        }
267    }
268
269    /// The `[[rust.exempt]]` list (empty when the table is absent). The named
270    /// accessor the Rust isolation rules (#44) waive through; equivalent to
271    /// [`Self::exemptions`]`(Language::Rust)`.
272    pub fn rust_exemptions(&self) -> &[Exemption] {
273        self.rust.as_ref().map_or(&[], |c| &c.exempt)
274    }
275
276    /// Reject any `exempt` entry that names no rule or carries an empty reason —
277    /// a reasonless or scopeless exemption can never be a silent pass.
278    fn validate(&self) -> Result<()> {
279        let tables = [
280            ("python", self.python.as_ref().map(|c| &c.exempt)),
281            ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
282            ("rust", self.rust.as_ref().map(|c| &c.exempt)),
283        ];
284        for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
285            for entry in exempt {
286                if entry.rules.is_empty() {
287                    bail!(
288                        "[{table}].exempt entry for `{}` names no rules — set \
289                         `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
290                        entry.path
291                    );
292                }
293                if entry.reason.trim().is_empty() {
294                    bail!(
295                        "[{table}].exempt entry for `{}` has an empty reason — \
296                         every exemption must say why the file is exempt",
297                        entry.path
298                    );
299                }
300            }
301        }
302        Ok(())
303    }
304}
305
306/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
307/// each still points to a file under `root`.
308///
309/// A stale entry — a path that no longer exists — is an error, so the exempt
310/// list can't silently rot (the auditable counterpart to an ignore-glob, which
311/// would just stop matching). Returns the matching paths as `/`-joined,
312/// `root`-relative strings, sorted and de-duplicated.
313pub fn resolve_exempt(
314    root: &Path,
315    exemptions: &[Exemption],
316    rule: Rule,
317) -> Result<BTreeSet<String>> {
318    let mut paths = BTreeSet::new();
319    for entry in exemptions {
320        if !entry.rules.contains(&rule) {
321            continue;
322        }
323        if !root.join(&entry.path).is_file() {
324            bail!(
325                "exempt entry `{}` matches no file under `{}` — remove the stale \
326                 entry or fix the path",
327                entry.path,
328                root.display()
329            );
330        }
331        paths.insert(entry.path.replace('\\', "/"));
332    }
333    Ok(paths)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::sync::atomic::{AtomicU64, Ordering};
340
341    fn parse(toml_src: &str) -> Result<Config> {
342        let config: Config = toml::from_str(toml_src)?;
343        config.validate()?;
344        Ok(config)
345    }
346
347    #[test]
348    fn an_exemption_with_no_rules_is_rejected() {
349        let err = parse(
350            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
351             [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
352        )
353        .unwrap_err();
354        assert!(err.to_string().contains("names no rules"), "got: {err}");
355    }
356
357    #[test]
358    fn an_exemption_with_an_empty_reason_is_rejected() {
359        let err = parse(
360            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
361             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \"  \"\n",
362        )
363        .unwrap_err();
364        assert!(err.to_string().contains("empty reason"), "got: {err}");
365    }
366
367    #[test]
368    fn an_unknown_rule_is_rejected() {
369        assert!(parse(
370            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
371             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
372        )
373        .is_err());
374    }
375
376    #[test]
377    fn default_python_coverage_is_the_strict_floor() {
378        // The zero-config floor (#80, #194) is strict by default: branch on, 100.
379        // Locked here so it can't silently drift from the Defaults reference.
380        assert_eq!(
381            PythonCoverage::default(),
382            PythonCoverage {
383                branch: true,
384                fail_under: 100,
385            }
386        );
387    }
388
389    #[test]
390    fn default_typescript_coverage_is_the_strict_floor() {
391        // The zero-config floor (#80, #194) is strict by default: all four metrics
392        // at 100. Locked here so it can't silently drift from the Defaults reference.
393        assert_eq!(
394            TypeScriptCoverage::default(),
395            TypeScriptCoverage {
396                lines: 100,
397                branches: 100,
398                functions: 100,
399                statements: 100,
400            }
401        );
402    }
403
404    #[test]
405    fn default_rust_coverage_is_the_strict_line_floor() {
406        // The zero-config Rust floor (#206) is `lines = 100` — matching Python/TS — with
407        // `regions` opt-in (None) and no branch component, two deliberate asymmetries
408        // forced by `cargo llvm-cov` on stable. Locked here so it can't silently drift
409        // from the Defaults reference.
410        assert_eq!(
411            RustCoverage::default(),
412            RustCoverage {
413                regions: None,
414                lines: 100,
415            }
416        );
417    }
418
419    #[test]
420    fn rust_coverage_table_parses_with_regions_omitted() {
421        // `regions` is opt-in (#206): a `[rust].coverage` table may set `lines` alone,
422        // leaving the region check off.
423        let config = parse("[rust]\ncoverage = { lines = 90 }\n").unwrap();
424        let coverage = config.rust.unwrap().coverage.unwrap();
425        assert_eq!(coverage.regions, None);
426        assert_eq!(coverage.lines, 90);
427    }
428
429    #[test]
430    fn a_valid_exemption_parses() {
431        let config = parse(
432            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
433             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
434             reason = \"thin launcher\"\n",
435        )
436        .unwrap();
437        let exempt = &config.python.unwrap().exempt;
438        assert_eq!(exempt.len(), 1);
439        assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
440    }
441
442    #[test]
443    fn exemptions_reads_the_rust_table() {
444        let config = parse(
445            "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
446             reason = \"generated\"\n",
447        )
448        .unwrap();
449        let rust = config.exemptions(crate::colocated_test::Language::Rust);
450        assert_eq!(rust.len(), 1);
451        assert_eq!(rust[0].path, "build.rs");
452    }
453
454    /// A throwaway directory tree, removed on drop.
455    struct TempTree(std::path::PathBuf);
456
457    impl TempTree {
458        fn new(files: &[&str]) -> Self {
459            static COUNTER: AtomicU64 = AtomicU64::new(0);
460            let root = std::env::temp_dir().join(format!(
461                "tc-exempt-{}-{}",
462                std::process::id(),
463                COUNTER.fetch_add(1, Ordering::Relaxed),
464            ));
465            for rel in files {
466                let path = root.join(rel);
467                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
468                std::fs::write(path, "x = 1\n").unwrap();
469            }
470            TempTree(root)
471        }
472    }
473
474    impl Drop for TempTree {
475        fn drop(&mut self) {
476            let _ = std::fs::remove_dir_all(&self.0);
477        }
478    }
479
480    fn exemption(path: &str, rules: &[Rule]) -> Exemption {
481        Exemption {
482            path: path.to_string(),
483            rules: rules.to_vec(),
484            reason: "deliberate".to_string(),
485        }
486    }
487
488    #[test]
489    fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
490        let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
491        let exemptions = [
492            exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
493            exemption("pkg/gen.py", &[Rule::Coverage]),
494            exemption("loc_only.py", &[Rule::ColocatedTest]),
495        ];
496        let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
497        assert_eq!(
498            coverage.into_iter().collect::<Vec<_>>(),
499            vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
500        );
501        let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
502        assert_eq!(
503            colocated_test.into_iter().collect::<Vec<_>>(),
504            vec!["cli.py".to_string(), "loc_only.py".to_string()],
505        );
506    }
507
508    #[test]
509    fn a_stale_exempt_path_is_an_error() {
510        let tree = TempTree::new(&["cli.py"]);
511        let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
512        let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
513        assert!(err.to_string().contains("matches no file"), "got: {err}");
514    }
515}