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(
473 root: &Path,
474 base: &str,
475 language: colocated_test::Language,
476 config_path: &Path,
477) -> anyhow::Result<i32> {
478 match language {
479 colocated_test::Language::Python => {}
480 colocated_test::Language::TypeScript => anyhow::bail!(
481 "`unit patch-coverage` supports `--language python`; \
482 the TypeScript twin is a separate item"
483 ),
484 colocated_test::Language::Rust => anyhow::bail!(
485 "`unit patch-coverage` supports `--language python`; \
486 the Rust twin (`cargo llvm-cov`) is a separate item"
487 ),
488 }
489 let omit = patch_coverage_exemptions(root, config_path)?;
490 let uncovered = patch_coverage::check(root, base, &omit)?;
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(root: &Path, config_path: &Path) -> anyhow::Result<Vec<String>> {
513 if !config_path.exists() {
514 return Ok(Vec::new());
515 }
516 let config = config::load_config(config_path)?;
517 let python = config.python.unwrap_or_default();
518 Ok(
519 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
520 .into_iter()
521 .collect(),
522 )
523}
524
525fn run_unit_isolation(
529 root: &Path,
530 language: isolation::Language,
531 config_path: &Path,
532) -> anyhow::Result<i32> {
533 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
534 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
535 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
536 c.exemptions(colocated_test::Language::TypeScript)
537 }),
538 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
539 c.exemptions(colocated_test::Language::Python)
540 }),
541 };
542 let violations = apply_waivers(raw, root, config_path, select)?;
543 if violations.is_empty() {
544 return Ok(0);
545 }
546 for v in &violations {
547 eprintln!(
548 "{}:{}: {} — {}",
549 v.file.display(),
550 v.line,
551 v.rule,
552 v.message
553 );
554 }
555 eprintln!("error: {} isolation violation(s)", violations.len());
556 Ok(1)
557}
558
559fn run_integration_lint(
563 root: &Path,
564 language: IntegrationLintLanguage,
565 config_path: &Path,
566) -> anyhow::Result<i32> {
567 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
568 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
569 c.exemptions(colocated_test::Language::Python)
570 }),
571 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
572 c.exemptions(colocated_test::Language::TypeScript)
573 }),
574 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
575 c.rust_exemptions()
576 }),
577 };
578 let violations = apply_waivers(raw, root, config_path, select)?;
579 if violations.is_empty() {
580 return Ok(0);
581 }
582 for v in &violations {
583 eprintln!(
584 "{}:{}: {} — {}",
585 v.file.display(),
586 v.line,
587 v.rule,
588 v.message
589 );
590 }
591 eprintln!("error: {} lint violation(s)", violations.len());
592 Ok(1)
593}
594
595type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
598
599fn apply_waivers(
606 violations: Vec<lint::Violation>,
607 root: &Path,
608 config_path: &Path,
609 exemptions: ExemptSelect,
610) -> anyhow::Result<Vec<lint::Violation>> {
611 use std::collections::hash_map::Entry;
612
613 if !config_path.exists() {
614 return Ok(violations);
615 }
616 let config = config::load_config(config_path)?;
617 let exempt = exemptions(&config);
618 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
620 std::collections::HashMap::new();
621 let mut kept = Vec::new();
622 for violation in violations {
623 let waived = match config::Rule::from_id(violation.rule) {
624 Some(rule) => {
625 let exempt_paths = match resolved.entry(rule) {
626 Entry::Occupied(entry) => entry.into_mut(),
627 Entry::Vacant(entry) => {
628 entry.insert(config::resolve_exempt(root, exempt, rule)?)
629 }
630 };
631 violation
632 .file
633 .strip_prefix(root)
634 .ok()
635 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
636 .is_some_and(|rel| exempt_paths.contains(&rel))
637 }
638 None => false,
639 };
640 if !waived {
641 kept.push(violation);
642 }
643 }
644 Ok(kept)
645}
646
647fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
656 let globs = match language {
657 colocated_test::Language::Python => vec!["*_test.py".to_string()],
658 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
659 colocated_test::Language::Rust => vec!["tests/".to_string()],
662 };
663 let offenders = packaging::inspect(artifact, &globs)?;
664 if offenders.is_empty() {
665 return Ok(0);
666 }
667 for offender in &offenders {
668 eprintln!("test file in built artifact: {}", offender.display());
669 }
670 eprintln!(
671 "error: {} test file(s) present in the built artifact \
672 (they must be excluded from packaging)",
673 offenders.len()
674 );
675 Ok(1)
676}
677
678fn run_workflow(path: &Path) -> anyhow::Result<i32> {
683 let violations = workflow::check(path, &command())?;
684 if violations.is_empty() {
685 return Ok(0);
686 }
687 for v in &violations {
688 eprintln!(
689 "{}:{}: {} — {}",
690 v.file.display(),
691 v.line,
692 v.rule,
693 v.message
694 );
695 }
696 eprintln!(
697 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
698 violations.len()
699 );
700 Ok(1)
701}
702
703fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
707 let repo = std::env::current_dir()?;
708 let attestation = e2e::attest(&repo, command)?;
709 println!(
710 "e2e attestation recorded for commit {} (command exited {})",
711 attestation.commit, attestation.exit_code
712 );
713 Ok(0)
714}
715
716fn run_e2e_verify() -> anyhow::Result<i32> {
720 let repo = std::env::current_dir()?;
721 match e2e::verify(&repo)? {
722 e2e::Verification::Fresh => Ok(0),
723 e2e::Verification::Missing => {
724 eprintln!(
725 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
726 );
727 Ok(1)
728 }
729 e2e::Verification::Stale { attested, latest } => {
730 eprintln!(
731 "e2e attestation out of date: attested {}, latest code commit {} — \
732 run `testing-conventions e2e attest '<your e2e command>'`",
733 &attested[..attested.len().min(7)],
734 &latest[..latest.len().min(7)]
735 );
736 Ok(1)
737 }
738 }
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn no_args_returns_ok_zero() {
747 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
748 }
749
750 #[test]
751 fn check_returns_ok_zero() {
752 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
753 }
754
755 #[test]
756 fn unknown_flag_errors() {
757 assert!(run(["testing-conventions", "--bogus"]).is_err());
758 }
759
760 #[test]
761 fn help_flag_returns_clap_display_help() {
762 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
763 let clap_err = err
764 .downcast_ref::<clap::Error>()
765 .expect("error should be a clap::Error");
766 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
767 }
768
769 #[test]
770 fn version_flag_returns_clap_display_version() {
771 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
772 let clap_err = err
773 .downcast_ref::<clap::Error>()
774 .expect("error should be a clap::Error");
775 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
776 }
777
778 #[test]
779 fn unit_coverage_rejects_rust() {
780 let err = run([
783 "testing-conventions",
784 "unit",
785 "coverage",
786 "pkg",
787 "--language",
788 "rust",
789 ])
790 .unwrap_err();
791 assert!(err.to_string().contains("separate item"), "got: {err}");
792 }
793}