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