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