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