1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod isolation;
5pub mod lint;
6pub mod packaging;
7pub mod ts;
8pub mod violation;
9pub mod workflow;
10
11use std::path::{Path, PathBuf};
12
13use clap::{CommandFactory, Parser, Subcommand};
14
15#[derive(Parser, Debug)]
16#[command(
17 name = "testing-conventions",
18 version,
19 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
20 long_about = None,
21)]
22pub struct Cli {
23 #[command(subcommand)]
24 command: Option<Command>,
25}
26
27#[derive(Subcommand, Debug)]
28enum Command {
29 Check,
31 Unit {
33 #[command(subcommand)]
34 rule: UnitRule,
35 },
36 Integration {
38 #[command(subcommand)]
39 rule: IntegrationRule,
40 },
41 Packaging {
43 path: PathBuf,
45 #[arg(long, value_enum)]
47 language: colocated_test::Language,
48 },
49 Workflow {
52 path: PathBuf,
54 },
55}
56
57#[derive(Subcommand, Debug)]
59enum UnitRule {
60 ColocatedTest {
62 path: PathBuf,
64 #[arg(long, value_enum)]
66 language: colocated_test::Language,
67 #[arg(long, default_value = "testing-conventions.toml")]
70 config: PathBuf,
71 },
72 Coverage {
74 path: PathBuf,
76 #[arg(long, value_enum)]
78 language: colocated_test::Language,
79 #[arg(long, default_value = "testing-conventions.toml")]
84 config: PathBuf,
85 },
86 Isolation {
88 path: PathBuf,
90 #[arg(long, value_enum)]
92 language: isolation::Language,
93 },
94}
95
96#[derive(Subcommand, Debug)]
99enum IntegrationRule {
100 Lint {
102 path: PathBuf,
104 #[arg(long, value_enum)]
106 language: colocated_test::Language,
107 #[arg(long, default_value = "testing-conventions.toml")]
110 config: PathBuf,
111 },
112}
113
114pub fn run<I, T>(args: I) -> anyhow::Result<i32>
115where
116 I: IntoIterator<Item = T>,
117 T: Into<std::ffi::OsString> + Clone,
118{
119 let cli = Cli::try_parse_from(args)?;
120 match cli.command {
121 Some(Command::Check) | None => Ok(0),
125 Some(Command::Unit { rule }) => match rule {
126 UnitRule::ColocatedTest {
127 path,
128 language,
129 config,
130 } => run_unit_colocated_test(&path, language, &config),
131 UnitRule::Coverage {
132 path,
133 language,
134 config,
135 } => run_unit_coverage(&path, language, &config),
136 UnitRule::Isolation { path, language } => run_unit_isolation(&path, language),
137 },
138 Some(Command::Integration { rule }) => match rule {
139 IntegrationRule::Lint {
140 path,
141 language,
142 config,
143 } => run_integration_lint(&path, language, &config),
144 },
145 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
146 Some(Command::Workflow { path }) => run_workflow(&path),
147 }
148}
149
150pub fn command() -> clap::Command {
154 Cli::command()
155}
156
157fn run_unit_colocated_test(
163 root: &Path,
164 language: colocated_test::Language,
165 config_path: &Path,
166) -> anyhow::Result<i32> {
167 let exempt = colocated_test_exemptions(root, language, config_path)?;
168 let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
169 if orphans.is_empty() {
170 return Ok(0);
171 }
172 for orphan in &orphans {
173 eprintln!("missing colocated unit test: {}", orphan.display());
174 }
175 eprintln!(
176 "error: {} source file(s) missing a colocated unit test \
177 (add a colocated test, or an `exempt` entry with a reason)",
178 orphans.len()
179 );
180 Ok(1)
181}
182
183fn colocated_test_exemptions(
187 root: &Path,
188 language: colocated_test::Language,
189 config_path: &Path,
190) -> anyhow::Result<std::collections::BTreeSet<String>> {
191 if !config_path.exists() {
192 return Ok(std::collections::BTreeSet::new());
193 }
194 let config = config::load_config(config_path)?;
195 config::resolve_exempt(
196 root,
197 config.exemptions(language),
198 config::Rule::ColocatedTest,
199 )
200}
201
202fn run_unit_coverage(
213 root: &Path,
214 language: colocated_test::Language,
215 config_path: &Path,
216) -> anyhow::Result<i32> {
217 let config = if config_path.exists() {
218 config::load_config(config_path)?
219 } else {
220 config::Config::default()
221 };
222 let outcome = match language {
223 colocated_test::Language::Python => {
224 let python = config.python.unwrap_or_default();
225 let coverage = python.coverage.unwrap_or_default();
226 let thresholds = coverage::Thresholds {
227 fail_under: coverage.fail_under,
228 branch: coverage.branch,
229 };
230 let omit: Vec<String> =
231 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
232 .into_iter()
233 .collect();
234 coverage::measure(root, thresholds, &omit)?
235 }
236 colocated_test::Language::TypeScript => {
237 let typescript = config.typescript.unwrap_or_default();
238 let coverage = typescript.coverage.unwrap_or_default();
239 let thresholds = coverage::TypeScriptThresholds {
240 lines: coverage.lines,
241 branches: coverage.branches,
242 functions: coverage.functions,
243 statements: coverage.statements,
244 };
245 let exclude: Vec<String> =
246 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
247 .into_iter()
248 .collect();
249 coverage::measure_typescript(root, thresholds, &exclude)?
250 }
251 };
252 match outcome {
253 coverage::Outcome::Pass => Ok(0),
254 coverage::Outcome::Fail(reason) => {
255 eprintln!("error: coverage check failed — {reason}");
256 Ok(1)
257 }
258 }
259}
260
261fn run_unit_isolation(root: &Path, language: isolation::Language) -> anyhow::Result<i32> {
265 let violations = match language {
266 isolation::Language::Rust => isolation::find_violations(root)?,
267 isolation::Language::TypeScript => ts::find_unit_violations(root)?,
268 };
269 if violations.is_empty() {
270 return Ok(0);
271 }
272 for v in &violations {
273 eprintln!(
274 "{}:{}: {} — {}",
275 v.file.display(),
276 v.line,
277 v.rule,
278 v.message
279 );
280 }
281 eprintln!("error: {} isolation violation(s)", violations.len());
282 Ok(1)
283}
284
285fn run_integration_lint(
289 root: &Path,
290 language: colocated_test::Language,
291 config_path: &Path,
292) -> anyhow::Result<i32> {
293 let waived = lint_waivers(root, language, config_path)?;
294 let raw = match language {
295 colocated_test::Language::Python => lint::find_violations(root)?,
296 colocated_test::Language::TypeScript => ts::find_integration_violations(root)?,
297 };
298 let violations: Vec<lint::Violation> = raw
299 .into_iter()
300 .filter(|v| !is_waived(v, root, &waived))
301 .collect();
302 if violations.is_empty() {
303 return Ok(0);
304 }
305 for v in &violations {
306 eprintln!(
307 "{}:{}: {} — {}",
308 v.file.display(),
309 v.line,
310 v.rule,
311 v.message
312 );
313 }
314 eprintln!("error: {} lint violation(s)", violations.len());
315 Ok(1)
316}
317
318fn lint_waivers(
322 root: &Path,
323 language: colocated_test::Language,
324 config_path: &Path,
325) -> anyhow::Result<std::collections::BTreeSet<String>> {
326 if !config_path.exists() {
327 return Ok(std::collections::BTreeSet::new());
328 }
329 let config = config::load_config(config_path)?;
330 config::resolve_exempt(
331 root,
332 config.exemptions(language),
333 config::Rule::NoConstantPatch,
334 )
335}
336
337fn is_waived(
339 violation: &lint::Violation,
340 root: &Path,
341 waived: &std::collections::BTreeSet<String>,
342) -> bool {
343 violation.rule == "no-constant-patch"
344 && violation
345 .file
346 .strip_prefix(root)
347 .ok()
348 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
349 .is_some_and(|rel| waived.contains(&rel))
350}
351
352fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
361 let globs = match language {
362 colocated_test::Language::Python => vec!["*_test.py".to_string()],
363 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
364 };
365 let offenders = packaging::inspect(artifact, &globs)?;
366 if offenders.is_empty() {
367 return Ok(0);
368 }
369 for offender in &offenders {
370 eprintln!("test file in built artifact: {}", offender.display());
371 }
372 eprintln!(
373 "error: {} test file(s) present in the built artifact \
374 (they must be excluded from packaging)",
375 offenders.len()
376 );
377 Ok(1)
378}
379
380fn run_workflow(path: &Path) -> anyhow::Result<i32> {
385 let violations = workflow::check(path, &command())?;
386 if violations.is_empty() {
387 return Ok(0);
388 }
389 for v in &violations {
390 eprintln!(
391 "{}:{}: {} — {}",
392 v.file.display(),
393 v.line,
394 v.rule,
395 v.message
396 );
397 }
398 eprintln!(
399 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
400 violations.len()
401 );
402 Ok(1)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn no_args_returns_ok_zero() {
411 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
412 }
413
414 #[test]
415 fn check_returns_ok_zero() {
416 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
417 }
418
419 #[test]
420 fn unknown_flag_errors() {
421 assert!(run(["testing-conventions", "--bogus"]).is_err());
422 }
423
424 #[test]
425 fn help_flag_returns_clap_display_help() {
426 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
427 let clap_err = err
428 .downcast_ref::<clap::Error>()
429 .expect("error should be a clap::Error");
430 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
431 }
432
433 #[test]
434 fn version_flag_returns_clap_display_version() {
435 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
436 let clap_err = err
437 .downcast_ref::<clap::Error>()
438 .expect("error should be a clap::Error");
439 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
440 }
441}