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, 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.
32#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct PythonConfig {
35    pub coverage: Option<PythonCoverage>,
36    #[serde(default)]
37    pub exempt: Vec<Exemption>,
38}
39
40/// The `[typescript]` table.
41#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
42#[serde(deny_unknown_fields)]
43pub struct TypeScriptConfig {
44    pub coverage: Option<TypeScriptCoverage>,
45    #[serde(default)]
46    pub exempt: Vec<Exemption>,
47}
48
49/// The `[rust]` table.
50#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct RustConfig {
53    pub coverage: Option<RustCoverage>,
54    #[serde(default)]
55    pub exempt: Vec<Exemption>,
56}
57
58/// `[python].coverage`.
59#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct PythonCoverage {
62    pub branch: bool,
63    pub fail_under: u8,
64}
65
66/// `[typescript].coverage`.
67#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct TypeScriptCoverage {
70    pub lines: u8,
71    pub branches: u8,
72    pub functions: u8,
73    pub statements: u8,
74}
75
76/// `[rust].coverage`. Branch coverage is still experimental, so only
77/// regions/lines are configurable.
78#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct RustCoverage {
81    pub regions: u8,
82    pub lines: u8,
83}
84
85/// A rule a file can be exempted from (issue #32).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
87#[serde(rename_all = "lowercase")]
88pub enum Rule {
89    /// The unit-test location/naming check ([`crate::location`]).
90    Location,
91    /// The unit-test coverage floor ([`crate::coverage`]).
92    Coverage,
93}
94
95/// One auditable per-file exemption — a `[[<language>.exempt]]` entry.
96///
97/// The opposite of a silent ignore-glob: an exemption is declared in the one
98/// config file, names the rules it lifts, and **must say why**. Empty
99/// (comment-only) files need no entry — they carry no logic and are not
100/// subjects — so this is for deliberate omissions the tool can't infer (a
101/// launcher shim, generated code, a re-export barrel).
102#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Exemption {
105    /// Path to the exempt file, relative to the scanned root.
106    pub path: String,
107    /// Which rules the exemption lifts (`location`, `coverage`).
108    pub rules: Vec<Rule>,
109    /// Why the omission is deliberate — required, and never empty.
110    pub reason: String,
111}
112
113/// Read one config file at `path` into a [`Config`], validating it on the way.
114///
115/// The validation is the config's self-guard: `serde`'s `deny_unknown_fields`
116/// rejects keys that aren't part of the schema, missing required keys and
117/// wrong-typed values are type errors, malformed TOML fails to parse, and every
118/// `exempt` entry must name a rule and carry a non-empty reason. Any of these
119/// surfaces as an `Err` rather than a silently-accepted default.
120pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
121    let path = path.as_ref();
122    let contents = std::fs::read_to_string(path)
123        .with_context(|| format!("reading config file `{}`", path.display()))?;
124    let config: Config = toml::from_str(&contents)
125        .with_context(|| format!("parsing config file `{}`", path.display()))?;
126    config
127        .validate()
128        .with_context(|| format!("validating config file `{}`", path.display()))?;
129    Ok(config)
130}
131
132impl Config {
133    /// The `exempt` list for `language` (empty when the table is absent).
134    pub fn exemptions(&self, language: crate::location::Language) -> &[Exemption] {
135        match language {
136            crate::location::Language::Python => self.python.as_ref().map_or(&[], |c| &c.exempt),
137            crate::location::Language::TypeScript => {
138                self.typescript.as_ref().map_or(&[], |c| &c.exempt)
139            }
140        }
141    }
142
143    /// Reject any `exempt` entry that names no rule or carries an empty reason —
144    /// a reasonless or scopeless exemption can never be a silent pass.
145    fn validate(&self) -> Result<()> {
146        let tables = [
147            ("python", self.python.as_ref().map(|c| &c.exempt)),
148            ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
149            ("rust", self.rust.as_ref().map(|c| &c.exempt)),
150        ];
151        for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
152            for entry in exempt {
153                if entry.rules.is_empty() {
154                    bail!(
155                        "[{table}].exempt entry for `{}` names no rules — set \
156                         `rules = [\"location\"]` and/or `\"coverage\"`",
157                        entry.path
158                    );
159                }
160                if entry.reason.trim().is_empty() {
161                    bail!(
162                        "[{table}].exempt entry for `{}` has an empty reason — \
163                         every exemption must say why the file is exempt",
164                        entry.path
165                    );
166                }
167            }
168        }
169        Ok(())
170    }
171}
172
173/// Resolve the set of exempt paths for `rule` from `exemptions`, validating that
174/// each still points to a file under `root`.
175///
176/// A stale entry — a path that no longer exists — is an error, so the exempt
177/// list can't silently rot (the auditable counterpart to an ignore-glob, which
178/// would just stop matching). Returns the matching paths as `/`-joined,
179/// `root`-relative strings, sorted and de-duplicated.
180pub fn resolve_exempt(
181    root: &Path,
182    exemptions: &[Exemption],
183    rule: Rule,
184) -> Result<BTreeSet<String>> {
185    let mut paths = BTreeSet::new();
186    for entry in exemptions {
187        if !entry.rules.contains(&rule) {
188            continue;
189        }
190        if !root.join(&entry.path).is_file() {
191            bail!(
192                "exempt entry `{}` matches no file under `{}` — remove the stale \
193                 entry or fix the path",
194                entry.path,
195                root.display()
196            );
197        }
198        paths.insert(entry.path.replace('\\', "/"));
199    }
200    Ok(paths)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::sync::atomic::{AtomicU64, Ordering};
207
208    fn parse(toml_src: &str) -> Result<Config> {
209        let config: Config = toml::from_str(toml_src)?;
210        config.validate()?;
211        Ok(config)
212    }
213
214    #[test]
215    fn an_exemption_with_no_rules_is_rejected() {
216        let err = parse(
217            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
218             [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
219        )
220        .unwrap_err();
221        assert!(err.to_string().contains("names no rules"), "got: {err}");
222    }
223
224    #[test]
225    fn an_exemption_with_an_empty_reason_is_rejected() {
226        let err = parse(
227            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
228             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"location\"]\nreason = \"  \"\n",
229        )
230        .unwrap_err();
231        assert!(err.to_string().contains("empty reason"), "got: {err}");
232    }
233
234    #[test]
235    fn an_unknown_rule_is_rejected() {
236        assert!(parse(
237            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
238             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
239        )
240        .is_err());
241    }
242
243    #[test]
244    fn a_valid_exemption_parses() {
245        let config = parse(
246            "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
247             [[python.exempt]]\npath = \"cli.py\"\nrules = [\"location\", \"coverage\"]\n\
248             reason = \"thin launcher\"\n",
249        )
250        .unwrap();
251        let exempt = &config.python.unwrap().exempt;
252        assert_eq!(exempt.len(), 1);
253        assert_eq!(exempt[0].rules, vec![Rule::Location, Rule::Coverage]);
254    }
255
256    /// A throwaway directory tree, removed on drop.
257    struct TempTree(std::path::PathBuf);
258
259    impl TempTree {
260        fn new(files: &[&str]) -> Self {
261            static COUNTER: AtomicU64 = AtomicU64::new(0);
262            let root = std::env::temp_dir().join(format!(
263                "tc-exempt-{}-{}",
264                std::process::id(),
265                COUNTER.fetch_add(1, Ordering::Relaxed),
266            ));
267            for rel in files {
268                let path = root.join(rel);
269                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
270                std::fs::write(path, "x = 1\n").unwrap();
271            }
272            TempTree(root)
273        }
274    }
275
276    impl Drop for TempTree {
277        fn drop(&mut self) {
278            let _ = std::fs::remove_dir_all(&self.0);
279        }
280    }
281
282    fn exemption(path: &str, rules: &[Rule]) -> Exemption {
283        Exemption {
284            path: path.to_string(),
285            rules: rules.to_vec(),
286            reason: "deliberate".to_string(),
287        }
288    }
289
290    #[test]
291    fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
292        let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
293        let exemptions = [
294            exemption("cli.py", &[Rule::Location, Rule::Coverage]),
295            exemption("pkg/gen.py", &[Rule::Coverage]),
296            exemption("loc_only.py", &[Rule::Location]),
297        ];
298        let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
299        assert_eq!(
300            coverage.into_iter().collect::<Vec<_>>(),
301            vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
302        );
303        let location = resolve_exempt(&tree.0, &exemptions, Rule::Location).unwrap();
304        assert_eq!(
305            location.into_iter().collect::<Vec<_>>(),
306            vec!["cli.py".to_string(), "loc_only.py".to_string()],
307        );
308    }
309
310    #[test]
311    fn a_stale_exempt_path_is_an_error() {
312        let tree = TempTree::new(&["cli.py"]);
313        let exemptions = [exemption("ghost.py", &[Rule::Location])];
314        let err = resolve_exempt(&tree.0, &exemptions, Rule::Location).unwrap_err();
315        assert!(err.to_string().contains("matches no file"), "got: {err}");
316    }
317}