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;
9
10use std::path::{Path, PathBuf};
11
12use clap::{Parser, Subcommand};
13
14#[derive(Parser, Debug)]
15#[command(
16 name = "testing-conventions",
17 version,
18 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
19 long_about = None,
20)]
21pub struct Cli {
22 #[command(subcommand)]
23 command: Option<Command>,
24}
25
26#[derive(Subcommand, Debug)]
27enum Command {
28 Check,
30 Unit {
32 #[command(subcommand)]
33 rule: UnitRule,
34 },
35 Integration {
37 #[command(subcommand)]
38 rule: IntegrationRule,
39 },
40 Packaging {
42 path: PathBuf,
44 #[arg(long, value_enum)]
46 language: colocated_test::Language,
47 },
48}
49
50#[derive(Subcommand, Debug)]
52enum UnitRule {
53 ColocatedTest {
55 path: PathBuf,
57 #[arg(long, value_enum)]
59 language: colocated_test::Language,
60 #[arg(long, default_value = "testing-conventions.toml")]
63 config: PathBuf,
64 },
65 Coverage {
67 path: PathBuf,
69 #[arg(long, value_enum)]
71 language: colocated_test::Language,
72 #[arg(long, default_value = "testing-conventions.toml")]
77 config: PathBuf,
78 },
79 Isolation {
81 path: PathBuf,
83 #[arg(long, value_enum)]
85 language: isolation::Language,
86 },
87}
88
89#[derive(Subcommand, Debug)]
92enum IntegrationRule {
93 Lint {
95 path: PathBuf,
97 #[arg(long, value_enum)]
99 language: colocated_test::Language,
100 #[arg(long, default_value = "testing-conventions.toml")]
103 config: PathBuf,
104 },
105}
106
107pub fn run<I, T>(args: I) -> anyhow::Result<i32>
108where
109 I: IntoIterator<Item = T>,
110 T: Into<std::ffi::OsString> + Clone,
111{
112 let cli = Cli::try_parse_from(args)?;
113 match cli.command {
114 Some(Command::Check) | None => Ok(0),
118 Some(Command::Unit { rule }) => match rule {
119 UnitRule::ColocatedTest {
120 path,
121 language,
122 config,
123 } => run_unit_colocated_test(&path, language, &config),
124 UnitRule::Coverage {
125 path,
126 language,
127 config,
128 } => run_unit_coverage(&path, language, &config),
129 UnitRule::Isolation { path, language } => run_unit_isolation(&path, language),
130 },
131 Some(Command::Integration { rule }) => match rule {
132 IntegrationRule::Lint {
133 path,
134 language,
135 config,
136 } => run_integration_lint(&path, language, &config),
137 },
138 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
139 }
140}
141
142fn run_unit_colocated_test(
148 root: &Path,
149 language: colocated_test::Language,
150 config_path: &Path,
151) -> anyhow::Result<i32> {
152 let exempt = colocated_test_exemptions(root, language, config_path)?;
153 let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
154 if orphans.is_empty() {
155 return Ok(0);
156 }
157 for orphan in &orphans {
158 eprintln!("missing colocated unit test: {}", orphan.display());
159 }
160 eprintln!(
161 "error: {} source file(s) missing a colocated unit test \
162 (add a colocated test, or an `exempt` entry with a reason)",
163 orphans.len()
164 );
165 Ok(1)
166}
167
168fn colocated_test_exemptions(
172 root: &Path,
173 language: colocated_test::Language,
174 config_path: &Path,
175) -> anyhow::Result<std::collections::BTreeSet<String>> {
176 if !config_path.exists() {
177 return Ok(std::collections::BTreeSet::new());
178 }
179 let config = config::load_config(config_path)?;
180 config::resolve_exempt(
181 root,
182 config.exemptions(language),
183 config::Rule::ColocatedTest,
184 )
185}
186
187fn run_unit_coverage(
198 root: &Path,
199 language: colocated_test::Language,
200 config_path: &Path,
201) -> anyhow::Result<i32> {
202 let config = if config_path.exists() {
203 config::load_config(config_path)?
204 } else {
205 config::Config::default()
206 };
207 let outcome = match language {
208 colocated_test::Language::Python => {
209 let python = config.python.unwrap_or_default();
210 let coverage = python.coverage.unwrap_or_default();
211 let thresholds = coverage::Thresholds {
212 fail_under: coverage.fail_under,
213 branch: coverage.branch,
214 };
215 let omit: Vec<String> =
216 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
217 .into_iter()
218 .collect();
219 coverage::measure(root, thresholds, &omit)?
220 }
221 colocated_test::Language::TypeScript => {
222 let typescript = config.typescript.unwrap_or_default();
223 let coverage = typescript.coverage.unwrap_or_default();
224 let thresholds = coverage::TypeScriptThresholds {
225 lines: coverage.lines,
226 branches: coverage.branches,
227 functions: coverage.functions,
228 statements: coverage.statements,
229 };
230 let exclude: Vec<String> =
231 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
232 .into_iter()
233 .collect();
234 coverage::measure_typescript(root, thresholds, &exclude)?
235 }
236 };
237 match outcome {
238 coverage::Outcome::Pass => Ok(0),
239 coverage::Outcome::Fail(reason) => {
240 eprintln!("error: coverage check failed — {reason}");
241 Ok(1)
242 }
243 }
244}
245
246fn run_unit_isolation(root: &Path, language: isolation::Language) -> anyhow::Result<i32> {
250 match language {
251 isolation::Language::Rust => {}
253 }
254 let violations = isolation::find_violations(root)?;
255 if violations.is_empty() {
256 return Ok(0);
257 }
258 for v in &violations {
259 eprintln!(
260 "{}:{}: {} — {}",
261 v.file.display(),
262 v.line,
263 v.rule,
264 v.message
265 );
266 }
267 eprintln!("error: {} isolation violation(s)", violations.len());
268 Ok(1)
269}
270
271fn run_integration_lint(
275 root: &Path,
276 language: colocated_test::Language,
277 config_path: &Path,
278) -> anyhow::Result<i32> {
279 let waived = lint_waivers(root, language, config_path)?;
280 let raw = match language {
281 colocated_test::Language::Python => lint::find_violations(root)?,
282 colocated_test::Language::TypeScript => ts::find_integration_violations(root)?,
283 };
284 let violations: Vec<lint::Violation> = raw
285 .into_iter()
286 .filter(|v| !is_waived(v, root, &waived))
287 .collect();
288 if violations.is_empty() {
289 return Ok(0);
290 }
291 for v in &violations {
292 eprintln!(
293 "{}:{}: {} — {}",
294 v.file.display(),
295 v.line,
296 v.rule,
297 v.message
298 );
299 }
300 eprintln!("error: {} lint violation(s)", violations.len());
301 Ok(1)
302}
303
304fn lint_waivers(
308 root: &Path,
309 language: colocated_test::Language,
310 config_path: &Path,
311) -> anyhow::Result<std::collections::BTreeSet<String>> {
312 if !config_path.exists() {
313 return Ok(std::collections::BTreeSet::new());
314 }
315 let config = config::load_config(config_path)?;
316 config::resolve_exempt(
317 root,
318 config.exemptions(language),
319 config::Rule::NoConstantPatch,
320 )
321}
322
323fn is_waived(
325 violation: &lint::Violation,
326 root: &Path,
327 waived: &std::collections::BTreeSet<String>,
328) -> bool {
329 violation.rule == "no-constant-patch"
330 && violation
331 .file
332 .strip_prefix(root)
333 .ok()
334 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
335 .is_some_and(|rel| waived.contains(&rel))
336}
337
338fn run_packaging(root: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
346 let globs = match language {
347 colocated_test::Language::Python => vec!["*_test.py".to_string()],
348 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
349 };
350 let offenders = packaging::scan(root, &globs)?;
351 if offenders.is_empty() {
352 return Ok(0);
353 }
354 for offender in &offenders {
355 eprintln!("test file in built artifact: {}", offender.display());
356 }
357 eprintln!(
358 "error: {} test file(s) present in the built artifact \
359 (they must be excluded from packaging)",
360 offenders.len()
361 );
362 Ok(1)
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368
369 #[test]
370 fn no_args_returns_ok_zero() {
371 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
372 }
373
374 #[test]
375 fn check_returns_ok_zero() {
376 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
377 }
378
379 #[test]
380 fn unknown_flag_errors() {
381 assert!(run(["testing-conventions", "--bogus"]).is_err());
382 }
383
384 #[test]
385 fn help_flag_returns_clap_display_help() {
386 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
387 let clap_err = err
388 .downcast_ref::<clap::Error>()
389 .expect("error should be a clap::Error");
390 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
391 }
392
393 #[test]
394 fn version_flag_returns_clap_display_version() {
395 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
396 let clap_err = err
397 .downcast_ref::<clap::Error>()
398 .expect("error should be a clap::Error");
399 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
400 }
401}