1use std::collections::BTreeSet;
10use std::path::Path;
11
12use anyhow::{bail, Context, Result};
13use serde::Deserialize;
14
15#[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#[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#[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#[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#[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
68impl Default for PythonCoverage {
73 fn default() -> Self {
74 Self {
75 branch: true,
76 fail_under: 85,
77 }
78 }
79}
80
81#[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
91impl 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
116#[serde(rename_all = "kebab-case")]
117pub enum Rule {
118 ColocatedTest,
120 Coverage,
122 NoConstantPatch,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
135#[serde(deny_unknown_fields)]
136pub struct Exemption {
137 pub path: String,
139 pub rules: Vec<Rule>,
141 pub reason: String,
143}
144
145pub 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 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 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
207pub 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 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 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 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}