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 CoChange,
125 NoMonkeypatch,
127 NoInlinePatch,
129 NoEnvironMutation,
131 NoConstantPatch,
133 NoFirstPartyPatch,
135 NoOutOfModuleCall,
137 NoOutOfModuleImport,
139 NoFirstPartyDouble,
141 UnmockedCollaborator,
143 UntypedMock,
145 NoFirstPartyMock,
147}
148
149impl Rule {
150 pub fn id(self) -> &'static str {
153 match self {
154 Rule::ColocatedTest => "colocated-test",
155 Rule::Coverage => "coverage",
156 Rule::CoChange => "co-change",
157 Rule::NoMonkeypatch => "no-monkeypatch",
158 Rule::NoInlinePatch => "no-inline-patch",
159 Rule::NoEnvironMutation => "no-environ-mutation",
160 Rule::NoConstantPatch => "no-constant-patch",
161 Rule::NoFirstPartyPatch => "no-first-party-patch",
162 Rule::NoOutOfModuleCall => "no-out-of-module-call",
163 Rule::NoOutOfModuleImport => "no-out-of-module-import",
164 Rule::NoFirstPartyDouble => "no-first-party-double",
165 Rule::UnmockedCollaborator => "unmocked-collaborator",
166 Rule::UntypedMock => "untyped-mock",
167 Rule::NoFirstPartyMock => "no-first-party-mock",
168 }
169 }
170
171 pub fn from_id(id: &str) -> Option<Rule> {
173 [
174 Rule::ColocatedTest,
175 Rule::Coverage,
176 Rule::CoChange,
177 Rule::NoMonkeypatch,
178 Rule::NoInlinePatch,
179 Rule::NoEnvironMutation,
180 Rule::NoConstantPatch,
181 Rule::NoFirstPartyPatch,
182 Rule::NoOutOfModuleCall,
183 Rule::NoOutOfModuleImport,
184 Rule::NoFirstPartyDouble,
185 Rule::UnmockedCollaborator,
186 Rule::UntypedMock,
187 Rule::NoFirstPartyMock,
188 ]
189 .into_iter()
190 .find(|rule| rule.id() == id)
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct Exemption {
204 pub path: String,
206 pub rules: Vec<Rule>,
208 pub reason: String,
210}
211
212pub fn load_config(path: impl AsRef<Path>) -> Result<Config> {
220 let path = path.as_ref();
221 let contents = std::fs::read_to_string(path)
222 .with_context(|| format!("reading config file `{}`", path.display()))?;
223 let config: Config = toml::from_str(&contents)
224 .with_context(|| format!("parsing config file `{}`", path.display()))?;
225 config
226 .validate()
227 .with_context(|| format!("validating config file `{}`", path.display()))?;
228 Ok(config)
229}
230
231impl Config {
232 pub fn exemptions(&self, language: crate::colocated_test::Language) -> &[Exemption] {
234 match language {
235 crate::colocated_test::Language::Python => {
236 self.python.as_ref().map_or(&[], |c| &c.exempt)
237 }
238 crate::colocated_test::Language::TypeScript => {
239 self.typescript.as_ref().map_or(&[], |c| &c.exempt)
240 }
241 crate::colocated_test::Language::Rust => self.rust_exemptions(),
242 }
243 }
244
245 pub fn rust_exemptions(&self) -> &[Exemption] {
249 self.rust.as_ref().map_or(&[], |c| &c.exempt)
250 }
251
252 fn validate(&self) -> Result<()> {
255 let tables = [
256 ("python", self.python.as_ref().map(|c| &c.exempt)),
257 ("typescript", self.typescript.as_ref().map(|c| &c.exempt)),
258 ("rust", self.rust.as_ref().map(|c| &c.exempt)),
259 ];
260 for (table, exempt) in tables.into_iter().filter_map(|(t, e)| e.map(|e| (t, e))) {
261 for entry in exempt {
262 if entry.rules.is_empty() {
263 bail!(
264 "[{table}].exempt entry for `{}` names no rules — set \
265 `rules = [\"colocated-test\"]` and/or `\"coverage\"`",
266 entry.path
267 );
268 }
269 if entry.reason.trim().is_empty() {
270 bail!(
271 "[{table}].exempt entry for `{}` has an empty reason — \
272 every exemption must say why the file is exempt",
273 entry.path
274 );
275 }
276 }
277 }
278 Ok(())
279 }
280}
281
282pub fn resolve_exempt(
290 root: &Path,
291 exemptions: &[Exemption],
292 rule: Rule,
293) -> Result<BTreeSet<String>> {
294 let mut paths = BTreeSet::new();
295 for entry in exemptions {
296 if !entry.rules.contains(&rule) {
297 continue;
298 }
299 if !root.join(&entry.path).is_file() {
300 bail!(
301 "exempt entry `{}` matches no file under `{}` — remove the stale \
302 entry or fix the path",
303 entry.path,
304 root.display()
305 );
306 }
307 paths.insert(entry.path.replace('\\', "/"));
308 }
309 Ok(paths)
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::sync::atomic::{AtomicU64, Ordering};
316
317 fn parse(toml_src: &str) -> Result<Config> {
318 let config: Config = toml::from_str(toml_src)?;
319 config.validate()?;
320 Ok(config)
321 }
322
323 #[test]
324 fn an_exemption_with_no_rules_is_rejected() {
325 let err = parse(
326 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
327 [[python.exempt]]\npath = \"cli.py\"\nrules = []\nreason = \"shim\"\n",
328 )
329 .unwrap_err();
330 assert!(err.to_string().contains("names no rules"), "got: {err}");
331 }
332
333 #[test]
334 fn an_exemption_with_an_empty_reason_is_rejected() {
335 let err = parse(
336 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
337 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\"]\nreason = \" \"\n",
338 )
339 .unwrap_err();
340 assert!(err.to_string().contains("empty reason"), "got: {err}");
341 }
342
343 #[test]
344 fn an_unknown_rule_is_rejected() {
345 assert!(parse(
346 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
347 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"packaging\"]\nreason = \"x\"\n",
348 )
349 .is_err());
350 }
351
352 #[test]
353 fn default_python_coverage_is_the_reasonable_floor() {
354 assert_eq!(
357 PythonCoverage::default(),
358 PythonCoverage {
359 branch: true,
360 fail_under: 85,
361 }
362 );
363 }
364
365 #[test]
366 fn default_typescript_coverage_matches_internals() {
367 assert_eq!(
370 TypeScriptCoverage::default(),
371 TypeScriptCoverage {
372 lines: 80,
373 branches: 75,
374 functions: 80,
375 statements: 80,
376 }
377 );
378 }
379
380 #[test]
381 fn a_valid_exemption_parses() {
382 let config = parse(
383 "[python]\ncoverage = { branch = true, fail_under = 100 }\n\
384 [[python.exempt]]\npath = \"cli.py\"\nrules = [\"colocated-test\", \"coverage\"]\n\
385 reason = \"thin launcher\"\n",
386 )
387 .unwrap();
388 let exempt = &config.python.unwrap().exempt;
389 assert_eq!(exempt.len(), 1);
390 assert_eq!(exempt[0].rules, vec![Rule::ColocatedTest, Rule::Coverage]);
391 }
392
393 #[test]
394 fn exemptions_reads_the_rust_table() {
395 let config = parse(
396 "[[rust.exempt]]\npath = \"build.rs\"\nrules = [\"no-out-of-module-call\"]\n\
397 reason = \"generated\"\n",
398 )
399 .unwrap();
400 let rust = config.exemptions(crate::colocated_test::Language::Rust);
401 assert_eq!(rust.len(), 1);
402 assert_eq!(rust[0].path, "build.rs");
403 }
404
405 struct TempTree(std::path::PathBuf);
407
408 impl TempTree {
409 fn new(files: &[&str]) -> Self {
410 static COUNTER: AtomicU64 = AtomicU64::new(0);
411 let root = std::env::temp_dir().join(format!(
412 "tc-exempt-{}-{}",
413 std::process::id(),
414 COUNTER.fetch_add(1, Ordering::Relaxed),
415 ));
416 for rel in files {
417 let path = root.join(rel);
418 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
419 std::fs::write(path, "x = 1\n").unwrap();
420 }
421 TempTree(root)
422 }
423 }
424
425 impl Drop for TempTree {
426 fn drop(&mut self) {
427 let _ = std::fs::remove_dir_all(&self.0);
428 }
429 }
430
431 fn exemption(path: &str, rules: &[Rule]) -> Exemption {
432 Exemption {
433 path: path.to_string(),
434 rules: rules.to_vec(),
435 reason: "deliberate".to_string(),
436 }
437 }
438
439 #[test]
440 fn resolve_keeps_only_the_requested_rule_and_returns_sorted_paths() {
441 let tree = TempTree::new(&["cli.py", "pkg/gen.py", "loc_only.py"]);
442 let exemptions = [
443 exemption("cli.py", &[Rule::ColocatedTest, Rule::Coverage]),
444 exemption("pkg/gen.py", &[Rule::Coverage]),
445 exemption("loc_only.py", &[Rule::ColocatedTest]),
446 ];
447 let coverage = resolve_exempt(&tree.0, &exemptions, Rule::Coverage).unwrap();
448 assert_eq!(
449 coverage.into_iter().collect::<Vec<_>>(),
450 vec!["cli.py".to_string(), "pkg/gen.py".to_string()],
451 );
452 let colocated_test = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap();
453 assert_eq!(
454 colocated_test.into_iter().collect::<Vec<_>>(),
455 vec!["cli.py".to_string(), "loc_only.py".to_string()],
456 );
457 }
458
459 #[test]
460 fn a_stale_exempt_path_is_an_error() {
461 let tree = TempTree::new(&["cli.py"]);
462 let exemptions = [exemption("ghost.py", &[Rule::ColocatedTest])];
463 let err = resolve_exempt(&tree.0, &exemptions, Rule::ColocatedTest).unwrap_err();
464 assert!(err.to_string().contains("matches no file"), "got: {err}");
465 }
466}