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