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 split_scopes(
409 scopes: std::collections::BTreeMap<String, config::LineScope>,
410) -> (
411 Vec<String>,
412 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
413) {
414 let mut whole_file = Vec::new();
415 let mut line_scoped = std::collections::BTreeMap::new();
416 for (path, scope) in scopes {
417 match scope {
418 config::LineScope::WholeFile => whole_file.push(path),
419 config::LineScope::Lines(lines) => {
420 line_scoped.insert(path, lines);
421 }
422 }
423 }
424 (whole_file, line_scoped)
425}
426
427fn run_unit_coverage(
445 root: &Path,
446 language: colocated_test::Language,
447 base: Option<&str>,
448 config_path: &Path,
449) -> anyhow::Result<i32> {
450 let config = if config_path.exists() {
451 config::load_config(config_path)?
452 } else {
453 config::Config::default()
454 };
455 let outcome = match language {
456 colocated_test::Language::Python => {
457 let python = config.python.unwrap_or_default();
458 let coverage = python.coverage.unwrap_or_default();
459 let thresholds = coverage::Thresholds {
460 fail_under: coverage.fail_under,
461 branch: coverage.branch,
462 };
463 let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
464 root,
465 &python.exempt,
466 config::Rule::Coverage,
467 )?);
468 match base {
469 Some(base) => {
470 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
471 }
472 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
473 None => {
474 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
475 }
476 }
477 }
478 colocated_test::Language::TypeScript => {
479 let typescript = config.typescript.unwrap_or_default();
480 let coverage = typescript.coverage.unwrap_or_default();
481 let thresholds = coverage::TypeScriptThresholds {
482 lines: coverage.lines,
483 branches: coverage.branches,
484 functions: coverage.functions,
485 statements: coverage.statements,
486 };
487 let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
488 root,
489 &typescript.exempt,
490 config::Rule::Coverage,
491 )?);
492 match base {
493 Some(base) => patch_coverage::measure_typescript(
494 root,
495 base,
496 thresholds,
497 &exclude,
498 &exempt_lines,
499 )?,
500 None if exempt_lines.is_empty() => {
501 coverage::measure_typescript(root, thresholds, &exclude)?
502 }
503 None => patch_coverage::measure_line_exempt_typescript(
504 root,
505 thresholds,
506 &exclude,
507 &exempt_lines,
508 )?,
509 }
510 }
511 colocated_test::Language::Rust => {
512 let rust = config.rust.unwrap_or_default();
513 let coverage = rust.coverage.unwrap_or_default();
518 let thresholds = coverage::RustThresholds {
519 regions: coverage.regions,
520 lines: coverage.lines,
521 };
522 let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
523 root,
524 &rust.exempt,
525 config::Rule::Coverage,
526 )?);
527 match base {
528 Some(base) => {
529 patch_coverage::measure_rust(root, base, thresholds, &ignore, &exempt_lines)?
530 }
531 None if exempt_lines.is_empty() => {
532 coverage::measure_rust(root, thresholds, &ignore)?
533 }
534 None => patch_coverage::measure_line_exempt_rust(
535 root,
536 thresholds,
537 &ignore,
538 &exempt_lines,
539 )?,
540 }
541 }
542 };
543 match outcome {
544 coverage::Outcome::Pass => Ok(0),
545 coverage::Outcome::Fail(reason) => {
546 eprintln!("error: coverage check failed — {reason}");
547 Ok(1)
548 }
549 }
550}
551
552fn run_unit_mutation(
563 root: &Path,
564 language: colocated_test::Language,
565 base: Option<&str>,
566 config_path: &Path,
567) -> anyhow::Result<i32> {
568 let config = if config_path.exists() {
569 config::load_config(config_path)?
570 } else {
571 config::Config::default()
572 };
573 let survivors = match language {
574 colocated_test::Language::Rust => {
575 let rust = config.rust.unwrap_or_default();
576 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
577 root,
578 &rust.exempt,
579 config::Rule::Mutation,
580 )?);
581 mutation::measure_rust(root, &exempt, &exempt_lines, base)?
582 }
583 colocated_test::Language::TypeScript => {
584 let typescript = config.typescript.unwrap_or_default();
585 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
586 root,
587 &typescript.exempt,
588 config::Rule::Mutation,
589 )?);
590 mutation::measure_typescript(root, &exempt, &exempt_lines, base)?
591 }
592 colocated_test::Language::Python => {
593 let python = config.python.unwrap_or_default();
594 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
595 root,
596 &python.exempt,
597 config::Rule::Mutation,
598 )?);
599 mutation::measure_python(root, &exempt, &exempt_lines, base)?
600 }
601 };
602 if survivors.is_empty() {
603 println!("unit mutation: no surviving mutants — every mutation was caught");
604 return Ok(0);
605 }
606
607 eprintln!(
608 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
609 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
610 survivors.len()
611 );
612 for survivor in &survivors {
613 eprintln!(
614 " {}:{}: {}",
615 survivor.file, survivor.line, survivor.description
616 );
617 }
618 Ok(1)
619}
620
621fn run_unit_lint(
626 root: &Path,
627 language: isolation::Language,
628 config_path: &Path,
629) -> anyhow::Result<i32> {
630 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
631 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
632 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
633 c.exemptions(colocated_test::Language::TypeScript)
634 }),
635 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
636 c.exemptions(colocated_test::Language::Python)
637 }),
638 };
639 let violations = apply_waivers(raw, root, config_path, select)?;
640 if violations.is_empty() {
641 return Ok(0);
642 }
643 for v in &violations {
644 eprintln!(
645 "{}:{}: {} — {}",
646 v.file.display(),
647 v.line,
648 v.rule,
649 v.message
650 );
651 }
652 eprintln!("error: {} isolation violation(s)", violations.len());
653 Ok(1)
654}
655
656fn run_integration_lint(
660 root: &Path,
661 language: IntegrationLintLanguage,
662 config_path: &Path,
663) -> anyhow::Result<i32> {
664 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
665 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
666 c.exemptions(colocated_test::Language::Python)
667 }),
668 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
669 c.exemptions(colocated_test::Language::TypeScript)
670 }),
671 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
672 c.rust_exemptions()
673 }),
674 };
675 let violations = apply_waivers(raw, root, config_path, select)?;
676 if violations.is_empty() {
677 return Ok(0);
678 }
679 for v in &violations {
680 eprintln!(
681 "{}:{}: {} — {}",
682 v.file.display(),
683 v.line,
684 v.rule,
685 v.message
686 );
687 }
688 eprintln!("error: {} lint violation(s)", violations.len());
689 Ok(1)
690}
691
692type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
695
696fn apply_waivers(
703 violations: Vec<lint::Violation>,
704 root: &Path,
705 config_path: &Path,
706 exemptions: ExemptSelect,
707) -> anyhow::Result<Vec<lint::Violation>> {
708 use std::collections::hash_map::Entry;
709
710 if !config_path.exists() {
711 return Ok(violations);
712 }
713 let config = config::load_config(config_path)?;
714 let exempt = exemptions(&config);
715 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
717 std::collections::HashMap::new();
718 let mut kept = Vec::new();
719 for violation in violations {
720 let waived = match config::Rule::from_id(violation.rule) {
721 Some(rule) => {
722 let exempt_paths = match resolved.entry(rule) {
723 Entry::Occupied(entry) => entry.into_mut(),
724 Entry::Vacant(entry) => {
725 entry.insert(config::resolve_exempt(root, exempt, rule)?)
726 }
727 };
728 violation
729 .file
730 .strip_prefix(root)
731 .ok()
732 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
733 .is_some_and(|rel| exempt_paths.contains(&rel))
734 }
735 None => false,
736 };
737 if !waived {
738 kept.push(violation);
739 }
740 }
741 Ok(kept)
742}
743
744fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
753 let globs = match language {
754 colocated_test::Language::Python => vec!["*_test.py".to_string()],
755 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
756 colocated_test::Language::Rust => vec!["tests/".to_string()],
759 };
760 let offenders = packaging::inspect(artifact, &globs)?;
761 if offenders.is_empty() {
762 return Ok(0);
763 }
764 for offender in &offenders {
765 eprintln!("test file in built artifact: {}", offender.display());
766 }
767 eprintln!(
768 "error: {} test file(s) present in the built artifact \
769 (they must be excluded from packaging)",
770 offenders.len()
771 );
772 Ok(1)
773}
774
775fn run_workflow(path: &Path) -> anyhow::Result<i32> {
780 let violations = workflow::check(path, &command())?;
781 if violations.is_empty() {
782 return Ok(0);
783 }
784 for v in &violations {
785 eprintln!(
786 "{}:{}: {} — {}",
787 v.file.display(),
788 v.line,
789 v.rule,
790 v.message
791 );
792 }
793 eprintln!(
794 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
795 violations.len()
796 );
797 Ok(1)
798}
799
800fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
804 let repo = std::env::current_dir()?;
805 let attestation = e2e::attest(&repo, command)?;
806 println!(
807 "e2e attestation recorded for commit {} (command exited {})",
808 attestation.commit, attestation.exit_code
809 );
810 Ok(0)
811}
812
813fn run_e2e_verify() -> anyhow::Result<i32> {
817 let repo = std::env::current_dir()?;
818 match e2e::verify(&repo)? {
819 e2e::Verification::Fresh => Ok(0),
820 e2e::Verification::Missing => {
821 eprintln!(
822 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
823 );
824 Ok(1)
825 }
826 e2e::Verification::Stale { attested, latest } => {
827 eprintln!(
828 "e2e attestation out of date: attested {}, latest code commit {} — \
829 run `testing-conventions e2e attest '<your e2e command>'`",
830 &attested[..attested.len().min(7)],
831 &latest[..latest.len().min(7)]
832 );
833 Ok(1)
834 }
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 use super::*;
841
842 #[test]
843 fn no_args_returns_ok_zero() {
844 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
845 }
846
847 #[test]
848 fn check_returns_ok_zero() {
849 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
850 }
851
852 #[test]
853 fn unknown_flag_errors() {
854 assert!(run(["testing-conventions", "--bogus"]).is_err());
855 }
856
857 #[test]
858 fn help_flag_returns_clap_display_help() {
859 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
860 let clap_err = err
861 .downcast_ref::<clap::Error>()
862 .expect("error should be a clap::Error");
863 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
864 }
865
866 #[test]
867 fn version_flag_returns_clap_display_version() {
868 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
869 let clap_err = err
870 .downcast_ref::<clap::Error>()
871 .expect("error should be a clap::Error");
872 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
873 }
874}