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.unwrap_or_default();
447 let thresholds = coverage::RustThresholds {
448 regions: coverage.regions,
449 lines: coverage.lines,
450 };
451 let ignore: Vec<String> =
452 config::resolve_exempt(root, &rust.exempt, config::Rule::Coverage)?
453 .into_iter()
454 .collect();
455 match base {
456 Some(base) => patch_coverage::measure_rust(root, base, thresholds, &ignore)?,
457 None => coverage::measure_rust(root, thresholds, &ignore)?,
458 }
459 }
460 };
461 match outcome {
462 coverage::Outcome::Pass => Ok(0),
463 coverage::Outcome::Fail(reason) => {
464 eprintln!("error: coverage check failed — {reason}");
465 Ok(1)
466 }
467 }
468}
469
470fn run_unit_lint(
475 root: &Path,
476 language: isolation::Language,
477 config_path: &Path,
478) -> anyhow::Result<i32> {
479 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
480 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
481 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
482 c.exemptions(colocated_test::Language::TypeScript)
483 }),
484 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
485 c.exemptions(colocated_test::Language::Python)
486 }),
487 };
488 let violations = apply_waivers(raw, root, config_path, select)?;
489 if violations.is_empty() {
490 return Ok(0);
491 }
492 for v in &violations {
493 eprintln!(
494 "{}:{}: {} — {}",
495 v.file.display(),
496 v.line,
497 v.rule,
498 v.message
499 );
500 }
501 eprintln!("error: {} isolation violation(s)", violations.len());
502 Ok(1)
503}
504
505fn run_integration_lint(
509 root: &Path,
510 language: IntegrationLintLanguage,
511 config_path: &Path,
512) -> anyhow::Result<i32> {
513 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
514 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
515 c.exemptions(colocated_test::Language::Python)
516 }),
517 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
518 c.exemptions(colocated_test::Language::TypeScript)
519 }),
520 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
521 c.rust_exemptions()
522 }),
523 };
524 let violations = apply_waivers(raw, root, config_path, select)?;
525 if violations.is_empty() {
526 return Ok(0);
527 }
528 for v in &violations {
529 eprintln!(
530 "{}:{}: {} — {}",
531 v.file.display(),
532 v.line,
533 v.rule,
534 v.message
535 );
536 }
537 eprintln!("error: {} lint violation(s)", violations.len());
538 Ok(1)
539}
540
541type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
544
545fn apply_waivers(
552 violations: Vec<lint::Violation>,
553 root: &Path,
554 config_path: &Path,
555 exemptions: ExemptSelect,
556) -> anyhow::Result<Vec<lint::Violation>> {
557 use std::collections::hash_map::Entry;
558
559 if !config_path.exists() {
560 return Ok(violations);
561 }
562 let config = config::load_config(config_path)?;
563 let exempt = exemptions(&config);
564 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
566 std::collections::HashMap::new();
567 let mut kept = Vec::new();
568 for violation in violations {
569 let waived = match config::Rule::from_id(violation.rule) {
570 Some(rule) => {
571 let exempt_paths = match resolved.entry(rule) {
572 Entry::Occupied(entry) => entry.into_mut(),
573 Entry::Vacant(entry) => {
574 entry.insert(config::resolve_exempt(root, exempt, rule)?)
575 }
576 };
577 violation
578 .file
579 .strip_prefix(root)
580 .ok()
581 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
582 .is_some_and(|rel| exempt_paths.contains(&rel))
583 }
584 None => false,
585 };
586 if !waived {
587 kept.push(violation);
588 }
589 }
590 Ok(kept)
591}
592
593fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
602 let globs = match language {
603 colocated_test::Language::Python => vec!["*_test.py".to_string()],
604 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
605 colocated_test::Language::Rust => vec!["tests/".to_string()],
608 };
609 let offenders = packaging::inspect(artifact, &globs)?;
610 if offenders.is_empty() {
611 return Ok(0);
612 }
613 for offender in &offenders {
614 eprintln!("test file in built artifact: {}", offender.display());
615 }
616 eprintln!(
617 "error: {} test file(s) present in the built artifact \
618 (they must be excluded from packaging)",
619 offenders.len()
620 );
621 Ok(1)
622}
623
624fn run_workflow(path: &Path) -> anyhow::Result<i32> {
629 let violations = workflow::check(path, &command())?;
630 if violations.is_empty() {
631 return Ok(0);
632 }
633 for v in &violations {
634 eprintln!(
635 "{}:{}: {} — {}",
636 v.file.display(),
637 v.line,
638 v.rule,
639 v.message
640 );
641 }
642 eprintln!(
643 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
644 violations.len()
645 );
646 Ok(1)
647}
648
649fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
653 let repo = std::env::current_dir()?;
654 let attestation = e2e::attest(&repo, command)?;
655 println!(
656 "e2e attestation recorded for commit {} (command exited {})",
657 attestation.commit, attestation.exit_code
658 );
659 Ok(0)
660}
661
662fn run_e2e_verify() -> anyhow::Result<i32> {
666 let repo = std::env::current_dir()?;
667 match e2e::verify(&repo)? {
668 e2e::Verification::Fresh => Ok(0),
669 e2e::Verification::Missing => {
670 eprintln!(
671 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
672 );
673 Ok(1)
674 }
675 e2e::Verification::Stale { attested, latest } => {
676 eprintln!(
677 "e2e attestation out of date: attested {}, latest code commit {} — \
678 run `testing-conventions e2e attest '<your e2e command>'`",
679 &attested[..attested.len().min(7)],
680 &latest[..latest.len().min(7)]
681 );
682 Ok(1)
683 }
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn no_args_returns_ok_zero() {
693 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
694 }
695
696 #[test]
697 fn check_returns_ok_zero() {
698 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
699 }
700
701 #[test]
702 fn unknown_flag_errors() {
703 assert!(run(["testing-conventions", "--bogus"]).is_err());
704 }
705
706 #[test]
707 fn help_flag_returns_clap_display_help() {
708 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
709 let clap_err = err
710 .downcast_ref::<clap::Error>()
711 .expect("error should be a clap::Error");
712 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
713 }
714
715 #[test]
716 fn version_flag_returns_clap_display_version() {
717 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
718 let clap_err = err
719 .downcast_ref::<clap::Error>()
720 .expect("error should be a clap::Error");
721 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
722 }
723}