1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod e2e;
5pub mod isolation;
6pub mod lint;
7pub mod packaging;
8pub mod ts;
9pub mod violation;
10pub mod workflow;
11
12use std::path::{Path, PathBuf};
13
14use clap::{CommandFactory, Parser, Subcommand};
15
16#[derive(Parser, Debug)]
17#[command(
18 name = "testing-conventions",
19 version,
20 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
21 long_about = None,
22)]
23pub struct Cli {
24 #[command(subcommand)]
25 command: Option<Command>,
26}
27
28#[derive(Subcommand, Debug)]
29enum Command {
30 Check,
32 Unit {
34 #[command(subcommand)]
35 rule: UnitRule,
36 },
37 Integration {
39 #[command(subcommand)]
40 rule: IntegrationRule,
41 },
42 Packaging {
44 path: PathBuf,
46 #[arg(long, value_enum)]
48 language: colocated_test::Language,
49 },
50 Workflow {
53 path: PathBuf,
55 },
56 E2e {
58 #[command(subcommand)]
59 command: E2eCommand,
60 },
61}
62
63#[derive(Subcommand, Debug)]
65enum UnitRule {
66 ColocatedTest {
68 path: PathBuf,
70 #[arg(long, value_enum)]
72 language: colocated_test::Language,
73 #[arg(long, default_value = "testing-conventions.toml")]
76 config: PathBuf,
77 },
78 Coverage {
80 path: PathBuf,
82 #[arg(long, value_enum)]
84 language: colocated_test::Language,
85 #[arg(long, default_value = "testing-conventions.toml")]
90 config: PathBuf,
91 },
92 Isolation {
94 path: PathBuf,
96 #[arg(long, value_enum)]
98 language: isolation::Language,
99 #[arg(long, default_value = "testing-conventions.toml")]
102 config: PathBuf,
103 },
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
110pub enum IntegrationLintLanguage {
111 #[value(name = "python")]
113 Python,
114 #[value(name = "typescript")]
116 TypeScript,
117 #[value(name = "rust")]
119 Rust,
120}
121
122#[derive(Subcommand, Debug)]
125enum IntegrationRule {
126 Lint {
128 path: PathBuf,
130 #[arg(long, value_enum)]
132 language: IntegrationLintLanguage,
133 #[arg(long, default_value = "testing-conventions.toml")]
136 config: PathBuf,
137 },
138}
139
140#[derive(Subcommand, Debug)]
143enum E2eCommand {
144 Attest {
146 command: String,
148 },
149}
150
151pub fn run<I, T>(args: I) -> anyhow::Result<i32>
152where
153 I: IntoIterator<Item = T>,
154 T: Into<std::ffi::OsString> + Clone,
155{
156 let cli = Cli::try_parse_from(args)?;
157 match cli.command {
158 Some(Command::Check) | None => Ok(0),
162 Some(Command::Unit { rule }) => match rule {
163 UnitRule::ColocatedTest {
164 path,
165 language,
166 config,
167 } => run_unit_colocated_test(&path, language, &config),
168 UnitRule::Coverage {
169 path,
170 language,
171 config,
172 } => run_unit_coverage(&path, language, &config),
173 UnitRule::Isolation {
174 path,
175 language,
176 config,
177 } => run_unit_isolation(&path, language, &config),
178 },
179 Some(Command::Integration { rule }) => match rule {
180 IntegrationRule::Lint {
181 path,
182 language,
183 config,
184 } => run_integration_lint(&path, language, &config),
185 },
186 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
187 Some(Command::Workflow { path }) => run_workflow(&path),
188 Some(Command::E2e { command }) => match command {
189 E2eCommand::Attest { command } => run_e2e_attest(&command),
190 },
191 }
192}
193
194pub fn command() -> clap::Command {
198 Cli::command()
199}
200
201fn run_unit_colocated_test(
207 root: &Path,
208 language: colocated_test::Language,
209 config_path: &Path,
210) -> anyhow::Result<i32> {
211 if language == colocated_test::Language::Rust {
212 anyhow::bail!(
213 "`unit colocated-test` checks file-based colocation (Python/TypeScript); \
214 Rust units are inline `#[cfg(test)]` modules — see `unit isolation`"
215 );
216 }
217 let exempt = colocated_test_exemptions(root, language, config_path)?;
218 let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
219 if orphans.is_empty() {
220 return Ok(0);
221 }
222 for orphan in &orphans {
223 eprintln!("missing colocated unit test: {}", orphan.display());
224 }
225 eprintln!(
226 "error: {} source file(s) missing a colocated unit test \
227 (add a colocated test, or an `exempt` entry with a reason)",
228 orphans.len()
229 );
230 Ok(1)
231}
232
233fn colocated_test_exemptions(
237 root: &Path,
238 language: colocated_test::Language,
239 config_path: &Path,
240) -> anyhow::Result<std::collections::BTreeSet<String>> {
241 if !config_path.exists() {
242 return Ok(std::collections::BTreeSet::new());
243 }
244 let config = config::load_config(config_path)?;
245 config::resolve_exempt(
246 root,
247 config.exemptions(language),
248 config::Rule::ColocatedTest,
249 )
250}
251
252fn run_unit_coverage(
263 root: &Path,
264 language: colocated_test::Language,
265 config_path: &Path,
266) -> anyhow::Result<i32> {
267 let config = if config_path.exists() {
268 config::load_config(config_path)?
269 } else {
270 config::Config::default()
271 };
272 let outcome = match language {
273 colocated_test::Language::Python => {
274 let python = config.python.unwrap_or_default();
275 let coverage = python.coverage.unwrap_or_default();
276 let thresholds = coverage::Thresholds {
277 fail_under: coverage.fail_under,
278 branch: coverage.branch,
279 };
280 let omit: Vec<String> =
281 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
282 .into_iter()
283 .collect();
284 coverage::measure(root, thresholds, &omit)?
285 }
286 colocated_test::Language::TypeScript => {
287 let typescript = config.typescript.unwrap_or_default();
288 let coverage = typescript.coverage.unwrap_or_default();
289 let thresholds = coverage::TypeScriptThresholds {
290 lines: coverage.lines,
291 branches: coverage.branches,
292 functions: coverage.functions,
293 statements: coverage.statements,
294 };
295 let exclude: Vec<String> =
296 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
297 .into_iter()
298 .collect();
299 coverage::measure_typescript(root, thresholds, &exclude)?
300 }
301 colocated_test::Language::Rust => anyhow::bail!(
302 "`unit coverage` supports `--language python` / `typescript`; \
303 Rust coverage (`cargo llvm-cov`) is a separate item"
304 ),
305 };
306 match outcome {
307 coverage::Outcome::Pass => Ok(0),
308 coverage::Outcome::Fail(reason) => {
309 eprintln!("error: coverage check failed — {reason}");
310 Ok(1)
311 }
312 }
313}
314
315fn run_unit_isolation(
319 root: &Path,
320 language: isolation::Language,
321 config_path: &Path,
322) -> anyhow::Result<i32> {
323 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
324 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
325 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
326 c.exemptions(colocated_test::Language::TypeScript)
327 }),
328 };
329 let violations = apply_waivers(raw, root, config_path, select)?;
330 if violations.is_empty() {
331 return Ok(0);
332 }
333 for v in &violations {
334 eprintln!(
335 "{}:{}: {} — {}",
336 v.file.display(),
337 v.line,
338 v.rule,
339 v.message
340 );
341 }
342 eprintln!("error: {} isolation violation(s)", violations.len());
343 Ok(1)
344}
345
346fn run_integration_lint(
350 root: &Path,
351 language: IntegrationLintLanguage,
352 config_path: &Path,
353) -> anyhow::Result<i32> {
354 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
355 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
356 c.exemptions(colocated_test::Language::Python)
357 }),
358 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
359 c.exemptions(colocated_test::Language::TypeScript)
360 }),
361 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
362 c.rust_exemptions()
363 }),
364 };
365 let violations = apply_waivers(raw, root, config_path, select)?;
366 if violations.is_empty() {
367 return Ok(0);
368 }
369 for v in &violations {
370 eprintln!(
371 "{}:{}: {} — {}",
372 v.file.display(),
373 v.line,
374 v.rule,
375 v.message
376 );
377 }
378 eprintln!("error: {} lint violation(s)", violations.len());
379 Ok(1)
380}
381
382type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
385
386fn apply_waivers(
393 violations: Vec<lint::Violation>,
394 root: &Path,
395 config_path: &Path,
396 exemptions: ExemptSelect,
397) -> anyhow::Result<Vec<lint::Violation>> {
398 use std::collections::hash_map::Entry;
399
400 if !config_path.exists() {
401 return Ok(violations);
402 }
403 let config = config::load_config(config_path)?;
404 let exempt = exemptions(&config);
405 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
407 std::collections::HashMap::new();
408 let mut kept = Vec::new();
409 for violation in violations {
410 let waived = match config::Rule::from_id(violation.rule) {
411 Some(rule) => {
412 let exempt_paths = match resolved.entry(rule) {
413 Entry::Occupied(entry) => entry.into_mut(),
414 Entry::Vacant(entry) => {
415 entry.insert(config::resolve_exempt(root, exempt, rule)?)
416 }
417 };
418 violation
419 .file
420 .strip_prefix(root)
421 .ok()
422 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
423 .is_some_and(|rel| exempt_paths.contains(&rel))
424 }
425 None => false,
426 };
427 if !waived {
428 kept.push(violation);
429 }
430 }
431 Ok(kept)
432}
433
434fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
443 let globs = match language {
444 colocated_test::Language::Python => vec!["*_test.py".to_string()],
445 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
446 colocated_test::Language::Rust => vec!["tests/".to_string()],
449 };
450 let offenders = packaging::inspect(artifact, &globs)?;
451 if offenders.is_empty() {
452 return Ok(0);
453 }
454 for offender in &offenders {
455 eprintln!("test file in built artifact: {}", offender.display());
456 }
457 eprintln!(
458 "error: {} test file(s) present in the built artifact \
459 (they must be excluded from packaging)",
460 offenders.len()
461 );
462 Ok(1)
463}
464
465fn run_workflow(path: &Path) -> anyhow::Result<i32> {
470 let violations = workflow::check(path, &command())?;
471 if violations.is_empty() {
472 return Ok(0);
473 }
474 for v in &violations {
475 eprintln!(
476 "{}:{}: {} — {}",
477 v.file.display(),
478 v.line,
479 v.rule,
480 v.message
481 );
482 }
483 eprintln!(
484 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
485 violations.len()
486 );
487 Ok(1)
488}
489
490fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
494 let repo = std::env::current_dir()?;
495 let attestation = e2e::attest(&repo, command)?;
496 println!(
497 "e2e attestation recorded for commit {} (command exited {})",
498 attestation.commit, attestation.exit_code
499 );
500 Ok(0)
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn no_args_returns_ok_zero() {
509 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
510 }
511
512 #[test]
513 fn check_returns_ok_zero() {
514 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
515 }
516
517 #[test]
518 fn unknown_flag_errors() {
519 assert!(run(["testing-conventions", "--bogus"]).is_err());
520 }
521
522 #[test]
523 fn help_flag_returns_clap_display_help() {
524 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
525 let clap_err = err
526 .downcast_ref::<clap::Error>()
527 .expect("error should be a clap::Error");
528 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
529 }
530
531 #[test]
532 fn version_flag_returns_clap_display_version() {
533 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
534 let clap_err = err
535 .downcast_ref::<clap::Error>()
536 .expect("error should be a clap::Error");
537 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
538 }
539
540 #[test]
541 fn unit_colocated_test_rejects_rust() {
542 let err = run([
543 "testing-conventions",
544 "unit",
545 "colocated-test",
546 "pkg",
547 "--language",
548 "rust",
549 ])
550 .unwrap_err();
551 assert!(err.to_string().contains("inline"), "got: {err}");
552 }
553
554 #[test]
555 fn unit_coverage_rejects_rust() {
556 let err = run([
559 "testing-conventions",
560 "unit",
561 "coverage",
562 "pkg",
563 "--language",
564 "rust",
565 ])
566 .unwrap_err();
567 assert!(err.to_string().contains("separate item"), "got: {err}");
568 }
569}