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 mutation;
9pub mod packaging;
10pub mod patch_coverage;
11pub mod ts;
12pub mod violation;
13pub mod workflow;
14
15use std::path::{Path, PathBuf};
16
17use clap::{CommandFactory, Parser, Subcommand};
18
19#[derive(Parser, Debug)]
20#[command(
21 name = "testing-conventions",
22 version,
23 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
24 long_about = None,
25)]
26pub struct Cli {
27 #[command(subcommand)]
28 command: Option<Command>,
29}
30
31#[derive(Subcommand, Debug)]
32enum Command {
33 Check,
35 Unit {
37 #[command(subcommand)]
38 rule: UnitRule,
39 },
40 Integration {
42 #[command(subcommand)]
43 rule: IntegrationRule,
44 },
45 Packaging {
47 path: PathBuf,
49 #[arg(long, value_enum)]
51 language: colocated_test::Language,
52 },
53 #[command(hide = true)]
58 Workflow {
59 path: PathBuf,
61 },
62 E2e {
64 #[command(subcommand)]
65 command: E2eCommand,
66 },
67}
68
69#[derive(Subcommand, Debug)]
71enum UnitRule {
72 ColocatedTest {
78 path: PathBuf,
80 #[arg(long, value_enum)]
82 language: colocated_test::Language,
83 #[arg(long)]
89 base: Option<String>,
90 #[arg(long, default_value = "testing-conventions.toml")]
93 config: PathBuf,
94 },
95 Coverage {
100 path: PathBuf,
102 #[arg(long, value_enum)]
104 language: colocated_test::Language,
105 #[arg(long)]
111 base: Option<String>,
112 #[arg(long, default_value = "testing-conventions.toml")]
117 config: PathBuf,
118 },
119 Lint {
121 path: PathBuf,
123 #[arg(long, value_enum)]
125 language: isolation::Language,
126 #[arg(long, default_value = "testing-conventions.toml")]
129 config: PathBuf,
130 },
131 Mutation {
136 path: PathBuf,
138 #[arg(long, value_enum)]
140 language: colocated_test::Language,
141 #[arg(long)]
145 base: Option<String>,
146 #[arg(long, default_value = "testing-conventions.toml")]
149 config: PathBuf,
150 },
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
157pub enum IntegrationLintLanguage {
158 #[value(name = "python")]
160 Python,
161 #[value(name = "typescript")]
163 TypeScript,
164 #[value(name = "rust")]
166 Rust,
167}
168
169#[derive(Subcommand, Debug)]
172enum IntegrationRule {
173 Lint {
175 path: PathBuf,
177 #[arg(long, value_enum)]
179 language: IntegrationLintLanguage,
180 #[arg(long, default_value = "testing-conventions.toml")]
183 config: PathBuf,
184 },
185}
186
187#[derive(Subcommand, Debug)]
190enum E2eCommand {
191 Attest {
193 command: String,
195 },
196 Verify,
198}
199
200pub fn run<I, T>(args: I) -> anyhow::Result<i32>
201where
202 I: IntoIterator<Item = T>,
203 T: Into<std::ffi::OsString> + Clone,
204{
205 let cli = Cli::try_parse_from(args)?;
206 match cli.command {
207 Some(Command::Check) | None => Ok(0),
211 Some(Command::Unit { rule }) => match rule {
212 UnitRule::ColocatedTest {
213 path,
214 language,
215 base,
216 config,
217 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
218 UnitRule::Coverage {
219 path,
220 language,
221 base,
222 config,
223 } => run_unit_coverage(&path, language, base.as_deref(), &config),
224 UnitRule::Lint {
225 path,
226 language,
227 config,
228 } => run_unit_lint(&path, language, &config),
229 UnitRule::Mutation {
230 path,
231 language,
232 base,
233 config,
234 } => run_unit_mutation(&path, language, base.as_deref(), &config),
235 },
236 Some(Command::Integration { rule }) => match rule {
237 IntegrationRule::Lint {
238 path,
239 language,
240 config,
241 } => run_integration_lint(&path, language, &config),
242 },
243 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
244 Some(Command::Workflow { path }) => run_workflow(&path),
245 Some(Command::E2e { command }) => match command {
246 E2eCommand::Attest { command } => run_e2e_attest(&command),
247 E2eCommand::Verify => run_e2e_verify(),
248 },
249 }
250}
251
252pub fn command() -> clap::Command {
256 Cli::command()
257}
258
259fn run_unit_colocated_test(
272 root: &Path,
273 language: colocated_test::Language,
274 base: Option<&str>,
275 config_path: &Path,
276) -> anyhow::Result<i32> {
277 if base.is_some() && language == colocated_test::Language::Rust {
280 anyhow::bail!(
281 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
282 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
283 );
284 }
285 let presence_clean = report_colocated_presence(root, language, config_path)?;
286 let co_change_clean = match base {
287 Some(base) => report_co_change(root, base, language, config_path)?,
288 None => true,
289 };
290 Ok(if presence_clean && co_change_clean {
291 0
292 } else {
293 1
294 })
295}
296
297fn report_colocated_presence(
303 root: &Path,
304 language: colocated_test::Language,
305 config_path: &Path,
306) -> anyhow::Result<bool> {
307 let exempt = colocated_test_exemptions(root, language, config_path)?;
308 let orphans = match language {
309 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
312 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
313 };
314 if orphans.is_empty() {
315 return Ok(true);
316 }
317 let (label, summary) = match language {
318 colocated_test::Language::Rust => (
319 "missing inline `#[cfg(test)]` tests",
320 "source file(s) with testable code but no inline `#[cfg(test)]` module \
321 (add an inline test module, or an `exempt` entry with a reason)",
322 ),
323 _ => (
324 "missing colocated unit test",
325 "source file(s) missing a colocated unit test \
326 (add a colocated test, or an `exempt` entry with a reason)",
327 ),
328 };
329 for orphan in &orphans {
330 eprintln!("{label}: {}", orphan.display());
331 }
332 eprintln!("error: {} {summary}", orphans.len());
333 Ok(false)
334}
335
336fn colocated_test_exemptions(
340 root: &Path,
341 language: colocated_test::Language,
342 config_path: &Path,
343) -> anyhow::Result<std::collections::BTreeSet<String>> {
344 if !config_path.exists() {
345 return Ok(std::collections::BTreeSet::new());
346 }
347 let config = config::load_config(config_path)?;
348 config::resolve_exempt(
349 root,
350 config.exemptions(language),
351 config::Rule::ColocatedTest,
352 )
353}
354
355fn report_co_change(
365 root: &Path,
366 base: &str,
367 language: colocated_test::Language,
368 config_path: &Path,
369) -> anyhow::Result<bool> {
370 let exempt = co_change_exemptions(root, language, config_path)?;
371 let stale = co_change::stale_sources(root, base, language, &exempt)?;
372 if stale.is_empty() {
373 return Ok(true);
374 }
375 for source in &stale {
376 eprintln!(
377 "source changed without its colocated test: {}",
378 source.display()
379 );
380 }
381 eprintln!(
382 "error: {} source file(s) changed without their colocated test co-changing \
383 (update the test, or add an `exempt` entry with a reason)",
384 stale.len()
385 );
386 Ok(false)
387}
388
389fn co_change_exemptions(
393 root: &Path,
394 language: colocated_test::Language,
395 config_path: &Path,
396) -> anyhow::Result<std::collections::BTreeSet<String>> {
397 if !config_path.exists() {
398 return Ok(std::collections::BTreeSet::new());
399 }
400 let config = config::load_config(config_path)?;
401 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
402}
403
404fn run_unit_coverage(
422 root: &Path,
423 language: colocated_test::Language,
424 base: Option<&str>,
425 config_path: &Path,
426) -> anyhow::Result<i32> {
427 let config = if config_path.exists() {
428 config::load_config(config_path)?
429 } else {
430 config::Config::default()
431 };
432 let outcome = match language {
433 colocated_test::Language::Python => {
434 let python = config.python.unwrap_or_default();
435 let coverage = python.coverage.unwrap_or_default();
436 let thresholds = coverage::Thresholds {
437 fail_under: coverage.fail_under,
438 branch: coverage.branch,
439 };
440 let omit: Vec<String> =
441 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
442 .into_iter()
443 .collect();
444 match base {
445 Some(base) => patch_coverage::measure(root, base, thresholds, &omit)?,
446 None => coverage::measure(root, thresholds, &omit)?,
447 }
448 }
449 colocated_test::Language::TypeScript => {
450 let typescript = config.typescript.unwrap_or_default();
451 let coverage = typescript.coverage.unwrap_or_default();
452 let thresholds = coverage::TypeScriptThresholds {
453 lines: coverage.lines,
454 branches: coverage.branches,
455 functions: coverage.functions,
456 statements: coverage.statements,
457 };
458 let exclude: Vec<String> =
459 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
460 .into_iter()
461 .collect();
462 match base {
463 Some(base) => patch_coverage::measure_typescript(root, base, thresholds, &exclude)?,
464 None => coverage::measure_typescript(root, thresholds, &exclude)?,
465 }
466 }
467 colocated_test::Language::Rust => {
468 let rust = config.rust.unwrap_or_default();
469 let coverage = rust.coverage.unwrap_or_default();
474 let thresholds = coverage::RustThresholds {
475 regions: coverage.regions,
476 lines: coverage.lines,
477 };
478 let ignore: Vec<String> =
479 config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
480 .into_iter()
481 .collect();
482 match base {
483 Some(base) => patch_coverage::measure_rust(root, base, thresholds, &ignore)?,
484 None => coverage::measure_rust(root, thresholds, &ignore)?,
485 }
486 }
487 };
488 match outcome {
489 coverage::Outcome::Pass => Ok(0),
490 coverage::Outcome::Fail(reason) => {
491 eprintln!("error: coverage check failed — {reason}");
492 Ok(1)
493 }
494 }
495}
496
497fn run_unit_mutation(
508 root: &Path,
509 language: colocated_test::Language,
510 base: Option<&str>,
511 config_path: &Path,
512) -> anyhow::Result<i32> {
513 let config = if config_path.exists() {
514 config::load_config(config_path)?
515 } else {
516 config::Config::default()
517 };
518 let survivors = match language {
519 colocated_test::Language::Rust => {
520 let rust = config.rust.unwrap_or_default();
521 let exempt: Vec<String> =
522 config::resolve_exempt(root, &rust.exempt, config::Rule::Mutation)?
523 .into_iter()
524 .collect();
525 mutation::measure_rust(root, &exempt, base)?
526 }
527 colocated_test::Language::TypeScript => {
528 let typescript = config.typescript.unwrap_or_default();
529 let exempt: Vec<String> =
530 config::resolve_exempt(root, &typescript.exempt, config::Rule::Mutation)?
531 .into_iter()
532 .collect();
533 mutation::measure_typescript(root, &exempt, base)?
534 }
535 colocated_test::Language::Python => {
536 let python = config.python.unwrap_or_default();
537 let exempt: Vec<String> =
538 config::resolve_exempt(root, &python.exempt, config::Rule::Mutation)?
539 .into_iter()
540 .collect();
541 mutation::measure_python(root, &exempt, base)?
542 }
543 };
544 if survivors.is_empty() {
545 println!("unit mutation: no surviving mutants — every mutation was caught");
546 return Ok(0);
547 }
548
549 eprintln!(
550 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
551 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
552 survivors.len()
553 );
554 for survivor in &survivors {
555 eprintln!(
556 " {}:{}: {}",
557 survivor.file, survivor.line, survivor.description
558 );
559 }
560 Ok(1)
561}
562
563fn run_unit_lint(
568 root: &Path,
569 language: isolation::Language,
570 config_path: &Path,
571) -> anyhow::Result<i32> {
572 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
573 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
574 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
575 c.exemptions(colocated_test::Language::TypeScript)
576 }),
577 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
578 c.exemptions(colocated_test::Language::Python)
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: {} isolation violation(s)", violations.len());
595 Ok(1)
596}
597
598fn run_integration_lint(
602 root: &Path,
603 language: IntegrationLintLanguage,
604 config_path: &Path,
605) -> anyhow::Result<i32> {
606 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
607 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
608 c.exemptions(colocated_test::Language::Python)
609 }),
610 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
611 c.exemptions(colocated_test::Language::TypeScript)
612 }),
613 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
614 c.rust_exemptions()
615 }),
616 };
617 let violations = apply_waivers(raw, root, config_path, select)?;
618 if violations.is_empty() {
619 return Ok(0);
620 }
621 for v in &violations {
622 eprintln!(
623 "{}:{}: {} — {}",
624 v.file.display(),
625 v.line,
626 v.rule,
627 v.message
628 );
629 }
630 eprintln!("error: {} lint violation(s)", violations.len());
631 Ok(1)
632}
633
634type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
637
638fn apply_waivers(
645 violations: Vec<lint::Violation>,
646 root: &Path,
647 config_path: &Path,
648 exemptions: ExemptSelect,
649) -> anyhow::Result<Vec<lint::Violation>> {
650 use std::collections::hash_map::Entry;
651
652 if !config_path.exists() {
653 return Ok(violations);
654 }
655 let config = config::load_config(config_path)?;
656 let exempt = exemptions(&config);
657 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
659 std::collections::HashMap::new();
660 let mut kept = Vec::new();
661 for violation in violations {
662 let waived = match config::Rule::from_id(violation.rule) {
663 Some(rule) => {
664 let exempt_paths = match resolved.entry(rule) {
665 Entry::Occupied(entry) => entry.into_mut(),
666 Entry::Vacant(entry) => {
667 entry.insert(config::resolve_exempt(root, exempt, rule)?)
668 }
669 };
670 violation
671 .file
672 .strip_prefix(root)
673 .ok()
674 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
675 .is_some_and(|rel| exempt_paths.contains(&rel))
676 }
677 None => false,
678 };
679 if !waived {
680 kept.push(violation);
681 }
682 }
683 Ok(kept)
684}
685
686fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
695 let globs = match language {
696 colocated_test::Language::Python => vec!["*_test.py".to_string()],
697 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
698 colocated_test::Language::Rust => vec!["tests/".to_string()],
701 };
702 let offenders = packaging::inspect(artifact, &globs)?;
703 if offenders.is_empty() {
704 return Ok(0);
705 }
706 for offender in &offenders {
707 eprintln!("test file in built artifact: {}", offender.display());
708 }
709 eprintln!(
710 "error: {} test file(s) present in the built artifact \
711 (they must be excluded from packaging)",
712 offenders.len()
713 );
714 Ok(1)
715}
716
717fn run_workflow(path: &Path) -> anyhow::Result<i32> {
722 let violations = workflow::check(path, &command())?;
723 if violations.is_empty() {
724 return Ok(0);
725 }
726 for v in &violations {
727 eprintln!(
728 "{}:{}: {} — {}",
729 v.file.display(),
730 v.line,
731 v.rule,
732 v.message
733 );
734 }
735 eprintln!(
736 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
737 violations.len()
738 );
739 Ok(1)
740}
741
742fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
746 let repo = std::env::current_dir()?;
747 let attestation = e2e::attest(&repo, command)?;
748 println!(
749 "e2e attestation recorded for commit {} (command exited {})",
750 attestation.commit, attestation.exit_code
751 );
752 Ok(0)
753}
754
755fn run_e2e_verify() -> anyhow::Result<i32> {
759 let repo = std::env::current_dir()?;
760 match e2e::verify(&repo)? {
761 e2e::Verification::Fresh => Ok(0),
762 e2e::Verification::Missing => {
763 eprintln!(
764 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
765 );
766 Ok(1)
767 }
768 e2e::Verification::Stale { attested, latest } => {
769 eprintln!(
770 "e2e attestation out of date: attested {}, latest code commit {} — \
771 run `testing-conventions e2e attest '<your e2e command>'`",
772 &attested[..attested.len().min(7)],
773 &latest[..latest.len().min(7)]
774 );
775 Ok(1)
776 }
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
785 fn no_args_returns_ok_zero() {
786 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
787 }
788
789 #[test]
790 fn check_returns_ok_zero() {
791 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
792 }
793
794 #[test]
795 fn unknown_flag_errors() {
796 assert!(run(["testing-conventions", "--bogus"]).is_err());
797 }
798
799 #[test]
800 fn help_flag_returns_clap_display_help() {
801 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
802 let clap_err = err
803 .downcast_ref::<clap::Error>()
804 .expect("error should be a clap::Error");
805 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
806 }
807
808 #[test]
809 fn version_flag_returns_clap_display_version() {
810 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
811 let clap_err = err
812 .downcast_ref::<clap::Error>()
813 .expect("error should be a clap::Error");
814 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
815 }
816}