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 => anyhow::bail!(
536 "`unit mutation` doesn't support Python yet — it lands with the mutation epic (#203)"
537 ),
538 };
539 if survivors.is_empty() {
540 println!("unit mutation: no surviving mutants — every mutation was caught");
541 return Ok(0);
542 }
543
544 eprintln!(
545 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
546 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
547 survivors.len()
548 );
549 for survivor in &survivors {
550 eprintln!(
551 " {}:{}: {}",
552 survivor.file, survivor.line, survivor.description
553 );
554 }
555 Ok(1)
556}
557
558fn run_unit_lint(
563 root: &Path,
564 language: isolation::Language,
565 config_path: &Path,
566) -> anyhow::Result<i32> {
567 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
568 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
569 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
570 c.exemptions(colocated_test::Language::TypeScript)
571 }),
572 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
573 c.exemptions(colocated_test::Language::Python)
574 }),
575 };
576 let violations = apply_waivers(raw, root, config_path, select)?;
577 if violations.is_empty() {
578 return Ok(0);
579 }
580 for v in &violations {
581 eprintln!(
582 "{}:{}: {} — {}",
583 v.file.display(),
584 v.line,
585 v.rule,
586 v.message
587 );
588 }
589 eprintln!("error: {} isolation violation(s)", violations.len());
590 Ok(1)
591}
592
593fn run_integration_lint(
597 root: &Path,
598 language: IntegrationLintLanguage,
599 config_path: &Path,
600) -> anyhow::Result<i32> {
601 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
602 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
603 c.exemptions(colocated_test::Language::Python)
604 }),
605 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
606 c.exemptions(colocated_test::Language::TypeScript)
607 }),
608 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
609 c.rust_exemptions()
610 }),
611 };
612 let violations = apply_waivers(raw, root, config_path, select)?;
613 if violations.is_empty() {
614 return Ok(0);
615 }
616 for v in &violations {
617 eprintln!(
618 "{}:{}: {} — {}",
619 v.file.display(),
620 v.line,
621 v.rule,
622 v.message
623 );
624 }
625 eprintln!("error: {} lint violation(s)", violations.len());
626 Ok(1)
627}
628
629type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
632
633fn apply_waivers(
640 violations: Vec<lint::Violation>,
641 root: &Path,
642 config_path: &Path,
643 exemptions: ExemptSelect,
644) -> anyhow::Result<Vec<lint::Violation>> {
645 use std::collections::hash_map::Entry;
646
647 if !config_path.exists() {
648 return Ok(violations);
649 }
650 let config = config::load_config(config_path)?;
651 let exempt = exemptions(&config);
652 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
654 std::collections::HashMap::new();
655 let mut kept = Vec::new();
656 for violation in violations {
657 let waived = match config::Rule::from_id(violation.rule) {
658 Some(rule) => {
659 let exempt_paths = match resolved.entry(rule) {
660 Entry::Occupied(entry) => entry.into_mut(),
661 Entry::Vacant(entry) => {
662 entry.insert(config::resolve_exempt(root, exempt, rule)?)
663 }
664 };
665 violation
666 .file
667 .strip_prefix(root)
668 .ok()
669 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
670 .is_some_and(|rel| exempt_paths.contains(&rel))
671 }
672 None => false,
673 };
674 if !waived {
675 kept.push(violation);
676 }
677 }
678 Ok(kept)
679}
680
681fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
690 let globs = match language {
691 colocated_test::Language::Python => vec!["*_test.py".to_string()],
692 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
693 colocated_test::Language::Rust => vec!["tests/".to_string()],
696 };
697 let offenders = packaging::inspect(artifact, &globs)?;
698 if offenders.is_empty() {
699 return Ok(0);
700 }
701 for offender in &offenders {
702 eprintln!("test file in built artifact: {}", offender.display());
703 }
704 eprintln!(
705 "error: {} test file(s) present in the built artifact \
706 (they must be excluded from packaging)",
707 offenders.len()
708 );
709 Ok(1)
710}
711
712fn run_workflow(path: &Path) -> anyhow::Result<i32> {
717 let violations = workflow::check(path, &command())?;
718 if violations.is_empty() {
719 return Ok(0);
720 }
721 for v in &violations {
722 eprintln!(
723 "{}:{}: {} — {}",
724 v.file.display(),
725 v.line,
726 v.rule,
727 v.message
728 );
729 }
730 eprintln!(
731 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
732 violations.len()
733 );
734 Ok(1)
735}
736
737fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
741 let repo = std::env::current_dir()?;
742 let attestation = e2e::attest(&repo, command)?;
743 println!(
744 "e2e attestation recorded for commit {} (command exited {})",
745 attestation.commit, attestation.exit_code
746 );
747 Ok(0)
748}
749
750fn run_e2e_verify() -> anyhow::Result<i32> {
754 let repo = std::env::current_dir()?;
755 match e2e::verify(&repo)? {
756 e2e::Verification::Fresh => Ok(0),
757 e2e::Verification::Missing => {
758 eprintln!(
759 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
760 );
761 Ok(1)
762 }
763 e2e::Verification::Stale { attested, latest } => {
764 eprintln!(
765 "e2e attestation out of date: attested {}, latest code commit {} — \
766 run `testing-conventions e2e attest '<your e2e command>'`",
767 &attested[..attested.len().min(7)],
768 &latest[..latest.len().min(7)]
769 );
770 Ok(1)
771 }
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use super::*;
778
779 #[test]
780 fn no_args_returns_ok_zero() {
781 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
782 }
783
784 #[test]
785 fn check_returns_ok_zero() {
786 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
787 }
788
789 #[test]
790 fn unknown_flag_errors() {
791 assert!(run(["testing-conventions", "--bogus"]).is_err());
792 }
793
794 #[test]
795 fn help_flag_returns_clap_display_help() {
796 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
797 let clap_err = err
798 .downcast_ref::<clap::Error>()
799 .expect("error should be a clap::Error");
800 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
801 }
802
803 #[test]
804 fn version_flag_returns_clap_display_version() {
805 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
806 let clap_err = err
807 .downcast_ref::<clap::Error>()
808 .expect("error should be a clap::Error");
809 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
810 }
811
812 #[test]
813 fn unit_mutation_rejects_python_until_parity() {
814 let err = run([
818 "testing-conventions",
819 "unit",
820 "mutation",
821 "pkg",
822 "--language",
823 "python",
824 ])
825 .unwrap_err();
826 assert!(
827 err.to_string().contains("doesn't support Python yet"),
828 "got: {err}"
829 );
830 }
831}