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 {
93 path: PathBuf,
95 #[arg(long, value_enum)]
97 language: colocated_test::Language,
98 #[arg(long, default_value = "testing-conventions.toml")]
103 config: PathBuf,
104 },
105 PatchCoverage {
110 path: PathBuf,
112 #[arg(long, value_enum)]
115 language: colocated_test::Language,
116 #[arg(long, default_value = "origin/main")]
120 base: String,
121 #[arg(long, default_value = "testing-conventions.toml")]
124 config: PathBuf,
125 },
126 Lint {
128 path: PathBuf,
130 #[arg(long, value_enum)]
132 language: isolation::Language,
133 #[arg(long, default_value = "testing-conventions.toml")]
136 config: PathBuf,
137 },
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
144pub enum IntegrationLintLanguage {
145 #[value(name = "python")]
147 Python,
148 #[value(name = "typescript")]
150 TypeScript,
151 #[value(name = "rust")]
153 Rust,
154}
155
156#[derive(Subcommand, Debug)]
159enum IntegrationRule {
160 Lint {
162 path: PathBuf,
164 #[arg(long, value_enum)]
166 language: IntegrationLintLanguage,
167 #[arg(long, default_value = "testing-conventions.toml")]
170 config: PathBuf,
171 },
172}
173
174#[derive(Subcommand, Debug)]
177enum E2eCommand {
178 Attest {
180 command: String,
182 },
183 Verify,
185}
186
187pub fn run<I, T>(args: I) -> anyhow::Result<i32>
188where
189 I: IntoIterator<Item = T>,
190 T: Into<std::ffi::OsString> + Clone,
191{
192 let cli = Cli::try_parse_from(args)?;
193 match cli.command {
194 Some(Command::Check) | None => Ok(0),
198 Some(Command::Unit { rule }) => match rule {
199 UnitRule::ColocatedTest {
200 path,
201 language,
202 base,
203 config,
204 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
205 UnitRule::Coverage {
206 path,
207 language,
208 config,
209 } => run_unit_coverage(&path, language, &config),
210 UnitRule::PatchCoverage {
211 path,
212 language,
213 base,
214 config,
215 } => run_unit_patch_coverage(&path, &base, language, &config),
216 UnitRule::Lint {
217 path,
218 language,
219 config,
220 } => run_unit_lint(&path, language, &config),
221 },
222 Some(Command::Integration { rule }) => match rule {
223 IntegrationRule::Lint {
224 path,
225 language,
226 config,
227 } => run_integration_lint(&path, language, &config),
228 },
229 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
230 Some(Command::Workflow { path }) => run_workflow(&path),
231 Some(Command::E2e { command }) => match command {
232 E2eCommand::Attest { command } => run_e2e_attest(&command),
233 E2eCommand::Verify => run_e2e_verify(),
234 },
235 }
236}
237
238pub fn command() -> clap::Command {
242 Cli::command()
243}
244
245fn run_unit_colocated_test(
258 root: &Path,
259 language: colocated_test::Language,
260 base: Option<&str>,
261 config_path: &Path,
262) -> anyhow::Result<i32> {
263 if base.is_some() && language == colocated_test::Language::Rust {
266 anyhow::bail!(
267 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
268 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
269 );
270 }
271 let presence_clean = report_colocated_presence(root, language, config_path)?;
272 let co_change_clean = match base {
273 Some(base) => report_co_change(root, base, language, config_path)?,
274 None => true,
275 };
276 Ok(if presence_clean && co_change_clean {
277 0
278 } else {
279 1
280 })
281}
282
283fn report_colocated_presence(
289 root: &Path,
290 language: colocated_test::Language,
291 config_path: &Path,
292) -> anyhow::Result<bool> {
293 let exempt = colocated_test_exemptions(root, language, config_path)?;
294 let orphans = match language {
295 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
298 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
299 };
300 if orphans.is_empty() {
301 return Ok(true);
302 }
303 let (label, summary) = match language {
304 colocated_test::Language::Rust => (
305 "missing inline `#[cfg(test)]` tests",
306 "source file(s) with testable code but no inline `#[cfg(test)]` module \
307 (add an inline test module, or an `exempt` entry with a reason)",
308 ),
309 _ => (
310 "missing colocated unit test",
311 "source file(s) missing a colocated unit test \
312 (add a colocated test, or an `exempt` entry with a reason)",
313 ),
314 };
315 for orphan in &orphans {
316 eprintln!("{label}: {}", orphan.display());
317 }
318 eprintln!("error: {} {summary}", orphans.len());
319 Ok(false)
320}
321
322fn colocated_test_exemptions(
326 root: &Path,
327 language: colocated_test::Language,
328 config_path: &Path,
329) -> anyhow::Result<std::collections::BTreeSet<String>> {
330 if !config_path.exists() {
331 return Ok(std::collections::BTreeSet::new());
332 }
333 let config = config::load_config(config_path)?;
334 config::resolve_exempt(
335 root,
336 config.exemptions(language),
337 config::Rule::ColocatedTest,
338 )
339}
340
341fn report_co_change(
351 root: &Path,
352 base: &str,
353 language: colocated_test::Language,
354 config_path: &Path,
355) -> anyhow::Result<bool> {
356 let exempt = co_change_exemptions(root, language, config_path)?;
357 let stale = co_change::stale_sources(root, base, language, &exempt)?;
358 if stale.is_empty() {
359 return Ok(true);
360 }
361 for source in &stale {
362 eprintln!(
363 "source changed without its colocated test: {}",
364 source.display()
365 );
366 }
367 eprintln!(
368 "error: {} source file(s) changed without their colocated test co-changing \
369 (update the test, or add an `exempt` entry with a reason)",
370 stale.len()
371 );
372 Ok(false)
373}
374
375fn co_change_exemptions(
379 root: &Path,
380 language: colocated_test::Language,
381 config_path: &Path,
382) -> anyhow::Result<std::collections::BTreeSet<String>> {
383 if !config_path.exists() {
384 return Ok(std::collections::BTreeSet::new());
385 }
386 let config = config::load_config(config_path)?;
387 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
388}
389
390fn run_unit_coverage(
403 root: &Path,
404 language: colocated_test::Language,
405 config_path: &Path,
406) -> anyhow::Result<i32> {
407 let config = if config_path.exists() {
408 config::load_config(config_path)?
409 } else {
410 config::Config::default()
411 };
412 let outcome = match language {
413 colocated_test::Language::Python => {
414 let python = config.python.unwrap_or_default();
415 let coverage = python.coverage.unwrap_or_default();
416 let thresholds = coverage::Thresholds {
417 fail_under: coverage.fail_under,
418 branch: coverage.branch,
419 };
420 let omit: Vec<String> =
421 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
422 .into_iter()
423 .collect();
424 coverage::measure(root, thresholds, &omit)?
425 }
426 colocated_test::Language::TypeScript => {
427 let typescript = config.typescript.unwrap_or_default();
428 let coverage = typescript.coverage.unwrap_or_default();
429 let thresholds = coverage::TypeScriptThresholds {
430 lines: coverage.lines,
431 branches: coverage.branches,
432 functions: coverage.functions,
433 statements: coverage.statements,
434 };
435 let exclude: Vec<String> =
436 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
437 .into_iter()
438 .collect();
439 coverage::measure_typescript(root, thresholds, &exclude)?
440 }
441 colocated_test::Language::Rust => {
442 let rust = config.rust.unwrap_or_default();
443 let coverage = rust.coverage.ok_or_else(|| {
447 anyhow::anyhow!(
448 "Rust coverage needs a `[rust].coverage` table (regions / lines) in `{}` — \
449 there is no zero-config default floor for Rust yet",
450 config_path.display()
451 )
452 })?;
453 let thresholds = coverage::RustThresholds {
454 regions: coverage.regions,
455 lines: coverage.lines,
456 };
457 let ignore: Vec<String> =
458 config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
459 .into_iter()
460 .collect();
461 coverage::measure_rust(root, thresholds, &ignore)?
462 }
463 };
464 match outcome {
465 coverage::Outcome::Pass => Ok(0),
466 coverage::Outcome::Fail(reason) => {
467 eprintln!("error: coverage check failed — {reason}");
468 Ok(1)
469 }
470 }
471}
472
473fn run_unit_patch_coverage(
485 root: &Path,
486 base: &str,
487 language: colocated_test::Language,
488 config_path: &Path,
489) -> anyhow::Result<i32> {
490 let exempt = patch_coverage_exemptions(root, config_path, language)?;
491 let uncovered = match language {
492 colocated_test::Language::Python => patch_coverage::check(root, base, &exempt)?,
493 colocated_test::Language::TypeScript => {
494 patch_coverage::check_typescript(root, base, &exempt)?
495 }
496 colocated_test::Language::Rust => patch_coverage::check_rust(root, base, &exempt)?,
497 };
498 if uncovered.is_empty() {
499 return Ok(0);
500 }
501 for u in &uncovered {
502 eprintln!(
503 "changed line not covered by the unit suite: {}:{}",
504 u.file, u.line
505 );
506 }
507 eprintln!(
508 "error: {} changed line(s) not covered by the unit suite \
509 (add a unit test, or a `coverage` exempt entry with a reason)",
510 uncovered.len()
511 );
512 Ok(1)
513}
514
515fn patch_coverage_exemptions(
520 root: &Path,
521 config_path: &Path,
522 language: colocated_test::Language,
523) -> anyhow::Result<Vec<String>> {
524 if !config_path.exists() {
525 return Ok(Vec::new());
526 }
527 let config = config::load_config(config_path)?;
528 Ok(
529 config::resolve_exempt(root, config.exemptions(language), config::Rule::Coverage)?
530 .into_iter()
531 .collect(),
532 )
533}
534
535fn run_unit_lint(
540 root: &Path,
541 language: isolation::Language,
542 config_path: &Path,
543) -> anyhow::Result<i32> {
544 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
545 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
546 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
547 c.exemptions(colocated_test::Language::TypeScript)
548 }),
549 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
550 c.exemptions(colocated_test::Language::Python)
551 }),
552 };
553 let violations = apply_waivers(raw, root, config_path, select)?;
554 if violations.is_empty() {
555 return Ok(0);
556 }
557 for v in &violations {
558 eprintln!(
559 "{}:{}: {} — {}",
560 v.file.display(),
561 v.line,
562 v.rule,
563 v.message
564 );
565 }
566 eprintln!("error: {} isolation violation(s)", violations.len());
567 Ok(1)
568}
569
570fn run_integration_lint(
574 root: &Path,
575 language: IntegrationLintLanguage,
576 config_path: &Path,
577) -> anyhow::Result<i32> {
578 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
579 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
580 c.exemptions(colocated_test::Language::Python)
581 }),
582 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
583 c.exemptions(colocated_test::Language::TypeScript)
584 }),
585 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
586 c.rust_exemptions()
587 }),
588 };
589 let violations = apply_waivers(raw, root, config_path, select)?;
590 if violations.is_empty() {
591 return Ok(0);
592 }
593 for v in &violations {
594 eprintln!(
595 "{}:{}: {} — {}",
596 v.file.display(),
597 v.line,
598 v.rule,
599 v.message
600 );
601 }
602 eprintln!("error: {} lint violation(s)", violations.len());
603 Ok(1)
604}
605
606type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
609
610fn apply_waivers(
617 violations: Vec<lint::Violation>,
618 root: &Path,
619 config_path: &Path,
620 exemptions: ExemptSelect,
621) -> anyhow::Result<Vec<lint::Violation>> {
622 use std::collections::hash_map::Entry;
623
624 if !config_path.exists() {
625 return Ok(violations);
626 }
627 let config = config::load_config(config_path)?;
628 let exempt = exemptions(&config);
629 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
631 std::collections::HashMap::new();
632 let mut kept = Vec::new();
633 for violation in violations {
634 let waived = match config::Rule::from_id(violation.rule) {
635 Some(rule) => {
636 let exempt_paths = match resolved.entry(rule) {
637 Entry::Occupied(entry) => entry.into_mut(),
638 Entry::Vacant(entry) => {
639 entry.insert(config::resolve_exempt(root, exempt, rule)?)
640 }
641 };
642 violation
643 .file
644 .strip_prefix(root)
645 .ok()
646 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
647 .is_some_and(|rel| exempt_paths.contains(&rel))
648 }
649 None => false,
650 };
651 if !waived {
652 kept.push(violation);
653 }
654 }
655 Ok(kept)
656}
657
658fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
667 let globs = match language {
668 colocated_test::Language::Python => vec!["*_test.py".to_string()],
669 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
670 colocated_test::Language::Rust => vec!["tests/".to_string()],
673 };
674 let offenders = packaging::inspect(artifact, &globs)?;
675 if offenders.is_empty() {
676 return Ok(0);
677 }
678 for offender in &offenders {
679 eprintln!("test file in built artifact: {}", offender.display());
680 }
681 eprintln!(
682 "error: {} test file(s) present in the built artifact \
683 (they must be excluded from packaging)",
684 offenders.len()
685 );
686 Ok(1)
687}
688
689fn run_workflow(path: &Path) -> anyhow::Result<i32> {
694 let violations = workflow::check(path, &command())?;
695 if violations.is_empty() {
696 return Ok(0);
697 }
698 for v in &violations {
699 eprintln!(
700 "{}:{}: {} — {}",
701 v.file.display(),
702 v.line,
703 v.rule,
704 v.message
705 );
706 }
707 eprintln!(
708 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
709 violations.len()
710 );
711 Ok(1)
712}
713
714fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
718 let repo = std::env::current_dir()?;
719 let attestation = e2e::attest(&repo, command)?;
720 println!(
721 "e2e attestation recorded for commit {} (command exited {})",
722 attestation.commit, attestation.exit_code
723 );
724 Ok(0)
725}
726
727fn run_e2e_verify() -> anyhow::Result<i32> {
731 let repo = std::env::current_dir()?;
732 match e2e::verify(&repo)? {
733 e2e::Verification::Fresh => Ok(0),
734 e2e::Verification::Missing => {
735 eprintln!(
736 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
737 );
738 Ok(1)
739 }
740 e2e::Verification::Stale { attested, latest } => {
741 eprintln!(
742 "e2e attestation out of date: attested {}, latest code commit {} — \
743 run `testing-conventions e2e attest '<your e2e command>'`",
744 &attested[..attested.len().min(7)],
745 &latest[..latest.len().min(7)]
746 );
747 Ok(1)
748 }
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn no_args_returns_ok_zero() {
758 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
759 }
760
761 #[test]
762 fn check_returns_ok_zero() {
763 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
764 }
765
766 #[test]
767 fn unknown_flag_errors() {
768 assert!(run(["testing-conventions", "--bogus"]).is_err());
769 }
770
771 #[test]
772 fn help_flag_returns_clap_display_help() {
773 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
774 let clap_err = err
775 .downcast_ref::<clap::Error>()
776 .expect("error should be a clap::Error");
777 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
778 }
779
780 #[test]
781 fn version_flag_returns_clap_display_version() {
782 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
783 let clap_err = err
784 .downcast_ref::<clap::Error>()
785 .expect("error should be a clap::Error");
786 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
787 }
788
789 #[test]
790 fn unit_coverage_rust_requires_a_coverage_table() {
791 let err = run([
796 "testing-conventions",
797 "unit",
798 "coverage",
799 "pkg",
800 "--language",
801 "rust",
802 ])
803 .unwrap_err();
804 assert!(err.to_string().contains("[rust].coverage"), "got: {err}");
805 }
806}