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, Hash, Deserialize)]
116#[serde(rename_all = "kebab-case")]
117pub enum Rule {
118 ColocatedTest,
120 Coverage,
122 NoConstantPatch,
124 NoOutOfModuleCall,
126 NoOutOfModuleImport,
128 NoFirstPartyDouble,
130 UnmockedCollaborator,
132 UntypedMock,
134 NoFirstPartyMock,
136}
137
138impl Rule {
139 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 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct Exemption {
183 pub path: String,
185 pub rules: Vec<Rule>,
187 pub reason: String,
189}
190
191pub 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 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 pub fn rust_exemptions(&self) -> &[Exemption] {
228 self.rust.as_ref().map_or(&[], |c| &c.exempt)
229 }
230
231 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
261pub 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 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 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 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}