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 match language {
514 colocated_test::Language::Rust => {}
515 _ => anyhow::bail!(
516 "`unit mutation` supports `--language rust` only for now — \
517 TypeScript and Python land with the mutation epic (#199)"
518 ),
519 }
520 let config = if config_path.exists() {
521 config::load_config(config_path)?
522 } else {
523 config::Config::default()
524 };
525 let rust = config.rust.unwrap_or_default();
526 let exempt: Vec<String> = config::resolve_exempt(root, &rust.exempt, config::Rule::Mutation)?
527 .into_iter()
528 .collect();
529
530 let survivors = mutation::measure_rust(root, &exempt, base)?;
531 if survivors.is_empty() {
532 println!("unit mutation: no surviving mutants — every mutation was caught");
533 return Ok(0);
534 }
535
536 eprintln!(
537 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
538 equivalent/defensive one with a reason-required `[[rust.exempt]] rules = [\"mutation\"]`:",
539 survivors.len()
540 );
541 for survivor in &survivors {
542 eprintln!(
543 " {}:{}: {}",
544 survivor.file, survivor.line, survivor.description
545 );
546 }
547 Ok(1)
548}
549
550fn run_unit_lint(
555 root: &Path,
556 language: isolation::Language,
557 config_path: &Path,
558) -> anyhow::Result<i32> {
559 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
560 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
561 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
562 c.exemptions(colocated_test::Language::TypeScript)
563 }),
564 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
565 c.exemptions(colocated_test::Language::Python)
566 }),
567 };
568 let violations = apply_waivers(raw, root, config_path, select)?;
569 if violations.is_empty() {
570 return Ok(0);
571 }
572 for v in &violations {
573 eprintln!(
574 "{}:{}: {} — {}",
575 v.file.display(),
576 v.line,
577 v.rule,
578 v.message
579 );
580 }
581 eprintln!("error: {} isolation violation(s)", violations.len());
582 Ok(1)
583}
584
585fn run_integration_lint(
589 root: &Path,
590 language: IntegrationLintLanguage,
591 config_path: &Path,
592) -> anyhow::Result<i32> {
593 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
594 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
595 c.exemptions(colocated_test::Language::Python)
596 }),
597 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
598 c.exemptions(colocated_test::Language::TypeScript)
599 }),
600 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
601 c.rust_exemptions()
602 }),
603 };
604 let violations = apply_waivers(raw, root, config_path, select)?;
605 if violations.is_empty() {
606 return Ok(0);
607 }
608 for v in &violations {
609 eprintln!(
610 "{}:{}: {} — {}",
611 v.file.display(),
612 v.line,
613 v.rule,
614 v.message
615 );
616 }
617 eprintln!("error: {} lint violation(s)", violations.len());
618 Ok(1)
619}
620
621type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
624
625fn apply_waivers(
632 violations: Vec<lint::Violation>,
633 root: &Path,
634 config_path: &Path,
635 exemptions: ExemptSelect,
636) -> anyhow::Result<Vec<lint::Violation>> {
637 use std::collections::hash_map::Entry;
638
639 if !config_path.exists() {
640 return Ok(violations);
641 }
642 let config = config::load_config(config_path)?;
643 let exempt = exemptions(&config);
644 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
646 std::collections::HashMap::new();
647 let mut kept = Vec::new();
648 for violation in violations {
649 let waived = match config::Rule::from_id(violation.rule) {
650 Some(rule) => {
651 let exempt_paths = match resolved.entry(rule) {
652 Entry::Occupied(entry) => entry.into_mut(),
653 Entry::Vacant(entry) => {
654 entry.insert(config::resolve_exempt(root, exempt, rule)?)
655 }
656 };
657 violation
658 .file
659 .strip_prefix(root)
660 .ok()
661 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
662 .is_some_and(|rel| exempt_paths.contains(&rel))
663 }
664 None => false,
665 };
666 if !waived {
667 kept.push(violation);
668 }
669 }
670 Ok(kept)
671}
672
673fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
682 let globs = match language {
683 colocated_test::Language::Python => vec!["*_test.py".to_string()],
684 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
685 colocated_test::Language::Rust => vec!["tests/".to_string()],
688 };
689 let offenders = packaging::inspect(artifact, &globs)?;
690 if offenders.is_empty() {
691 return Ok(0);
692 }
693 for offender in &offenders {
694 eprintln!("test file in built artifact: {}", offender.display());
695 }
696 eprintln!(
697 "error: {} test file(s) present in the built artifact \
698 (they must be excluded from packaging)",
699 offenders.len()
700 );
701 Ok(1)
702}
703
704fn run_workflow(path: &Path) -> anyhow::Result<i32> {
709 let violations = workflow::check(path, &command())?;
710 if violations.is_empty() {
711 return Ok(0);
712 }
713 for v in &violations {
714 eprintln!(
715 "{}:{}: {} — {}",
716 v.file.display(),
717 v.line,
718 v.rule,
719 v.message
720 );
721 }
722 eprintln!(
723 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
724 violations.len()
725 );
726 Ok(1)
727}
728
729fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
733 let repo = std::env::current_dir()?;
734 let attestation = e2e::attest(&repo, command)?;
735 println!(
736 "e2e attestation recorded for commit {} (command exited {})",
737 attestation.commit, attestation.exit_code
738 );
739 Ok(0)
740}
741
742fn run_e2e_verify() -> anyhow::Result<i32> {
746 let repo = std::env::current_dir()?;
747 match e2e::verify(&repo)? {
748 e2e::Verification::Fresh => Ok(0),
749 e2e::Verification::Missing => {
750 eprintln!(
751 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
752 );
753 Ok(1)
754 }
755 e2e::Verification::Stale { attested, latest } => {
756 eprintln!(
757 "e2e attestation out of date: attested {}, latest code commit {} — \
758 run `testing-conventions e2e attest '<your e2e command>'`",
759 &attested[..attested.len().min(7)],
760 &latest[..latest.len().min(7)]
761 );
762 Ok(1)
763 }
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770
771 #[test]
772 fn no_args_returns_ok_zero() {
773 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
774 }
775
776 #[test]
777 fn check_returns_ok_zero() {
778 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
779 }
780
781 #[test]
782 fn unknown_flag_errors() {
783 assert!(run(["testing-conventions", "--bogus"]).is_err());
784 }
785
786 #[test]
787 fn help_flag_returns_clap_display_help() {
788 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
789 let clap_err = err
790 .downcast_ref::<clap::Error>()
791 .expect("error should be a clap::Error");
792 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
793 }
794
795 #[test]
796 fn version_flag_returns_clap_display_version() {
797 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
798 let clap_err = err
799 .downcast_ref::<clap::Error>()
800 .expect("error should be a clap::Error");
801 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
802 }
803
804 #[test]
805 fn unit_mutation_rejects_a_non_rust_language() {
806 let err = run([
810 "testing-conventions",
811 "unit",
812 "mutation",
813 "pkg",
814 "--language",
815 "python",
816 ])
817 .unwrap_err();
818 assert!(
819 err.to_string().contains("supports `--language rust` only"),
820 "got: {err}"
821 );
822 }
823}