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 run_unit_coverage(
382 root: &Path,
383 language: colocated_test::Language,
384 config_path: &Path,
385) -> anyhow::Result<i32> {
386 let config = if config_path.exists() {
387 config::load_config(config_path)?
388 } else {
389 config::Config::default()
390 };
391 let outcome = match language {
392 colocated_test::Language::Python => {
393 let python = config.python.unwrap_or_default();
394 let coverage = python.coverage.unwrap_or_default();
395 let thresholds = coverage::Thresholds {
396 fail_under: coverage.fail_under,
397 branch: coverage.branch,
398 };
399 let omit: Vec<String> =
400 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
401 .into_iter()
402 .collect();
403 coverage::measure(root, thresholds, &omit)?
404 }
405 colocated_test::Language::TypeScript => {
406 let typescript = config.typescript.unwrap_or_default();
407 let coverage = typescript.coverage.unwrap_or_default();
408 let thresholds = coverage::TypeScriptThresholds {
409 lines: coverage.lines,
410 branches: coverage.branches,
411 functions: coverage.functions,
412 statements: coverage.statements,
413 };
414 let exclude: Vec<String> =
415 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
416 .into_iter()
417 .collect();
418 coverage::measure_typescript(root, thresholds, &exclude)?
419 }
420 colocated_test::Language::Rust => anyhow::bail!(
421 "`unit coverage` supports `--language python` / `typescript`; \
422 Rust coverage (`cargo llvm-cov`) is a separate item"
423 ),
424 };
425 match outcome {
426 coverage::Outcome::Pass => Ok(0),
427 coverage::Outcome::Fail(reason) => {
428 eprintln!("error: coverage check failed — {reason}");
429 Ok(1)
430 }
431 }
432}
433
434fn run_unit_patch_coverage(
446 root: &Path,
447 base: &str,
448 language: colocated_test::Language,
449 config_path: &Path,
450) -> anyhow::Result<i32> {
451 let exempt = patch_coverage_exemptions(root, config_path, language)?;
452 let uncovered = match language {
453 colocated_test::Language::Python => patch_coverage::check(root, base, &exempt)?,
454 colocated_test::Language::TypeScript => {
455 patch_coverage::check_typescript(root, base, &exempt)?
456 }
457 colocated_test::Language::Rust => anyhow::bail!(
458 "`unit patch-coverage` supports `--language python` / `typescript`; \
459 the Rust twin (`cargo llvm-cov`) is a separate item"
460 ),
461 };
462 if uncovered.is_empty() {
463 return Ok(0);
464 }
465 for u in &uncovered {
466 eprintln!(
467 "changed line not covered by the unit suite: {}:{}",
468 u.file, u.line
469 );
470 }
471 eprintln!(
472 "error: {} changed line(s) not covered by the unit suite \
473 (add a unit test, or a `coverage` exempt entry with a reason)",
474 uncovered.len()
475 );
476 Ok(1)
477}
478
479fn patch_coverage_exemptions(
484 root: &Path,
485 config_path: &Path,
486 language: colocated_test::Language,
487) -> anyhow::Result<Vec<String>> {
488 if !config_path.exists() {
489 return Ok(Vec::new());
490 }
491 let config = config::load_config(config_path)?;
492 Ok(
493 config::resolve_exempt(root, config.exemptions(language), config::Rule::Coverage)?
494 .into_iter()
495 .collect(),
496 )
497}
498
499fn run_unit_isolation(
503 root: &Path,
504 language: isolation::Language,
505 config_path: &Path,
506) -> anyhow::Result<i32> {
507 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
508 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
509 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
510 c.exemptions(colocated_test::Language::TypeScript)
511 }),
512 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
513 c.exemptions(colocated_test::Language::Python)
514 }),
515 };
516 let violations = apply_waivers(raw, root, config_path, select)?;
517 if violations.is_empty() {
518 return Ok(0);
519 }
520 for v in &violations {
521 eprintln!(
522 "{}:{}: {} — {}",
523 v.file.display(),
524 v.line,
525 v.rule,
526 v.message
527 );
528 }
529 eprintln!("error: {} isolation violation(s)", violations.len());
530 Ok(1)
531}
532
533fn run_integration_lint(
537 root: &Path,
538 language: IntegrationLintLanguage,
539 config_path: &Path,
540) -> anyhow::Result<i32> {
541 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
542 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
543 c.exemptions(colocated_test::Language::Python)
544 }),
545 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
546 c.exemptions(colocated_test::Language::TypeScript)
547 }),
548 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
549 c.rust_exemptions()
550 }),
551 };
552 let violations = apply_waivers(raw, root, config_path, select)?;
553 if violations.is_empty() {
554 return Ok(0);
555 }
556 for v in &violations {
557 eprintln!(
558 "{}:{}: {} — {}",
559 v.file.display(),
560 v.line,
561 v.rule,
562 v.message
563 );
564 }
565 eprintln!("error: {} lint violation(s)", violations.len());
566 Ok(1)
567}
568
569type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
572
573fn apply_waivers(
580 violations: Vec<lint::Violation>,
581 root: &Path,
582 config_path: &Path,
583 exemptions: ExemptSelect,
584) -> anyhow::Result<Vec<lint::Violation>> {
585 use std::collections::hash_map::Entry;
586
587 if !config_path.exists() {
588 return Ok(violations);
589 }
590 let config = config::load_config(config_path)?;
591 let exempt = exemptions(&config);
592 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
594 std::collections::HashMap::new();
595 let mut kept = Vec::new();
596 for violation in violations {
597 let waived = match config::Rule::from_id(violation.rule) {
598 Some(rule) => {
599 let exempt_paths = match resolved.entry(rule) {
600 Entry::Occupied(entry) => entry.into_mut(),
601 Entry::Vacant(entry) => {
602 entry.insert(config::resolve_exempt(root, exempt, rule)?)
603 }
604 };
605 violation
606 .file
607 .strip_prefix(root)
608 .ok()
609 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
610 .is_some_and(|rel| exempt_paths.contains(&rel))
611 }
612 None => false,
613 };
614 if !waived {
615 kept.push(violation);
616 }
617 }
618 Ok(kept)
619}
620
621fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
630 let globs = match language {
631 colocated_test::Language::Python => vec!["*_test.py".to_string()],
632 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
633 colocated_test::Language::Rust => vec!["tests/".to_string()],
636 };
637 let offenders = packaging::inspect(artifact, &globs)?;
638 if offenders.is_empty() {
639 return Ok(0);
640 }
641 for offender in &offenders {
642 eprintln!("test file in built artifact: {}", offender.display());
643 }
644 eprintln!(
645 "error: {} test file(s) present in the built artifact \
646 (they must be excluded from packaging)",
647 offenders.len()
648 );
649 Ok(1)
650}
651
652fn run_workflow(path: &Path) -> anyhow::Result<i32> {
657 let violations = workflow::check(path, &command())?;
658 if violations.is_empty() {
659 return Ok(0);
660 }
661 for v in &violations {
662 eprintln!(
663 "{}:{}: {} — {}",
664 v.file.display(),
665 v.line,
666 v.rule,
667 v.message
668 );
669 }
670 eprintln!(
671 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
672 violations.len()
673 );
674 Ok(1)
675}
676
677fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
681 let repo = std::env::current_dir()?;
682 let attestation = e2e::attest(&repo, command)?;
683 println!(
684 "e2e attestation recorded for commit {} (command exited {})",
685 attestation.commit, attestation.exit_code
686 );
687 Ok(0)
688}
689
690fn run_e2e_verify() -> anyhow::Result<i32> {
694 let repo = std::env::current_dir()?;
695 match e2e::verify(&repo)? {
696 e2e::Verification::Fresh => Ok(0),
697 e2e::Verification::Missing => {
698 eprintln!(
699 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
700 );
701 Ok(1)
702 }
703 e2e::Verification::Stale { attested, latest } => {
704 eprintln!(
705 "e2e attestation out of date: attested {}, latest code commit {} — \
706 run `testing-conventions e2e attest '<your e2e command>'`",
707 &attested[..attested.len().min(7)],
708 &latest[..latest.len().min(7)]
709 );
710 Ok(1)
711 }
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn no_args_returns_ok_zero() {
721 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
722 }
723
724 #[test]
725 fn check_returns_ok_zero() {
726 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
727 }
728
729 #[test]
730 fn unknown_flag_errors() {
731 assert!(run(["testing-conventions", "--bogus"]).is_err());
732 }
733
734 #[test]
735 fn help_flag_returns_clap_display_help() {
736 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
737 let clap_err = err
738 .downcast_ref::<clap::Error>()
739 .expect("error should be a clap::Error");
740 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
741 }
742
743 #[test]
744 fn version_flag_returns_clap_display_version() {
745 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
746 let clap_err = err
747 .downcast_ref::<clap::Error>()
748 .expect("error should be a clap::Error");
749 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
750 }
751
752 #[test]
753 fn unit_coverage_rejects_rust() {
754 let err = run([
757 "testing-conventions",
758 "unit",
759 "coverage",
760 "pkg",
761 "--language",
762 "rust",
763 ])
764 .unwrap_err();
765 assert!(err.to_string().contains("separate item"), "got: {err}");
766 }
767}