1pub mod co_change;
2pub mod colocated_test;
3pub mod config;
4pub mod coverage;
5pub mod e2e;
6pub mod isolation;
7pub mod lint;
8pub mod packaging;
9pub mod patch_coverage;
10pub mod ts;
11pub mod violation;
12pub mod workflow;
13
14use std::path::{Path, PathBuf};
15
16use clap::{CommandFactory, Parser, Subcommand};
17
18#[derive(Parser, Debug)]
19#[command(
20 name = "testing-conventions",
21 version,
22 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
23 long_about = None,
24)]
25pub struct Cli {
26 #[command(subcommand)]
27 command: Option<Command>,
28}
29
30#[derive(Subcommand, Debug)]
31enum Command {
32 Check,
34 Unit {
36 #[command(subcommand)]
37 rule: UnitRule,
38 },
39 Integration {
41 #[command(subcommand)]
42 rule: IntegrationRule,
43 },
44 Packaging {
46 path: PathBuf,
48 #[arg(long, value_enum)]
50 language: colocated_test::Language,
51 },
52 Workflow {
55 path: PathBuf,
57 },
58 E2e {
60 #[command(subcommand)]
61 command: E2eCommand,
62 },
63}
64
65#[derive(Subcommand, Debug)]
67enum UnitRule {
68 ColocatedTest {
74 path: PathBuf,
76 #[arg(long, value_enum)]
78 language: colocated_test::Language,
79 #[arg(long)]
85 base: Option<String>,
86 #[arg(long, default_value = "testing-conventions.toml")]
89 config: PathBuf,
90 },
91 Coverage {
96 path: PathBuf,
98 #[arg(long, value_enum)]
100 language: colocated_test::Language,
101 #[arg(long)]
107 base: Option<String>,
108 #[arg(long, default_value = "testing-conventions.toml")]
113 config: PathBuf,
114 },
115 Lint {
117 path: PathBuf,
119 #[arg(long, value_enum)]
121 language: isolation::Language,
122 #[arg(long, default_value = "testing-conventions.toml")]
125 config: PathBuf,
126 },
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
133pub enum IntegrationLintLanguage {
134 #[value(name = "python")]
136 Python,
137 #[value(name = "typescript")]
139 TypeScript,
140 #[value(name = "rust")]
142 Rust,
143}
144
145#[derive(Subcommand, Debug)]
148enum IntegrationRule {
149 Lint {
151 path: PathBuf,
153 #[arg(long, value_enum)]
155 language: IntegrationLintLanguage,
156 #[arg(long, default_value = "testing-conventions.toml")]
159 config: PathBuf,
160 },
161}
162
163#[derive(Subcommand, Debug)]
166enum E2eCommand {
167 Attest {
169 command: String,
171 },
172 Verify,
174}
175
176pub fn run<I, T>(args: I) -> anyhow::Result<i32>
177where
178 I: IntoIterator<Item = T>,
179 T: Into<std::ffi::OsString> + Clone,
180{
181 let cli = Cli::try_parse_from(args)?;
182 match cli.command {
183 Some(Command::Check) | None => Ok(0),
187 Some(Command::Unit { rule }) => match rule {
188 UnitRule::ColocatedTest {
189 path,
190 language,
191 base,
192 config,
193 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
194 UnitRule::Coverage {
195 path,
196 language,
197 base,
198 config,
199 } => run_unit_coverage(&path, language, base.as_deref(), &config),
200 UnitRule::Lint {
201 path,
202 language,
203 config,
204 } => run_unit_lint(&path, language, &config),
205 },
206 Some(Command::Integration { rule }) => match rule {
207 IntegrationRule::Lint {
208 path,
209 language,
210 config,
211 } => run_integration_lint(&path, language, &config),
212 },
213 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
214 Some(Command::Workflow { path }) => run_workflow(&path),
215 Some(Command::E2e { command }) => match command {
216 E2eCommand::Attest { command } => run_e2e_attest(&command),
217 E2eCommand::Verify => run_e2e_verify(),
218 },
219 }
220}
221
222pub fn command() -> clap::Command {
226 Cli::command()
227}
228
229fn run_unit_colocated_test(
242 root: &Path,
243 language: colocated_test::Language,
244 base: Option<&str>,
245 config_path: &Path,
246) -> anyhow::Result<i32> {
247 if base.is_some() && language == colocated_test::Language::Rust {
250 anyhow::bail!(
251 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
252 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
253 );
254 }
255 let presence_clean = report_colocated_presence(root, language, config_path)?;
256 let co_change_clean = match base {
257 Some(base) => report_co_change(root, base, language, config_path)?,
258 None => true,
259 };
260 Ok(if presence_clean && co_change_clean {
261 0
262 } else {
263 1
264 })
265}
266
267fn report_colocated_presence(
273 root: &Path,
274 language: colocated_test::Language,
275 config_path: &Path,
276) -> anyhow::Result<bool> {
277 let exempt = colocated_test_exemptions(root, language, config_path)?;
278 let orphans = match language {
279 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
282 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
283 };
284 if orphans.is_empty() {
285 return Ok(true);
286 }
287 let (label, summary) = match language {
288 colocated_test::Language::Rust => (
289 "missing inline `#[cfg(test)]` tests",
290 "source file(s) with testable code but no inline `#[cfg(test)]` module \
291 (add an inline test module, or an `exempt` entry with a reason)",
292 ),
293 _ => (
294 "missing colocated unit test",
295 "source file(s) missing a colocated unit test \
296 (add a colocated test, or an `exempt` entry with a reason)",
297 ),
298 };
299 for orphan in &orphans {
300 eprintln!("{label}: {}", orphan.display());
301 }
302 eprintln!("error: {} {summary}", orphans.len());
303 Ok(false)
304}
305
306fn colocated_test_exemptions(
310 root: &Path,
311 language: colocated_test::Language,
312 config_path: &Path,
313) -> anyhow::Result<std::collections::BTreeSet<String>> {
314 if !config_path.exists() {
315 return Ok(std::collections::BTreeSet::new());
316 }
317 let config = config::load_config(config_path)?;
318 config::resolve_exempt(
319 root,
320 config.exemptions(language),
321 config::Rule::ColocatedTest,
322 )
323}
324
325fn report_co_change(
335 root: &Path,
336 base: &str,
337 language: colocated_test::Language,
338 config_path: &Path,
339) -> anyhow::Result<bool> {
340 let exempt = co_change_exemptions(root, language, config_path)?;
341 let stale = co_change::stale_sources(root, base, language, &exempt)?;
342 if stale.is_empty() {
343 return Ok(true);
344 }
345 for source in &stale {
346 eprintln!(
347 "source changed without its colocated test: {}",
348 source.display()
349 );
350 }
351 eprintln!(
352 "error: {} source file(s) changed without their colocated test co-changing \
353 (update the test, or add an `exempt` entry with a reason)",
354 stale.len()
355 );
356 Ok(false)
357}
358
359fn co_change_exemptions(
363 root: &Path,
364 language: colocated_test::Language,
365 config_path: &Path,
366) -> anyhow::Result<std::collections::BTreeSet<String>> {
367 if !config_path.exists() {
368 return Ok(std::collections::BTreeSet::new());
369 }
370 let config = config::load_config(config_path)?;
371 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
372}
373
374fn run_unit_coverage(
392 root: &Path,
393 language: colocated_test::Language,
394 base: Option<&str>,
395 config_path: &Path,
396) -> anyhow::Result<i32> {
397 let config = if config_path.exists() {
398 config::load_config(config_path)?
399 } else {
400 config::Config::default()
401 };
402 let outcome = match language {
403 colocated_test::Language::Python => {
404 let python = config.python.unwrap_or_default();
405 let coverage = python.coverage.unwrap_or_default();
406 let thresholds = coverage::Thresholds {
407 fail_under: coverage.fail_under,
408 branch: coverage.branch,
409 };
410 let omit: Vec<String> =
411 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
412 .into_iter()
413 .collect();
414 match base {
415 Some(base) => patch_coverage::measure(root, base, thresholds, &omit)?,
416 None => coverage::measure(root, thresholds, &omit)?,
417 }
418 }
419 colocated_test::Language::TypeScript => {
420 let typescript = config.typescript.unwrap_or_default();
421 let coverage = typescript.coverage.unwrap_or_default();
422 let thresholds = coverage::TypeScriptThresholds {
423 lines: coverage.lines,
424 branches: coverage.branches,
425 functions: coverage.functions,
426 statements: coverage.statements,
427 };
428 let exclude: Vec<String> =
429 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
430 .into_iter()
431 .collect();
432 match base {
433 Some(base) => patch_coverage::measure_typescript(root, base, thresholds, &exclude)?,
434 None => coverage::measure_typescript(root, thresholds, &exclude)?,
435 }
436 }
437 colocated_test::Language::Rust => {
438 let rust = config.rust.unwrap_or_default();
439 let coverage = rust.coverage.ok_or_else(|| {
443 anyhow::anyhow!(
444 "Rust coverage needs a `[rust].coverage` table (regions / lines) in `{}` — \
445 there is no zero-config default floor for Rust yet",
446 config_path.display()
447 )
448 })?;
449 let thresholds = coverage::RustThresholds {
450 regions: coverage.regions,
451 lines: coverage.lines,
452 };
453 let ignore: Vec<String> =
454 config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
455 .into_iter()
456 .collect();
457 match base {
458 Some(base) => patch_coverage::measure_rust(root, base, thresholds, &ignore)?,
459 None => coverage::measure_rust(root, thresholds, &ignore)?,
460 }
461 }
462 };
463 match outcome {
464 coverage::Outcome::Pass => Ok(0),
465 coverage::Outcome::Fail(reason) => {
466 eprintln!("error: coverage check failed — {reason}");
467 Ok(1)
468 }
469 }
470}
471
472fn run_unit_lint(
477 root: &Path,
478 language: isolation::Language,
479 config_path: &Path,
480) -> anyhow::Result<i32> {
481 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
482 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
483 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
484 c.exemptions(colocated_test::Language::TypeScript)
485 }),
486 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
487 c.exemptions(colocated_test::Language::Python)
488 }),
489 };
490 let violations = apply_waivers(raw, root, config_path, select)?;
491 if violations.is_empty() {
492 return Ok(0);
493 }
494 for v in &violations {
495 eprintln!(
496 "{}:{}: {} — {}",
497 v.file.display(),
498 v.line,
499 v.rule,
500 v.message
501 );
502 }
503 eprintln!("error: {} isolation violation(s)", violations.len());
504 Ok(1)
505}
506
507fn run_integration_lint(
511 root: &Path,
512 language: IntegrationLintLanguage,
513 config_path: &Path,
514) -> anyhow::Result<i32> {
515 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
516 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
517 c.exemptions(colocated_test::Language::Python)
518 }),
519 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
520 c.exemptions(colocated_test::Language::TypeScript)
521 }),
522 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
523 c.rust_exemptions()
524 }),
525 };
526 let violations = apply_waivers(raw, root, config_path, select)?;
527 if violations.is_empty() {
528 return Ok(0);
529 }
530 for v in &violations {
531 eprintln!(
532 "{}:{}: {} — {}",
533 v.file.display(),
534 v.line,
535 v.rule,
536 v.message
537 );
538 }
539 eprintln!("error: {} lint violation(s)", violations.len());
540 Ok(1)
541}
542
543type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
546
547fn apply_waivers(
554 violations: Vec<lint::Violation>,
555 root: &Path,
556 config_path: &Path,
557 exemptions: ExemptSelect,
558) -> anyhow::Result<Vec<lint::Violation>> {
559 use std::collections::hash_map::Entry;
560
561 if !config_path.exists() {
562 return Ok(violations);
563 }
564 let config = config::load_config(config_path)?;
565 let exempt = exemptions(&config);
566 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
568 std::collections::HashMap::new();
569 let mut kept = Vec::new();
570 for violation in violations {
571 let waived = match config::Rule::from_id(violation.rule) {
572 Some(rule) => {
573 let exempt_paths = match resolved.entry(rule) {
574 Entry::Occupied(entry) => entry.into_mut(),
575 Entry::Vacant(entry) => {
576 entry.insert(config::resolve_exempt(root, exempt, rule)?)
577 }
578 };
579 violation
580 .file
581 .strip_prefix(root)
582 .ok()
583 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
584 .is_some_and(|rel| exempt_paths.contains(&rel))
585 }
586 None => false,
587 };
588 if !waived {
589 kept.push(violation);
590 }
591 }
592 Ok(kept)
593}
594
595fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
604 let globs = match language {
605 colocated_test::Language::Python => vec!["*_test.py".to_string()],
606 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
607 colocated_test::Language::Rust => vec!["tests/".to_string()],
610 };
611 let offenders = packaging::inspect(artifact, &globs)?;
612 if offenders.is_empty() {
613 return Ok(0);
614 }
615 for offender in &offenders {
616 eprintln!("test file in built artifact: {}", offender.display());
617 }
618 eprintln!(
619 "error: {} test file(s) present in the built artifact \
620 (they must be excluded from packaging)",
621 offenders.len()
622 );
623 Ok(1)
624}
625
626fn run_workflow(path: &Path) -> anyhow::Result<i32> {
631 let violations = workflow::check(path, &command())?;
632 if violations.is_empty() {
633 return Ok(0);
634 }
635 for v in &violations {
636 eprintln!(
637 "{}:{}: {} — {}",
638 v.file.display(),
639 v.line,
640 v.rule,
641 v.message
642 );
643 }
644 eprintln!(
645 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
646 violations.len()
647 );
648 Ok(1)
649}
650
651fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
655 let repo = std::env::current_dir()?;
656 let attestation = e2e::attest(&repo, command)?;
657 println!(
658 "e2e attestation recorded for commit {} (command exited {})",
659 attestation.commit, attestation.exit_code
660 );
661 Ok(0)
662}
663
664fn run_e2e_verify() -> anyhow::Result<i32> {
668 let repo = std::env::current_dir()?;
669 match e2e::verify(&repo)? {
670 e2e::Verification::Fresh => Ok(0),
671 e2e::Verification::Missing => {
672 eprintln!(
673 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
674 );
675 Ok(1)
676 }
677 e2e::Verification::Stale { attested, latest } => {
678 eprintln!(
679 "e2e attestation out of date: attested {}, latest code commit {} — \
680 run `testing-conventions e2e attest '<your e2e command>'`",
681 &attested[..attested.len().min(7)],
682 &latest[..latest.len().min(7)]
683 );
684 Ok(1)
685 }
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn no_args_returns_ok_zero() {
695 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
696 }
697
698 #[test]
699 fn check_returns_ok_zero() {
700 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
701 }
702
703 #[test]
704 fn unknown_flag_errors() {
705 assert!(run(["testing-conventions", "--bogus"]).is_err());
706 }
707
708 #[test]
709 fn help_flag_returns_clap_display_help() {
710 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
711 let clap_err = err
712 .downcast_ref::<clap::Error>()
713 .expect("error should be a clap::Error");
714 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
715 }
716
717 #[test]
718 fn version_flag_returns_clap_display_version() {
719 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
720 let clap_err = err
721 .downcast_ref::<clap::Error>()
722 .expect("error should be a clap::Error");
723 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
724 }
725
726 #[test]
727 fn unit_coverage_rust_requires_a_coverage_table() {
728 let err = run([
733 "testing-conventions",
734 "unit",
735 "coverage",
736 "pkg",
737 "--language",
738 "rust",
739 ])
740 .unwrap_err();
741 assert!(err.to_string().contains("[rust].coverage"), "got: {err}");
742 }
743}