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 #[command(hide = true)]
57 Workflow {
58 path: PathBuf,
60 },
61 E2e {
63 #[command(subcommand)]
64 command: E2eCommand,
65 },
66}
67
68#[derive(Subcommand, Debug)]
70enum UnitRule {
71 ColocatedTest {
77 path: PathBuf,
79 #[arg(long, value_enum)]
81 language: colocated_test::Language,
82 #[arg(long)]
88 base: Option<String>,
89 #[arg(long, default_value = "testing-conventions.toml")]
92 config: PathBuf,
93 },
94 Coverage {
99 path: PathBuf,
101 #[arg(long, value_enum)]
103 language: colocated_test::Language,
104 #[arg(long)]
110 base: Option<String>,
111 #[arg(long, default_value = "testing-conventions.toml")]
116 config: PathBuf,
117 },
118 Lint {
120 path: PathBuf,
122 #[arg(long, value_enum)]
124 language: isolation::Language,
125 #[arg(long, default_value = "testing-conventions.toml")]
128 config: PathBuf,
129 },
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
136pub enum IntegrationLintLanguage {
137 #[value(name = "python")]
139 Python,
140 #[value(name = "typescript")]
142 TypeScript,
143 #[value(name = "rust")]
145 Rust,
146}
147
148#[derive(Subcommand, Debug)]
151enum IntegrationRule {
152 Lint {
154 path: PathBuf,
156 #[arg(long, value_enum)]
158 language: IntegrationLintLanguage,
159 #[arg(long, default_value = "testing-conventions.toml")]
162 config: PathBuf,
163 },
164}
165
166#[derive(Subcommand, Debug)]
169enum E2eCommand {
170 Attest {
172 command: String,
174 },
175 Verify,
177}
178
179pub fn run<I, T>(args: I) -> anyhow::Result<i32>
180where
181 I: IntoIterator<Item = T>,
182 T: Into<std::ffi::OsString> + Clone,
183{
184 let cli = Cli::try_parse_from(args)?;
185 match cli.command {
186 Some(Command::Check) | None => Ok(0),
190 Some(Command::Unit { rule }) => match rule {
191 UnitRule::ColocatedTest {
192 path,
193 language,
194 base,
195 config,
196 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
197 UnitRule::Coverage {
198 path,
199 language,
200 base,
201 config,
202 } => run_unit_coverage(&path, language, base.as_deref(), &config),
203 UnitRule::Lint {
204 path,
205 language,
206 config,
207 } => run_unit_lint(&path, language, &config),
208 },
209 Some(Command::Integration { rule }) => match rule {
210 IntegrationRule::Lint {
211 path,
212 language,
213 config,
214 } => run_integration_lint(&path, language, &config),
215 },
216 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
217 Some(Command::Workflow { path }) => run_workflow(&path),
218 Some(Command::E2e { command }) => match command {
219 E2eCommand::Attest { command } => run_e2e_attest(&command),
220 E2eCommand::Verify => run_e2e_verify(),
221 },
222 }
223}
224
225pub fn command() -> clap::Command {
229 Cli::command()
230}
231
232fn run_unit_colocated_test(
245 root: &Path,
246 language: colocated_test::Language,
247 base: Option<&str>,
248 config_path: &Path,
249) -> anyhow::Result<i32> {
250 if base.is_some() && language == colocated_test::Language::Rust {
253 anyhow::bail!(
254 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
255 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
256 );
257 }
258 let presence_clean = report_colocated_presence(root, language, config_path)?;
259 let co_change_clean = match base {
260 Some(base) => report_co_change(root, base, language, config_path)?,
261 None => true,
262 };
263 Ok(if presence_clean && co_change_clean {
264 0
265 } else {
266 1
267 })
268}
269
270fn report_colocated_presence(
276 root: &Path,
277 language: colocated_test::Language,
278 config_path: &Path,
279) -> anyhow::Result<bool> {
280 let exempt = colocated_test_exemptions(root, language, config_path)?;
281 let orphans = match language {
282 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
285 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
286 };
287 if orphans.is_empty() {
288 return Ok(true);
289 }
290 let (label, summary) = match language {
291 colocated_test::Language::Rust => (
292 "missing inline `#[cfg(test)]` tests",
293 "source file(s) with testable code but no inline `#[cfg(test)]` module \
294 (add an inline test module, or an `exempt` entry with a reason)",
295 ),
296 _ => (
297 "missing colocated unit test",
298 "source file(s) missing a colocated unit test \
299 (add a colocated test, or an `exempt` entry with a reason)",
300 ),
301 };
302 for orphan in &orphans {
303 eprintln!("{label}: {}", orphan.display());
304 }
305 eprintln!("error: {} {summary}", orphans.len());
306 Ok(false)
307}
308
309fn colocated_test_exemptions(
313 root: &Path,
314 language: colocated_test::Language,
315 config_path: &Path,
316) -> anyhow::Result<std::collections::BTreeSet<String>> {
317 if !config_path.exists() {
318 return Ok(std::collections::BTreeSet::new());
319 }
320 let config = config::load_config(config_path)?;
321 config::resolve_exempt(
322 root,
323 config.exemptions(language),
324 config::Rule::ColocatedTest,
325 )
326}
327
328fn report_co_change(
338 root: &Path,
339 base: &str,
340 language: colocated_test::Language,
341 config_path: &Path,
342) -> anyhow::Result<bool> {
343 let exempt = co_change_exemptions(root, language, config_path)?;
344 let stale = co_change::stale_sources(root, base, language, &exempt)?;
345 if stale.is_empty() {
346 return Ok(true);
347 }
348 for source in &stale {
349 eprintln!(
350 "source changed without its colocated test: {}",
351 source.display()
352 );
353 }
354 eprintln!(
355 "error: {} source file(s) changed without their colocated test co-changing \
356 (update the test, or add an `exempt` entry with a reason)",
357 stale.len()
358 );
359 Ok(false)
360}
361
362fn co_change_exemptions(
366 root: &Path,
367 language: colocated_test::Language,
368 config_path: &Path,
369) -> anyhow::Result<std::collections::BTreeSet<String>> {
370 if !config_path.exists() {
371 return Ok(std::collections::BTreeSet::new());
372 }
373 let config = config::load_config(config_path)?;
374 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
375}
376
377fn run_unit_coverage(
395 root: &Path,
396 language: colocated_test::Language,
397 base: Option<&str>,
398 config_path: &Path,
399) -> anyhow::Result<i32> {
400 let config = if config_path.exists() {
401 config::load_config(config_path)?
402 } else {
403 config::Config::default()
404 };
405 let outcome = match language {
406 colocated_test::Language::Python => {
407 let python = config.python.unwrap_or_default();
408 let coverage = python.coverage.unwrap_or_default();
409 let thresholds = coverage::Thresholds {
410 fail_under: coverage.fail_under,
411 branch: coverage.branch,
412 };
413 let omit: Vec<String> =
414 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
415 .into_iter()
416 .collect();
417 match base {
418 Some(base) => patch_coverage::measure(root, base, thresholds, &omit)?,
419 None => coverage::measure(root, thresholds, &omit)?,
420 }
421 }
422 colocated_test::Language::TypeScript => {
423 let typescript = config.typescript.unwrap_or_default();
424 let coverage = typescript.coverage.unwrap_or_default();
425 let thresholds = coverage::TypeScriptThresholds {
426 lines: coverage.lines,
427 branches: coverage.branches,
428 functions: coverage.functions,
429 statements: coverage.statements,
430 };
431 let exclude: Vec<String> =
432 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
433 .into_iter()
434 .collect();
435 match base {
436 Some(base) => patch_coverage::measure_typescript(root, base, thresholds, &exclude)?,
437 None => coverage::measure_typescript(root, thresholds, &exclude)?,
438 }
439 }
440 colocated_test::Language::Rust => {
441 let rust = config.rust.unwrap_or_default();
442 let coverage = rust.coverage.ok_or_else(|| {
446 anyhow::anyhow!(
447 "Rust coverage needs a `[rust].coverage` table (regions / lines) in `{}` — \
448 there is no zero-config default floor for Rust yet",
449 config_path.display()
450 )
451 })?;
452 let thresholds = coverage::RustThresholds {
453 regions: coverage.regions,
454 lines: coverage.lines,
455 };
456 let ignore: Vec<String> =
457 config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
458 .into_iter()
459 .collect();
460 match base {
461 Some(base) => patch_coverage::measure_rust(root, base, thresholds, &ignore)?,
462 None => coverage::measure_rust(root, thresholds, &ignore)?,
463 }
464 }
465 };
466 match outcome {
467 coverage::Outcome::Pass => Ok(0),
468 coverage::Outcome::Fail(reason) => {
469 eprintln!("error: coverage check failed — {reason}");
470 Ok(1)
471 }
472 }
473}
474
475fn run_unit_lint(
480 root: &Path,
481 language: isolation::Language,
482 config_path: &Path,
483) -> anyhow::Result<i32> {
484 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
485 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
486 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
487 c.exemptions(colocated_test::Language::TypeScript)
488 }),
489 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
490 c.exemptions(colocated_test::Language::Python)
491 }),
492 };
493 let violations = apply_waivers(raw, root, config_path, select)?;
494 if violations.is_empty() {
495 return Ok(0);
496 }
497 for v in &violations {
498 eprintln!(
499 "{}:{}: {} — {}",
500 v.file.display(),
501 v.line,
502 v.rule,
503 v.message
504 );
505 }
506 eprintln!("error: {} isolation violation(s)", violations.len());
507 Ok(1)
508}
509
510fn run_integration_lint(
514 root: &Path,
515 language: IntegrationLintLanguage,
516 config_path: &Path,
517) -> anyhow::Result<i32> {
518 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
519 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
520 c.exemptions(colocated_test::Language::Python)
521 }),
522 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
523 c.exemptions(colocated_test::Language::TypeScript)
524 }),
525 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
526 c.rust_exemptions()
527 }),
528 };
529 let violations = apply_waivers(raw, root, config_path, select)?;
530 if violations.is_empty() {
531 return Ok(0);
532 }
533 for v in &violations {
534 eprintln!(
535 "{}:{}: {} — {}",
536 v.file.display(),
537 v.line,
538 v.rule,
539 v.message
540 );
541 }
542 eprintln!("error: {} lint violation(s)", violations.len());
543 Ok(1)
544}
545
546type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
549
550fn apply_waivers(
557 violations: Vec<lint::Violation>,
558 root: &Path,
559 config_path: &Path,
560 exemptions: ExemptSelect,
561) -> anyhow::Result<Vec<lint::Violation>> {
562 use std::collections::hash_map::Entry;
563
564 if !config_path.exists() {
565 return Ok(violations);
566 }
567 let config = config::load_config(config_path)?;
568 let exempt = exemptions(&config);
569 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
571 std::collections::HashMap::new();
572 let mut kept = Vec::new();
573 for violation in violations {
574 let waived = match config::Rule::from_id(violation.rule) {
575 Some(rule) => {
576 let exempt_paths = match resolved.entry(rule) {
577 Entry::Occupied(entry) => entry.into_mut(),
578 Entry::Vacant(entry) => {
579 entry.insert(config::resolve_exempt(root, exempt, rule)?)
580 }
581 };
582 violation
583 .file
584 .strip_prefix(root)
585 .ok()
586 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
587 .is_some_and(|rel| exempt_paths.contains(&rel))
588 }
589 None => false,
590 };
591 if !waived {
592 kept.push(violation);
593 }
594 }
595 Ok(kept)
596}
597
598fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
607 let globs = match language {
608 colocated_test::Language::Python => vec!["*_test.py".to_string()],
609 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
610 colocated_test::Language::Rust => vec!["tests/".to_string()],
613 };
614 let offenders = packaging::inspect(artifact, &globs)?;
615 if offenders.is_empty() {
616 return Ok(0);
617 }
618 for offender in &offenders {
619 eprintln!("test file in built artifact: {}", offender.display());
620 }
621 eprintln!(
622 "error: {} test file(s) present in the built artifact \
623 (they must be excluded from packaging)",
624 offenders.len()
625 );
626 Ok(1)
627}
628
629fn run_workflow(path: &Path) -> anyhow::Result<i32> {
634 let violations = workflow::check(path, &command())?;
635 if violations.is_empty() {
636 return Ok(0);
637 }
638 for v in &violations {
639 eprintln!(
640 "{}:{}: {} — {}",
641 v.file.display(),
642 v.line,
643 v.rule,
644 v.message
645 );
646 }
647 eprintln!(
648 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
649 violations.len()
650 );
651 Ok(1)
652}
653
654fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
658 let repo = std::env::current_dir()?;
659 let attestation = e2e::attest(&repo, command)?;
660 println!(
661 "e2e attestation recorded for commit {} (command exited {})",
662 attestation.commit, attestation.exit_code
663 );
664 Ok(0)
665}
666
667fn run_e2e_verify() -> anyhow::Result<i32> {
671 let repo = std::env::current_dir()?;
672 match e2e::verify(&repo)? {
673 e2e::Verification::Fresh => Ok(0),
674 e2e::Verification::Missing => {
675 eprintln!(
676 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
677 );
678 Ok(1)
679 }
680 e2e::Verification::Stale { attested, latest } => {
681 eprintln!(
682 "e2e attestation out of date: attested {}, latest code commit {} — \
683 run `testing-conventions e2e attest '<your e2e command>'`",
684 &attested[..attested.len().min(7)],
685 &latest[..latest.len().min(7)]
686 );
687 Ok(1)
688 }
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695
696 #[test]
697 fn no_args_returns_ok_zero() {
698 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
699 }
700
701 #[test]
702 fn check_returns_ok_zero() {
703 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
704 }
705
706 #[test]
707 fn unknown_flag_errors() {
708 assert!(run(["testing-conventions", "--bogus"]).is_err());
709 }
710
711 #[test]
712 fn help_flag_returns_clap_display_help() {
713 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
714 let clap_err = err
715 .downcast_ref::<clap::Error>()
716 .expect("error should be a clap::Error");
717 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
718 }
719
720 #[test]
721 fn version_flag_returns_clap_display_version() {
722 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
723 let clap_err = err
724 .downcast_ref::<clap::Error>()
725 .expect("error should be a clap::Error");
726 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
727 }
728
729 #[test]
730 fn unit_coverage_rust_requires_a_coverage_table() {
731 let err = run([
736 "testing-conventions",
737 "unit",
738 "coverage",
739 "pkg",
740 "--language",
741 "rust",
742 ])
743 .unwrap_err();
744 assert!(err.to_string().contains("[rust].coverage"), "got: {err}");
745 }
746}