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