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 {
70 path: PathBuf,
72 #[arg(long, value_enum)]
74 language: colocated_test::Language,
75 #[arg(long, default_value = "testing-conventions.toml")]
78 config: PathBuf,
79 },
80 Coverage {
82 path: PathBuf,
84 #[arg(long, value_enum)]
86 language: colocated_test::Language,
87 #[arg(long, default_value = "testing-conventions.toml")]
92 config: PathBuf,
93 },
94 PatchCoverage {
99 path: PathBuf,
101 #[arg(long, value_enum)]
104 language: colocated_test::Language,
105 #[arg(long, default_value = "origin/main")]
109 base: String,
110 #[arg(long, default_value = "testing-conventions.toml")]
113 config: PathBuf,
114 },
115 Isolation {
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 CoChange {
131 path: PathBuf,
133 #[arg(long, value_enum)]
136 language: colocated_test::Language,
137 #[arg(long)]
140 base: String,
141 #[arg(long, default_value = "testing-conventions.toml")]
144 config: PathBuf,
145 },
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
152pub enum IntegrationLintLanguage {
153 #[value(name = "python")]
155 Python,
156 #[value(name = "typescript")]
158 TypeScript,
159 #[value(name = "rust")]
161 Rust,
162}
163
164#[derive(Subcommand, Debug)]
167enum IntegrationRule {
168 Lint {
170 path: PathBuf,
172 #[arg(long, value_enum)]
174 language: IntegrationLintLanguage,
175 #[arg(long, default_value = "testing-conventions.toml")]
178 config: PathBuf,
179 },
180}
181
182#[derive(Subcommand, Debug)]
185enum E2eCommand {
186 Attest {
188 command: String,
190 },
191 Verify,
193}
194
195pub fn run<I, T>(args: I) -> anyhow::Result<i32>
196where
197 I: IntoIterator<Item = T>,
198 T: Into<std::ffi::OsString> + Clone,
199{
200 let cli = Cli::try_parse_from(args)?;
201 match cli.command {
202 Some(Command::Check) | None => Ok(0),
206 Some(Command::Unit { rule }) => match rule {
207 UnitRule::ColocatedTest {
208 path,
209 language,
210 config,
211 } => run_unit_colocated_test(&path, language, &config),
212 UnitRule::Coverage {
213 path,
214 language,
215 config,
216 } => run_unit_coverage(&path, language, &config),
217 UnitRule::PatchCoverage {
218 path,
219 language,
220 base,
221 config,
222 } => run_unit_patch_coverage(&path, &base, language, &config),
223 UnitRule::Isolation {
224 path,
225 language,
226 config,
227 } => run_unit_isolation(&path, language, &config),
228 UnitRule::CoChange {
229 path,
230 language,
231 base,
232 config,
233 } => run_unit_co_change(&path, &base, language, &config),
234 },
235 Some(Command::Integration { rule }) => match rule {
236 IntegrationRule::Lint {
237 path,
238 language,
239 config,
240 } => run_integration_lint(&path, language, &config),
241 },
242 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
243 Some(Command::Workflow { path }) => run_workflow(&path),
244 Some(Command::E2e { command }) => match command {
245 E2eCommand::Attest { command } => run_e2e_attest(&command),
246 E2eCommand::Verify => run_e2e_verify(),
247 },
248 }
249}
250
251pub fn command() -> clap::Command {
255 Cli::command()
256}
257
258fn run_unit_colocated_test(
264 root: &Path,
265 language: colocated_test::Language,
266 config_path: &Path,
267) -> anyhow::Result<i32> {
268 let exempt = colocated_test_exemptions(root, language, config_path)?;
269 let orphans = match language {
270 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
273 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
274 };
275 if orphans.is_empty() {
276 return Ok(0);
277 }
278 let (label, summary) = match language {
279 colocated_test::Language::Rust => (
280 "missing inline `#[cfg(test)]` tests",
281 "source file(s) with testable code but no inline `#[cfg(test)]` module \
282 (add an inline test module, or an `exempt` entry with a reason)",
283 ),
284 _ => (
285 "missing colocated unit test",
286 "source file(s) missing a colocated unit test \
287 (add a colocated test, or an `exempt` entry with a reason)",
288 ),
289 };
290 for orphan in &orphans {
291 eprintln!("{label}: {}", orphan.display());
292 }
293 eprintln!("error: {} {summary}", orphans.len());
294 Ok(1)
295}
296
297fn colocated_test_exemptions(
301 root: &Path,
302 language: colocated_test::Language,
303 config_path: &Path,
304) -> anyhow::Result<std::collections::BTreeSet<String>> {
305 if !config_path.exists() {
306 return Ok(std::collections::BTreeSet::new());
307 }
308 let config = config::load_config(config_path)?;
309 config::resolve_exempt(
310 root,
311 config.exemptions(language),
312 config::Rule::ColocatedTest,
313 )
314}
315
316fn run_unit_co_change(
326 root: &Path,
327 base: &str,
328 language: colocated_test::Language,
329 config_path: &Path,
330) -> anyhow::Result<i32> {
331 if language == colocated_test::Language::Rust {
332 anyhow::bail!(
333 "`unit co-change` supports `--language python` / `typescript`; Rust units \
334 are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
335 );
336 }
337 let exempt = co_change_exemptions(root, language, config_path)?;
338 let stale = co_change::stale_sources(root, base, language, &exempt)?;
339 if stale.is_empty() {
340 return Ok(0);
341 }
342 for source in &stale {
343 eprintln!(
344 "source changed without its colocated test: {}",
345 source.display()
346 );
347 }
348 eprintln!(
349 "error: {} source file(s) changed without their colocated test co-changing \
350 (update the test, or add an `exempt` entry with a reason)",
351 stale.len()
352 );
353 Ok(1)
354}
355
356fn co_change_exemptions(
360 root: &Path,
361 language: colocated_test::Language,
362 config_path: &Path,
363) -> anyhow::Result<std::collections::BTreeSet<String>> {
364 if !config_path.exists() {
365 return Ok(std::collections::BTreeSet::new());
366 }
367 let config = config::load_config(config_path)?;
368 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
369}
370
371fn combine_outcomes(outcomes: impl IntoIterator<Item = coverage::Outcome>) -> coverage::Outcome {
376 let reasons: Vec<String> = outcomes
377 .into_iter()
378 .filter_map(|outcome| match outcome {
379 coverage::Outcome::Pass => None,
380 coverage::Outcome::Fail(reason) => Some(reason),
381 })
382 .collect();
383 if reasons.is_empty() {
384 coverage::Outcome::Pass
385 } else {
386 coverage::Outcome::Fail(reasons.join("; "))
387 }
388}
389
390fn run_unit_coverage(
401 root: &Path,
402 language: colocated_test::Language,
403 config_path: &Path,
404) -> anyhow::Result<i32> {
405 let config = if config_path.exists() {
406 config::load_config(config_path)?
407 } else {
408 config::Config::default()
409 };
410 let outcome = match language {
411 colocated_test::Language::Python => {
412 let python = config.python.unwrap_or_default();
413 let coverage = python.coverage.unwrap_or_default();
414 let thresholds = coverage::Thresholds {
415 fail_under: coverage.fail_under,
416 branch: coverage.branch,
417 };
418 let omit: Vec<String> =
419 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
420 .into_iter()
421 .collect();
422 let report = coverage::measure_report(root, &omit)?;
426 let baseline = coverage::read_baseline(root)?
427 .and_then(|baseline| baseline.python)
428 .map(|python| python.percent_covered);
429 combine_outcomes([
430 coverage::evaluate(&report, thresholds),
431 coverage::evaluate_ratchet(report.totals.percent_covered, baseline),
432 ])
433 }
434 colocated_test::Language::TypeScript => {
435 let typescript = config.typescript.unwrap_or_default();
436 let coverage = typescript.coverage.unwrap_or_default();
437 let thresholds = coverage::TypeScriptThresholds {
438 lines: coverage.lines,
439 branches: coverage.branches,
440 functions: coverage.functions,
441 statements: coverage.statements,
442 };
443 let exclude: Vec<String> =
444 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
445 .into_iter()
446 .collect();
447 coverage::measure_typescript(root, thresholds, &exclude)?
448 }
449 colocated_test::Language::Rust => anyhow::bail!(
450 "`unit coverage` supports `--language python` / `typescript`; \
451 Rust coverage (`cargo llvm-cov`) is a separate item"
452 ),
453 };
454 match outcome {
455 coverage::Outcome::Pass => Ok(0),
456 coverage::Outcome::Fail(reason) => {
457 eprintln!("error: coverage check failed — {reason}");
458 Ok(1)
459 }
460 }
461}
462
463fn run_unit_patch_coverage(
475 root: &Path,
476 base: &str,
477 language: colocated_test::Language,
478 config_path: &Path,
479) -> anyhow::Result<i32> {
480 let exempt = patch_coverage_exemptions(root, config_path, language)?;
481 let uncovered = match language {
482 colocated_test::Language::Python => patch_coverage::check(root, base, &exempt)?,
483 colocated_test::Language::TypeScript => {
484 patch_coverage::check_typescript(root, base, &exempt)?
485 }
486 colocated_test::Language::Rust => anyhow::bail!(
487 "`unit patch-coverage` supports `--language python` / `typescript`; \
488 the Rust twin (`cargo llvm-cov`) is a separate item"
489 ),
490 };
491 if uncovered.is_empty() {
492 return Ok(0);
493 }
494 for u in &uncovered {
495 eprintln!(
496 "changed line not covered by the unit suite: {}:{}",
497 u.file, u.line
498 );
499 }
500 eprintln!(
501 "error: {} changed line(s) not covered by the unit suite \
502 (add a unit test, or a `coverage` exempt entry with a reason)",
503 uncovered.len()
504 );
505 Ok(1)
506}
507
508fn patch_coverage_exemptions(
513 root: &Path,
514 config_path: &Path,
515 language: colocated_test::Language,
516) -> anyhow::Result<Vec<String>> {
517 if !config_path.exists() {
518 return Ok(Vec::new());
519 }
520 let config = config::load_config(config_path)?;
521 Ok(
522 config::resolve_exempt(root, config.exemptions(language), config::Rule::Coverage)?
523 .into_iter()
524 .collect(),
525 )
526}
527
528fn run_unit_isolation(
532 root: &Path,
533 language: isolation::Language,
534 config_path: &Path,
535) -> anyhow::Result<i32> {
536 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
537 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
538 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
539 c.exemptions(colocated_test::Language::TypeScript)
540 }),
541 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
542 c.exemptions(colocated_test::Language::Python)
543 }),
544 };
545 let violations = apply_waivers(raw, root, config_path, select)?;
546 if violations.is_empty() {
547 return Ok(0);
548 }
549 for v in &violations {
550 eprintln!(
551 "{}:{}: {} — {}",
552 v.file.display(),
553 v.line,
554 v.rule,
555 v.message
556 );
557 }
558 eprintln!("error: {} isolation violation(s)", violations.len());
559 Ok(1)
560}
561
562fn run_integration_lint(
566 root: &Path,
567 language: IntegrationLintLanguage,
568 config_path: &Path,
569) -> anyhow::Result<i32> {
570 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
571 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
572 c.exemptions(colocated_test::Language::Python)
573 }),
574 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
575 c.exemptions(colocated_test::Language::TypeScript)
576 }),
577 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
578 c.rust_exemptions()
579 }),
580 };
581 let violations = apply_waivers(raw, root, config_path, select)?;
582 if violations.is_empty() {
583 return Ok(0);
584 }
585 for v in &violations {
586 eprintln!(
587 "{}:{}: {} — {}",
588 v.file.display(),
589 v.line,
590 v.rule,
591 v.message
592 );
593 }
594 eprintln!("error: {} lint violation(s)", violations.len());
595 Ok(1)
596}
597
598type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
601
602fn apply_waivers(
609 violations: Vec<lint::Violation>,
610 root: &Path,
611 config_path: &Path,
612 exemptions: ExemptSelect,
613) -> anyhow::Result<Vec<lint::Violation>> {
614 use std::collections::hash_map::Entry;
615
616 if !config_path.exists() {
617 return Ok(violations);
618 }
619 let config = config::load_config(config_path)?;
620 let exempt = exemptions(&config);
621 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
623 std::collections::HashMap::new();
624 let mut kept = Vec::new();
625 for violation in violations {
626 let waived = match config::Rule::from_id(violation.rule) {
627 Some(rule) => {
628 let exempt_paths = match resolved.entry(rule) {
629 Entry::Occupied(entry) => entry.into_mut(),
630 Entry::Vacant(entry) => {
631 entry.insert(config::resolve_exempt(root, exempt, rule)?)
632 }
633 };
634 violation
635 .file
636 .strip_prefix(root)
637 .ok()
638 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
639 .is_some_and(|rel| exempt_paths.contains(&rel))
640 }
641 None => false,
642 };
643 if !waived {
644 kept.push(violation);
645 }
646 }
647 Ok(kept)
648}
649
650fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
659 let globs = match language {
660 colocated_test::Language::Python => vec!["*_test.py".to_string()],
661 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
662 colocated_test::Language::Rust => vec!["tests/".to_string()],
665 };
666 let offenders = packaging::inspect(artifact, &globs)?;
667 if offenders.is_empty() {
668 return Ok(0);
669 }
670 for offender in &offenders {
671 eprintln!("test file in built artifact: {}", offender.display());
672 }
673 eprintln!(
674 "error: {} test file(s) present in the built artifact \
675 (they must be excluded from packaging)",
676 offenders.len()
677 );
678 Ok(1)
679}
680
681fn run_workflow(path: &Path) -> anyhow::Result<i32> {
686 let violations = workflow::check(path, &command())?;
687 if violations.is_empty() {
688 return Ok(0);
689 }
690 for v in &violations {
691 eprintln!(
692 "{}:{}: {} — {}",
693 v.file.display(),
694 v.line,
695 v.rule,
696 v.message
697 );
698 }
699 eprintln!(
700 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
701 violations.len()
702 );
703 Ok(1)
704}
705
706fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
710 let repo = std::env::current_dir()?;
711 let attestation = e2e::attest(&repo, command)?;
712 println!(
713 "e2e attestation recorded for commit {} (command exited {})",
714 attestation.commit, attestation.exit_code
715 );
716 Ok(0)
717}
718
719fn run_e2e_verify() -> anyhow::Result<i32> {
723 let repo = std::env::current_dir()?;
724 match e2e::verify(&repo)? {
725 e2e::Verification::Fresh => Ok(0),
726 e2e::Verification::Missing => {
727 eprintln!(
728 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
729 );
730 Ok(1)
731 }
732 e2e::Verification::Stale { attested, latest } => {
733 eprintln!(
734 "e2e attestation out of date: attested {}, latest code commit {} — \
735 run `testing-conventions e2e attest '<your e2e command>'`",
736 &attested[..attested.len().min(7)],
737 &latest[..latest.len().min(7)]
738 );
739 Ok(1)
740 }
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 #[test]
749 fn no_args_returns_ok_zero() {
750 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
751 }
752
753 #[test]
754 fn check_returns_ok_zero() {
755 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
756 }
757
758 #[test]
759 fn unknown_flag_errors() {
760 assert!(run(["testing-conventions", "--bogus"]).is_err());
761 }
762
763 #[test]
764 fn help_flag_returns_clap_display_help() {
765 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
766 let clap_err = err
767 .downcast_ref::<clap::Error>()
768 .expect("error should be a clap::Error");
769 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
770 }
771
772 #[test]
773 fn version_flag_returns_clap_display_version() {
774 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
775 let clap_err = err
776 .downcast_ref::<clap::Error>()
777 .expect("error should be a clap::Error");
778 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
779 }
780
781 #[test]
782 fn unit_coverage_rejects_rust() {
783 let err = run([
786 "testing-conventions",
787 "unit",
788 "coverage",
789 "pkg",
790 "--language",
791 "rust",
792 ])
793 .unwrap_err();
794 assert!(err.to_string().contains("separate item"), "got: {err}");
795 }
796}