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 ts;
10pub mod violation;
11pub mod workflow;
12
13use std::path::{Path, PathBuf};
14
15use clap::{CommandFactory, Parser, Subcommand};
16
17#[derive(Parser, Debug)]
18#[command(
19 name = "testing-conventions",
20 version,
21 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
22 long_about = None,
23)]
24pub struct Cli {
25 #[command(subcommand)]
26 command: Option<Command>,
27}
28
29#[derive(Subcommand, Debug)]
30enum Command {
31 Check,
33 Unit {
35 #[command(subcommand)]
36 rule: UnitRule,
37 },
38 Integration {
40 #[command(subcommand)]
41 rule: IntegrationRule,
42 },
43 Packaging {
45 path: PathBuf,
47 #[arg(long, value_enum)]
49 language: colocated_test::Language,
50 },
51 Workflow {
54 path: PathBuf,
56 },
57 E2e {
59 #[command(subcommand)]
60 command: E2eCommand,
61 },
62}
63
64#[derive(Subcommand, Debug)]
66enum UnitRule {
67 ColocatedTest {
69 path: PathBuf,
71 #[arg(long, value_enum)]
73 language: colocated_test::Language,
74 #[arg(long, default_value = "testing-conventions.toml")]
77 config: PathBuf,
78 },
79 Coverage {
81 path: PathBuf,
83 #[arg(long, value_enum)]
85 language: colocated_test::Language,
86 #[arg(long, default_value = "testing-conventions.toml")]
91 config: PathBuf,
92 },
93 Isolation {
95 path: PathBuf,
97 #[arg(long, value_enum)]
99 language: isolation::Language,
100 #[arg(long, default_value = "testing-conventions.toml")]
103 config: PathBuf,
104 },
105 CoChange {
109 path: PathBuf,
111 #[arg(long, value_enum)]
114 language: colocated_test::Language,
115 #[arg(long)]
118 base: String,
119 #[arg(long, default_value = "testing-conventions.toml")]
122 config: PathBuf,
123 },
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
130pub enum IntegrationLintLanguage {
131 #[value(name = "python")]
133 Python,
134 #[value(name = "typescript")]
136 TypeScript,
137 #[value(name = "rust")]
139 Rust,
140}
141
142#[derive(Subcommand, Debug)]
145enum IntegrationRule {
146 Lint {
148 path: PathBuf,
150 #[arg(long, value_enum)]
152 language: IntegrationLintLanguage,
153 #[arg(long, default_value = "testing-conventions.toml")]
156 config: PathBuf,
157 },
158}
159
160#[derive(Subcommand, Debug)]
163enum E2eCommand {
164 Attest {
166 command: String,
168 },
169 Verify,
171}
172
173pub fn run<I, T>(args: I) -> anyhow::Result<i32>
174where
175 I: IntoIterator<Item = T>,
176 T: Into<std::ffi::OsString> + Clone,
177{
178 let cli = Cli::try_parse_from(args)?;
179 match cli.command {
180 Some(Command::Check) | None => Ok(0),
184 Some(Command::Unit { rule }) => match rule {
185 UnitRule::ColocatedTest {
186 path,
187 language,
188 config,
189 } => run_unit_colocated_test(&path, language, &config),
190 UnitRule::Coverage {
191 path,
192 language,
193 config,
194 } => run_unit_coverage(&path, language, &config),
195 UnitRule::Isolation {
196 path,
197 language,
198 config,
199 } => run_unit_isolation(&path, language, &config),
200 UnitRule::CoChange {
201 path,
202 language,
203 base,
204 config,
205 } => run_unit_co_change(&path, &base, language, &config),
206 },
207 Some(Command::Integration { rule }) => match rule {
208 IntegrationRule::Lint {
209 path,
210 language,
211 config,
212 } => run_integration_lint(&path, language, &config),
213 },
214 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
215 Some(Command::Workflow { path }) => run_workflow(&path),
216 Some(Command::E2e { command }) => match command {
217 E2eCommand::Attest { command } => run_e2e_attest(&command),
218 E2eCommand::Verify => run_e2e_verify(),
219 },
220 }
221}
222
223pub fn command() -> clap::Command {
227 Cli::command()
228}
229
230fn run_unit_colocated_test(
236 root: &Path,
237 language: colocated_test::Language,
238 config_path: &Path,
239) -> anyhow::Result<i32> {
240 let exempt = colocated_test_exemptions(root, language, config_path)?;
241 let orphans = match language {
242 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
245 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
246 };
247 if orphans.is_empty() {
248 return Ok(0);
249 }
250 let (label, summary) = match language {
251 colocated_test::Language::Rust => (
252 "missing inline `#[cfg(test)]` tests",
253 "source file(s) with testable code but no inline `#[cfg(test)]` module \
254 (add an inline test module, or an `exempt` entry with a reason)",
255 ),
256 _ => (
257 "missing colocated unit test",
258 "source file(s) missing a colocated unit test \
259 (add a colocated test, or an `exempt` entry with a reason)",
260 ),
261 };
262 for orphan in &orphans {
263 eprintln!("{label}: {}", orphan.display());
264 }
265 eprintln!("error: {} {summary}", orphans.len());
266 Ok(1)
267}
268
269fn colocated_test_exemptions(
273 root: &Path,
274 language: colocated_test::Language,
275 config_path: &Path,
276) -> anyhow::Result<std::collections::BTreeSet<String>> {
277 if !config_path.exists() {
278 return Ok(std::collections::BTreeSet::new());
279 }
280 let config = config::load_config(config_path)?;
281 config::resolve_exempt(
282 root,
283 config.exemptions(language),
284 config::Rule::ColocatedTest,
285 )
286}
287
288fn run_unit_co_change(
298 root: &Path,
299 base: &str,
300 language: colocated_test::Language,
301 config_path: &Path,
302) -> anyhow::Result<i32> {
303 if language == colocated_test::Language::Rust {
304 anyhow::bail!(
305 "`unit co-change` supports `--language python` / `typescript`; Rust units \
306 are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
307 );
308 }
309 let exempt = co_change_exemptions(root, language, config_path)?;
310 let stale = co_change::stale_sources(root, base, language, &exempt)?;
311 if stale.is_empty() {
312 return Ok(0);
313 }
314 for source in &stale {
315 eprintln!(
316 "source changed without its colocated test: {}",
317 source.display()
318 );
319 }
320 eprintln!(
321 "error: {} source file(s) changed without their colocated test co-changing \
322 (update the test, or add an `exempt` entry with a reason)",
323 stale.len()
324 );
325 Ok(1)
326}
327
328fn co_change_exemptions(
332 root: &Path,
333 language: colocated_test::Language,
334 config_path: &Path,
335) -> anyhow::Result<std::collections::BTreeSet<String>> {
336 if !config_path.exists() {
337 return Ok(std::collections::BTreeSet::new());
338 }
339 let config = config::load_config(config_path)?;
340 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
341}
342
343fn combine_outcomes(outcomes: impl IntoIterator<Item = coverage::Outcome>) -> coverage::Outcome {
348 let reasons: Vec<String> = outcomes
349 .into_iter()
350 .filter_map(|outcome| match outcome {
351 coverage::Outcome::Pass => None,
352 coverage::Outcome::Fail(reason) => Some(reason),
353 })
354 .collect();
355 if reasons.is_empty() {
356 coverage::Outcome::Pass
357 } else {
358 coverage::Outcome::Fail(reasons.join("; "))
359 }
360}
361
362fn run_unit_coverage(
373 root: &Path,
374 language: colocated_test::Language,
375 config_path: &Path,
376) -> anyhow::Result<i32> {
377 let config = if config_path.exists() {
378 config::load_config(config_path)?
379 } else {
380 config::Config::default()
381 };
382 let outcome = match language {
383 colocated_test::Language::Python => {
384 let python = config.python.unwrap_or_default();
385 let coverage = python.coverage.unwrap_or_default();
386 let thresholds = coverage::Thresholds {
387 fail_under: coverage.fail_under,
388 branch: coverage.branch,
389 };
390 let omit: Vec<String> =
391 config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
392 .into_iter()
393 .collect();
394 let report = coverage::measure_report(root, &omit)?;
398 let baseline = coverage::read_baseline(root)?
399 .and_then(|baseline| baseline.python)
400 .map(|python| python.percent_covered);
401 combine_outcomes([
402 coverage::evaluate(&report, thresholds),
403 coverage::evaluate_ratchet(report.totals.percent_covered, baseline),
404 ])
405 }
406 colocated_test::Language::TypeScript => {
407 let typescript = config.typescript.unwrap_or_default();
408 let coverage = typescript.coverage.unwrap_or_default();
409 let thresholds = coverage::TypeScriptThresholds {
410 lines: coverage.lines,
411 branches: coverage.branches,
412 functions: coverage.functions,
413 statements: coverage.statements,
414 };
415 let exclude: Vec<String> =
416 config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
417 .into_iter()
418 .collect();
419 coverage::measure_typescript(root, thresholds, &exclude)?
420 }
421 colocated_test::Language::Rust => anyhow::bail!(
422 "`unit coverage` supports `--language python` / `typescript`; \
423 Rust coverage (`cargo llvm-cov`) is a separate item"
424 ),
425 };
426 match outcome {
427 coverage::Outcome::Pass => Ok(0),
428 coverage::Outcome::Fail(reason) => {
429 eprintln!("error: coverage check failed — {reason}");
430 Ok(1)
431 }
432 }
433}
434
435fn run_unit_isolation(
439 root: &Path,
440 language: isolation::Language,
441 config_path: &Path,
442) -> anyhow::Result<i32> {
443 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
444 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
445 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
446 c.exemptions(colocated_test::Language::TypeScript)
447 }),
448 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
449 c.exemptions(colocated_test::Language::Python)
450 }),
451 };
452 let violations = apply_waivers(raw, root, config_path, select)?;
453 if violations.is_empty() {
454 return Ok(0);
455 }
456 for v in &violations {
457 eprintln!(
458 "{}:{}: {} — {}",
459 v.file.display(),
460 v.line,
461 v.rule,
462 v.message
463 );
464 }
465 eprintln!("error: {} isolation violation(s)", violations.len());
466 Ok(1)
467}
468
469fn run_integration_lint(
473 root: &Path,
474 language: IntegrationLintLanguage,
475 config_path: &Path,
476) -> anyhow::Result<i32> {
477 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
478 IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
479 c.exemptions(colocated_test::Language::Python)
480 }),
481 IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
482 c.exemptions(colocated_test::Language::TypeScript)
483 }),
484 IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
485 c.rust_exemptions()
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: {} lint violation(s)", violations.len());
502 Ok(1)
503}
504
505type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
508
509fn apply_waivers(
516 violations: Vec<lint::Violation>,
517 root: &Path,
518 config_path: &Path,
519 exemptions: ExemptSelect,
520) -> anyhow::Result<Vec<lint::Violation>> {
521 use std::collections::hash_map::Entry;
522
523 if !config_path.exists() {
524 return Ok(violations);
525 }
526 let config = config::load_config(config_path)?;
527 let exempt = exemptions(&config);
528 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
530 std::collections::HashMap::new();
531 let mut kept = Vec::new();
532 for violation in violations {
533 let waived = match config::Rule::from_id(violation.rule) {
534 Some(rule) => {
535 let exempt_paths = match resolved.entry(rule) {
536 Entry::Occupied(entry) => entry.into_mut(),
537 Entry::Vacant(entry) => {
538 entry.insert(config::resolve_exempt(root, exempt, rule)?)
539 }
540 };
541 violation
542 .file
543 .strip_prefix(root)
544 .ok()
545 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
546 .is_some_and(|rel| exempt_paths.contains(&rel))
547 }
548 None => false,
549 };
550 if !waived {
551 kept.push(violation);
552 }
553 }
554 Ok(kept)
555}
556
557fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
566 let globs = match language {
567 colocated_test::Language::Python => vec!["*_test.py".to_string()],
568 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
569 colocated_test::Language::Rust => vec!["tests/".to_string()],
572 };
573 let offenders = packaging::inspect(artifact, &globs)?;
574 if offenders.is_empty() {
575 return Ok(0);
576 }
577 for offender in &offenders {
578 eprintln!("test file in built artifact: {}", offender.display());
579 }
580 eprintln!(
581 "error: {} test file(s) present in the built artifact \
582 (they must be excluded from packaging)",
583 offenders.len()
584 );
585 Ok(1)
586}
587
588fn run_workflow(path: &Path) -> anyhow::Result<i32> {
593 let violations = workflow::check(path, &command())?;
594 if violations.is_empty() {
595 return Ok(0);
596 }
597 for v in &violations {
598 eprintln!(
599 "{}:{}: {} — {}",
600 v.file.display(),
601 v.line,
602 v.rule,
603 v.message
604 );
605 }
606 eprintln!(
607 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
608 violations.len()
609 );
610 Ok(1)
611}
612
613fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
617 let repo = std::env::current_dir()?;
618 let attestation = e2e::attest(&repo, command)?;
619 println!(
620 "e2e attestation recorded for commit {} (command exited {})",
621 attestation.commit, attestation.exit_code
622 );
623 Ok(0)
624}
625
626fn run_e2e_verify() -> anyhow::Result<i32> {
630 let repo = std::env::current_dir()?;
631 match e2e::verify(&repo)? {
632 e2e::Verification::Fresh => Ok(0),
633 e2e::Verification::Missing => {
634 eprintln!(
635 "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
636 );
637 Ok(1)
638 }
639 e2e::Verification::Stale { attested, latest } => {
640 eprintln!(
641 "e2e attestation out of date: attested {}, latest code commit {} — \
642 run `testing-conventions e2e attest '<your e2e command>'`",
643 &attested[..attested.len().min(7)],
644 &latest[..latest.len().min(7)]
645 );
646 Ok(1)
647 }
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn no_args_returns_ok_zero() {
657 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
658 }
659
660 #[test]
661 fn check_returns_ok_zero() {
662 assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
663 }
664
665 #[test]
666 fn unknown_flag_errors() {
667 assert!(run(["testing-conventions", "--bogus"]).is_err());
668 }
669
670 #[test]
671 fn help_flag_returns_clap_display_help() {
672 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
673 let clap_err = err
674 .downcast_ref::<clap::Error>()
675 .expect("error should be a clap::Error");
676 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
677 }
678
679 #[test]
680 fn version_flag_returns_clap_display_version() {
681 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
682 let clap_err = err
683 .downcast_ref::<clap::Error>()
684 .expect("error should be a clap::Error");
685 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
686 }
687
688 #[test]
689 fn unit_coverage_rejects_rust() {
690 let err = run([
693 "testing-conventions",
694 "unit",
695 "coverage",
696 "pkg",
697 "--language",
698 "rust",
699 ])
700 .unwrap_err();
701 assert!(err.to_string().contains("separate item"), "got: {err}");
702 }
703}