1pub mod agents;
2pub mod co_change;
3pub mod colocated_test;
4pub mod config;
5pub mod coverage;
6pub mod e2e;
7pub mod isolation;
8pub mod lint;
9pub mod mutation;
10pub mod one_function;
11pub mod packaging;
12pub mod patch_coverage;
13pub mod tiers;
14pub mod ts;
15pub mod violation;
16mod walk;
17pub mod workflow;
18
19use std::path::{Path, PathBuf};
20
21use clap::{CommandFactory, Parser, Subcommand};
22
23#[derive(Parser, Debug)]
24#[command(
25 name = "testing-conventions",
26 version,
27 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
28 long_about = None,
29)]
30pub struct Cli {
31 #[command(subcommand)]
32 command: Option<Command>,
33}
34
35#[derive(Subcommand, Debug)]
36enum Command {
37 Install {
42 #[arg(default_value = "AGENTS.md")]
44 path: PathBuf,
45 },
46 Unit {
48 #[command(subcommand)]
49 rule: UnitRule,
50 },
51 Integration {
53 #[command(subcommand)]
54 rule: IntegrationRule,
55 },
56 Packaging {
58 path: PathBuf,
60 #[arg(long, value_enum)]
62 language: colocated_test::Language,
63 },
64 #[command(hide = true)]
69 Workflow {
70 path: PathBuf,
72 },
73 E2e {
75 #[command(subcommand)]
76 command: E2eCommand,
77 },
78}
79
80#[derive(Subcommand, Debug)]
81enum UnitRule {
82 ColocatedTest {
88 path: PathBuf,
90 #[arg(long, value_enum)]
92 language: colocated_test::Language,
93 #[arg(long)]
99 base: Option<String>,
100 #[arg(long, default_value = "testing-conventions.toml")]
103 config: PathBuf,
104 },
105 Coverage {
110 path: PathBuf,
112 #[arg(long, value_enum)]
114 language: colocated_test::Language,
115 #[arg(long)]
121 base: Option<String>,
122 #[arg(long, default_value = "testing-conventions.toml")]
127 config: PathBuf,
128 },
129 OneFunctionPerFile {
133 path: PathBuf,
135 #[arg(long, value_enum)]
137 language: colocated_test::Language,
138 #[arg(long, default_value = "testing-conventions.toml")]
143 config: PathBuf,
144 },
145 Lint {
147 path: PathBuf,
149 #[arg(long, value_enum)]
151 language: isolation::Language,
152 #[arg(long, default_value = "testing-conventions.toml")]
155 config: PathBuf,
156 },
157 Mutation {
163 path: PathBuf,
165 #[arg(long, value_enum)]
167 language: colocated_test::Language,
168 #[arg(long)]
172 base: Option<String>,
173 #[arg(long, default_value = "testing-conventions.toml")]
176 config: PathBuf,
177 #[arg(long = "ts-mutation-adapter", hide = true)]
181 ts_adapter: Option<PathBuf>,
182 },
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
187pub enum IntegrationLintLanguage {
188 #[value(name = "python")]
190 Python,
191 #[value(name = "typescript")]
193 TypeScript,
194 #[value(name = "rust")]
196 Rust,
197}
198
199#[derive(Subcommand, Debug)]
200enum IntegrationRule {
201 Lint {
203 path: PathBuf,
205 #[arg(long, value_enum)]
207 language: IntegrationLintLanguage,
208 #[arg(long, default_value = "testing-conventions.toml")]
211 config: PathBuf,
212 },
213}
214
215#[derive(Subcommand, Debug)]
216enum E2eCommand {
217 Attest {
221 command: String,
223 },
224 Verify {
226 #[arg(default_value = ".")]
229 path: PathBuf,
230 #[arg(long)]
233 scope: Option<PathBuf>,
234 #[arg(long)]
241 base: Option<String>,
242 #[arg(long = "extra-scope")]
247 extra_scope: Vec<PathBuf>,
248 #[arg(long = "exclude")]
252 exclude: Vec<PathBuf>,
253 },
254 Slug {
257 branch: Option<String>,
259 },
260}
261
262pub fn run<I, T>(args: I) -> anyhow::Result<i32>
263where
264 I: IntoIterator<Item = T>,
265 T: Into<std::ffi::OsString> + Clone,
266{
267 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
270 let cli = Cli::try_parse_from(args)?;
271 match cli.command {
272 None => Ok(0),
273 Some(Command::Unit { rule }) => match rule {
274 UnitRule::ColocatedTest {
275 path,
276 language,
277 base,
278 config,
279 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
280 UnitRule::Coverage {
281 path,
282 language,
283 base,
284 config,
285 } => run_unit_coverage(&path, language, base.as_deref(), &config),
286 UnitRule::OneFunctionPerFile {
287 path,
288 language,
289 config,
290 } => run_unit_one_function(&path, language, &config),
291 UnitRule::Lint {
292 path,
293 language,
294 config,
295 } => run_unit_lint(&path, language, &config),
296 UnitRule::Mutation {
297 path,
298 language,
299 base,
300 config,
301 ts_adapter,
302 } => run_unit_mutation(
303 &path,
304 language,
305 base.as_deref(),
306 &config,
307 ts_adapter.as_deref(),
308 ),
309 },
310 Some(Command::Integration { rule }) => match rule {
311 IntegrationRule::Lint {
312 path,
313 language,
314 config,
315 } => run_integration_lint(&path, language, &config),
316 },
317 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
318 Some(Command::Workflow { path }) => run_workflow(&path),
319 Some(Command::E2e { command }) => match command {
320 E2eCommand::Attest { command } => run_e2e_attest(&command),
321 E2eCommand::Verify {
322 path,
323 scope,
324 base,
325 extra_scope,
326 exclude,
327 } => run_e2e_verify(
328 &path,
329 scope.as_deref(),
330 base.as_deref(),
331 &extra_scope,
332 &exclude,
333 ),
334 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
335 },
336 Some(Command::Install { path }) => {
337 agents::install(&path)?;
338 Ok(0)
339 }
340 }
341}
342
343pub fn command() -> clap::Command {
345 Cli::command()
346}
347
348fn run_unit_colocated_test(
351 root: &Path,
352 language: colocated_test::Language,
353 base: Option<&str>,
354 config_path: &Path,
355) -> anyhow::Result<i32> {
356 if base.is_some() && language == colocated_test::Language::Rust {
357 anyhow::bail!(
358 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
359 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
360 );
361 }
362 let presence_clean = report_colocated_presence(root, language, config_path)?;
363 let co_change_clean = match base {
364 Some(base) => report_co_change(root, base, language, config_path)?,
365 None => true,
366 };
367 Ok(if presence_clean && co_change_clean {
368 0
369 } else {
370 1
371 })
372}
373
374fn report_colocated_presence(
377 root: &Path,
378 language: colocated_test::Language,
379 config_path: &Path,
380) -> anyhow::Result<bool> {
381 let exempt = colocated_test_exemptions(root, language, config_path)?;
382 let orphans = match language {
383 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
384 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
385 };
386 if orphans.is_empty() {
387 return Ok(true);
388 }
389 let (label, summary) = match language {
390 colocated_test::Language::Rust => (
391 "missing inline `#[cfg(test)]` tests",
392 "source file(s) with testable code but no inline `#[cfg(test)]` module \
393 (add an inline test module, or an `exempt` entry with a reason)",
394 ),
395 _ => (
396 "missing colocated unit test",
397 "source file(s) missing a colocated unit test \
398 (add a colocated test, or an `exempt` entry with a reason)",
399 ),
400 };
401 for orphan in &orphans {
402 eprintln!("{label}: {}", orphan.display());
403 }
404 eprintln!("error: {} {summary}", orphans.len());
405 Ok(false)
406}
407
408fn colocated_test_exemptions(
410 root: &Path,
411 language: colocated_test::Language,
412 config_path: &Path,
413) -> anyhow::Result<std::collections::BTreeSet<String>> {
414 if !config_path.exists() {
415 return Ok(std::collections::BTreeSet::new());
416 }
417 let config = config::load_config(config_path)?;
418 config::resolve_exempt(
419 root,
420 config.exemptions(language),
421 config::Rule::ColocatedTest,
422 )
423}
424
425fn report_co_change(
428 root: &Path,
429 base: &str,
430 language: colocated_test::Language,
431 config_path: &Path,
432) -> anyhow::Result<bool> {
433 let exempt = co_change_exemptions(root, language, config_path)?;
434 let stale = co_change::stale_sources(root, base, language, &exempt)?;
435 if stale.is_empty() {
436 return Ok(true);
437 }
438 for source in &stale {
439 eprintln!(
440 "source changed without its colocated test: {}",
441 source.display()
442 );
443 }
444 eprintln!(
445 "error: {} source file(s) changed without their colocated test co-changing \
446 (update the test, or add an `exempt` entry with a reason)",
447 stale.len()
448 );
449 Ok(false)
450}
451
452fn co_change_exemptions(
454 root: &Path,
455 language: colocated_test::Language,
456 config_path: &Path,
457) -> anyhow::Result<std::collections::BTreeSet<String>> {
458 if !config_path.exists() {
459 return Ok(std::collections::BTreeSet::new());
460 }
461 let config = config::load_config(config_path)?;
462 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
463}
464
465fn split_scopes(
467 scopes: std::collections::BTreeMap<String, config::LineScope>,
468) -> (
469 Vec<String>,
470 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
471) {
472 let mut whole_file = Vec::new();
473 let mut line_scoped = std::collections::BTreeMap::new();
474 for (path, scope) in scopes {
475 match scope {
476 config::LineScope::WholeFile => whole_file.push(path),
477 config::LineScope::Lines(lines) => {
478 line_scoped.insert(path, lines);
479 }
480 }
481 }
482 (whole_file, line_scoped)
483}
484
485fn run_unit_coverage(
488 root: &Path,
489 language: colocated_test::Language,
490 base: Option<&str>,
491 config_path: &Path,
492) -> anyhow::Result<i32> {
493 let config = if config_path.exists() {
494 config::load_config(config_path)?
495 } else {
496 config::Config::default()
497 };
498 let outcome = match language {
499 colocated_test::Language::Python => {
500 let python = config.python.unwrap_or_default();
501 let coverage = python.coverage.unwrap_or_default();
502 let thresholds = coverage::Thresholds {
503 fail_under: coverage.fail_under,
504 branch: coverage.branch,
505 };
506 let scopes =
507 config::resolve_exempt_scoped(root, &python.exempt, config::Rule::Coverage)?;
508 let (omit, exempt_lines) = split_scopes(scopes);
509 match base {
510 Some(base) => {
511 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
512 }
513 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
514 None => {
515 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
516 }
517 }
518 }
519 colocated_test::Language::TypeScript => {
520 let typescript = config.typescript.unwrap_or_default();
521 let coverage = typescript.coverage.unwrap_or_default();
522 let thresholds = coverage::TypeScriptThresholds {
523 lines: coverage.lines,
524 branches: coverage.branches,
525 functions: coverage.functions,
526 statements: coverage.statements,
527 };
528 let scopes =
529 config::resolve_exempt_scoped(root, &typescript.exempt, config::Rule::Coverage)?;
530 let (exclude, exempt_lines) = split_scopes(scopes);
531 match base {
532 Some(base) => patch_coverage::measure_typescript(
533 root,
534 base,
535 thresholds,
536 &exclude,
537 &exempt_lines,
538 )?,
539 None if exempt_lines.is_empty() => {
540 coverage::measure_typescript(root, thresholds, &exclude)?
541 }
542 None => patch_coverage::measure_line_exempt_typescript(
543 root,
544 thresholds,
545 &exclude,
546 &exempt_lines,
547 )?,
548 }
549 }
550 colocated_test::Language::Rust => {
551 let rust = config.rust.unwrap_or_default();
552 let coverage = rust.coverage.unwrap_or_default();
553 let thresholds = coverage::RustThresholds {
554 regions: coverage.regions,
555 lines: coverage.lines,
556 functions: coverage.functions,
557 branch: coverage.branch,
558 };
559 let scopes = config::resolve_exempt_scoped(root, &rust.exempt, config::Rule::Coverage)?;
560 let (ignore, exempt_lines) = split_scopes(scopes);
561 match base {
562 Some(base) => patch_coverage::measure_rust(
563 root,
564 base,
565 thresholds,
566 &ignore,
567 &exempt_lines,
568 &rust.features,
569 )?,
570 None if exempt_lines.is_empty() => {
571 coverage::measure_rust(root, thresholds, &ignore, &rust.features)?
572 }
573 None => patch_coverage::measure_line_exempt_rust(
574 root,
575 thresholds,
576 &ignore,
577 &exempt_lines,
578 &rust.features,
579 )?,
580 }
581 }
582 };
583 match outcome {
584 coverage::Outcome::Pass => Ok(0),
585 coverage::Outcome::Fail(reason) => {
586 eprintln!("error: coverage check failed — {reason}");
587 Ok(1)
588 }
589 }
590}
591
592fn run_unit_mutation(
595 root: &Path,
596 language: colocated_test::Language,
597 base: Option<&str>,
598 config_path: &Path,
599 ts_adapter: Option<&Path>,
600) -> anyhow::Result<i32> {
601 let config = if config_path.exists() {
602 config::load_config(config_path)?
603 } else {
604 config::Config::default()
605 };
606 let measurement = match language {
607 colocated_test::Language::Rust => {
608 let rust = config.rust.unwrap_or_default();
609 let scopes = config::resolve_exempt_scoped(root, &rust.exempt, config::Rule::Mutation)?;
610 let (exempt, exempt_lines) = split_scopes(scopes);
611 mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
612 }
613 colocated_test::Language::TypeScript => {
614 let typescript = config.typescript.unwrap_or_default();
615 let scopes =
616 config::resolve_exempt_scoped(root, &typescript.exempt, config::Rule::Mutation)?;
617 let (exempt, exempt_lines) = split_scopes(scopes);
618 let adapter = ts_adapter.ok_or_else(|| {
619 anyhow::anyhow!(
620 "the TypeScript mutation adapter path is required: pass \
621 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
622 automatically — run the rule through that CLI, not the raw binary."
623 )
624 })?;
625 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
626 }
627 colocated_test::Language::Python => {
628 let python = config.python.unwrap_or_default();
629 let scopes =
630 config::resolve_exempt_scoped(root, &python.exempt, config::Rule::Mutation)?;
631 let (exempt, exempt_lines) = split_scopes(scopes);
632 mutation::measure_python(root, &exempt, &exempt_lines, base)?
633 }
634 };
635 let (count, survivors) = match measurement {
636 mutation::Measurement::EngineNotRun => {
637 println!("unit mutation: no mutatable changed lines — engine not run");
638 return Ok(0);
639 }
640 mutation::Measurement::Tested { count, survivors } => (count, survivors),
641 };
642 if survivors.is_empty() {
643 if count == 0 {
644 println!("unit mutation: the engine found no mutants to test");
645 } else {
646 println!(
647 "unit mutation: no surviving mutants — every mutation was caught \
648 ({count} mutant(s) tested)"
649 );
650 }
651 return Ok(0);
652 }
653
654 eprintln!(
655 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
656 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
657 survivors.len()
658 );
659 for survivor in &survivors {
660 eprintln!(
661 " {}:{}: {}",
662 survivor.file, survivor.line, survivor.description
663 );
664 }
665 Ok(1)
666}
667
668fn run_unit_one_function(
671 root: &Path,
672 language: colocated_test::Language,
673 config_path: &Path,
674) -> anyhow::Result<i32> {
675 let threshold = if config_path.exists() {
676 config::load_config(config_path)?.one_function_threshold(language)
677 } else {
678 config::Config::default().one_function_threshold(language)
679 };
680 let key = match language {
681 colocated_test::Language::Python => "python",
682 colocated_test::Language::TypeScript => "typescript",
683 colocated_test::Language::Rust => "rust",
684 };
685 let Some(max_lines) = threshold else {
686 println!(
687 "unit one-function-per-file: not enabled for {key} — \
688 set `[{key}].one_function_per_file` to opt in"
689 );
690 return Ok(0);
691 };
692 let raw = one_function::find_violations(root, language, max_lines)?;
693 let select: ExemptSelect = match language {
694 colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
695 colocated_test::Language::TypeScript => {
696 |c| c.exemptions(colocated_test::Language::TypeScript)
697 }
698 colocated_test::Language::Rust => |c| c.rust_exemptions(),
699 };
700 let violations = apply_waivers(raw, root, config_path, select)?;
701 if violations.is_empty() {
702 return Ok(0);
703 }
704 for v in &violations {
705 eprintln!(
706 "{}:{}: {} — {}",
707 v.file.display(),
708 v.line,
709 v.rule,
710 v.message
711 );
712 }
713 eprintln!(
714 "error: {} function(s) sharing a file with another function over the \
715 {max_lines}-line threshold (move each to its own module, or add an \
716 `exempt` entry with a reason)",
717 violations.len()
718 );
719 Ok(1)
720}
721
722fn run_unit_lint(
725 root: &Path,
726 language: isolation::Language,
727 config_path: &Path,
728) -> anyhow::Result<i32> {
729 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
730 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
731 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
732 c.exemptions(colocated_test::Language::TypeScript)
733 }),
734 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
735 c.exemptions(colocated_test::Language::Python)
736 }),
737 };
738 let violations = apply_waivers(raw, root, config_path, select)?;
739 if violations.is_empty() {
740 return Ok(0);
741 }
742 for v in &violations {
743 eprintln!(
744 "{}:{}: {} — {}",
745 v.file.display(),
746 v.line,
747 v.rule,
748 v.message
749 );
750 }
751 eprintln!("error: {} isolation violation(s)", violations.len());
752 Ok(1)
753}
754
755fn run_integration_lint(
758 root: &Path,
759 language: IntegrationLintLanguage,
760 config_path: &Path,
761) -> anyhow::Result<i32> {
762 let manifest = match language {
763 IntegrationLintLanguage::Python => "pyproject.toml",
764 IntegrationLintLanguage::TypeScript => "package.json",
765 IntegrationLintLanguage::Rust => "Cargo.toml",
766 };
767 let package_root = tiers::package_root(root, manifest);
768 let scan_root = package_root.as_deref().unwrap_or(root);
769 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
770 IntegrationLintLanguage::Python => (
771 match &package_root {
772 Some(package_root) => lint::find_suite_violations(package_root)?,
773 None => lint::find_violations(root)?,
774 },
775 |c| c.exemptions(colocated_test::Language::Python),
776 ),
777 IntegrationLintLanguage::TypeScript => (
778 match &package_root {
779 Some(package_root) => ts::find_suite_violations(package_root)?,
780 None => ts::find_integration_violations(root)?,
781 },
782 |c| c.exemptions(colocated_test::Language::TypeScript),
783 ),
784 IntegrationLintLanguage::Rust => {
785 (isolation::find_integration_violations(scan_root)?, |c| {
786 c.rust_exemptions()
787 })
788 }
789 };
790 let violations = apply_waivers(raw, scan_root, config_path, select)?;
791 if violations.is_empty() {
792 return Ok(0);
793 }
794 for v in &violations {
795 eprintln!(
796 "{}:{}: {} — {}",
797 v.file.display(),
798 v.line,
799 v.rule,
800 v.message
801 );
802 }
803 eprintln!("error: {} lint violation(s)", violations.len());
804 Ok(1)
805}
806
807type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
809
810fn apply_waivers(
812 violations: Vec<lint::Violation>,
813 root: &Path,
814 config_path: &Path,
815 exemptions: ExemptSelect,
816) -> anyhow::Result<Vec<lint::Violation>> {
817 use std::collections::hash_map::Entry;
818
819 if !config_path.exists() {
820 return Ok(violations);
821 }
822 let config = config::load_config(config_path)?;
823 let exempt = exemptions(&config);
824 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
825 std::collections::HashMap::new();
826 let mut kept = Vec::new();
827 for violation in violations {
828 let waived = match config::Rule::from_id(violation.rule) {
829 Some(rule) => {
830 let exempt_paths = match resolved.entry(rule) {
831 Entry::Occupied(entry) => entry.into_mut(),
832 Entry::Vacant(entry) => {
833 entry.insert(config::resolve_exempt(root, exempt, rule)?)
834 }
835 };
836 violation
837 .file
838 .strip_prefix(root)
839 .ok()
840 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
841 .is_some_and(|rel| exempt_paths.contains(&rel))
842 }
843 None => false,
844 };
845 if !waived {
846 kept.push(violation);
847 }
848 }
849 Ok(kept)
850}
851
852fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
855 let globs = match language {
856 colocated_test::Language::Python => vec!["*_test.py".to_string()],
857 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
858 colocated_test::Language::Rust => vec!["tests/".to_string()],
860 };
861 let offenders = packaging::inspect(artifact, &globs)?;
862 if offenders.is_empty() {
863 return Ok(0);
864 }
865 for offender in &offenders {
866 eprintln!("test file in built artifact: {}", offender.display());
867 }
868 eprintln!(
869 "error: {} test file(s) present in the built artifact \
870 (they must be excluded from packaging)",
871 offenders.len()
872 );
873 Ok(1)
874}
875
876fn run_workflow(path: &Path) -> anyhow::Result<i32> {
879 let violations = workflow::check(path, &command())?;
880 if violations.is_empty() {
881 return Ok(0);
882 }
883 for v in &violations {
884 eprintln!(
885 "{}:{}: {} — {}",
886 v.file.display(),
887 v.line,
888 v.rule,
889 v.message
890 );
891 }
892 eprintln!(
893 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
894 violations.len()
895 );
896 Ok(1)
897}
898
899fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
902 let repo = std::env::current_dir()?;
903 let attestation = e2e::attest(&repo, command)?;
904 if attestation.exit_code != 0 {
905 eprintln!(
906 "e2e command `{command}` exited {}; a receipt records a run that passed — \
907 fix the failure and attest again",
908 attestation.exit_code
909 );
910 return Ok(attestation.exit_code);
911 }
912 println!(
913 "e2e receipt recorded for branch {} at {}/{}.json",
914 attestation.branch,
915 e2e::RECEIPTS_DIR,
916 e2e::branch_slug(&attestation.branch),
917 );
918 Ok(0)
919}
920
921fn run_e2e_verify(
925 path: &Path,
926 scope: Option<&Path>,
927 base: Option<&str>,
928 extra_scopes: &[PathBuf],
929 excludes: &[PathBuf],
930) -> anyhow::Result<i32> {
931 match e2e::verify_extra_scoped(path, scope.unwrap_or(path), base, extra_scopes, excludes)? {
932 e2e::Verification::Fresh => Ok(0),
933 e2e::Verification::Missing => {
934 eprintln!(
935 "no e2e receipt answers this change — run \
936 `testing-conventions e2e attest '<your e2e command>'`; the command is \
937 your judgment: the full suite, a targeted subset, or a no-op"
938 );
939 Ok(1)
940 }
941 }
942}
943
944fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
946 let slug = match branch {
947 Some(name) => e2e::branch_slug(name),
948 None => {
949 let repo = std::env::current_dir()?;
950 e2e::branch_slug(&e2e::current_branch(&repo)?)
951 }
952 };
953 println!("{slug}");
954 Ok(0)
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960
961 #[test]
962 fn no_args_returns_ok_zero() {
963 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
964 }
965
966 #[test]
967 fn unknown_flag_errors() {
968 assert!(run(["testing-conventions", "--bogus"]).is_err());
969 }
970
971 #[test]
972 fn split_scopes_separates_whole_file_paths_from_line_sets() {
973 let mut scopes = std::collections::BTreeMap::new();
974 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
975 scopes.insert(
976 "widget.py".to_string(),
977 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
978 );
979 let (whole_file, line_scoped) = split_scopes(scopes);
980 assert_eq!(whole_file, vec!["shim.py".to_string()]);
981 assert_eq!(line_scoped.len(), 1);
982 assert_eq!(
983 line_scoped["widget.py"],
984 std::collections::BTreeSet::from([3])
985 );
986 }
987
988 fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
989 config.exemptions(colocated_test::Language::Python)
990 }
991
992 #[test]
993 fn a_violation_with_an_unwaivable_rule_id_is_kept() {
994 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
995 std::fs::create_dir_all(&dir).unwrap();
996 let config_path = dir.join("testing-conventions.toml");
997 std::fs::write(&config_path, "").unwrap();
998 let violation = lint::Violation {
999 file: dir.join("widget_test.py"),
1000 line: 1,
1001 rule: "not-a-waivable-rule",
1002 message: "synthetic".to_string(),
1003 };
1004 let kept = apply_waivers(
1005 vec![violation.clone()],
1006 &dir,
1007 &config_path,
1008 python_exemptions,
1009 );
1010 let _ = std::fs::remove_dir_all(&dir);
1011 assert_eq!(kept.unwrap(), vec![violation]);
1012 }
1013
1014 #[test]
1015 fn a_missing_config_keeps_every_violation() {
1016 let violation = lint::Violation {
1017 file: PathBuf::from("/tree/widget_test.py"),
1018 line: 1,
1019 rule: "no-monkeypatch",
1020 message: "synthetic".to_string(),
1021 };
1022 let kept = apply_waivers(
1023 vec![violation.clone()],
1024 Path::new("/tree"),
1025 Path::new("/nonexistent-tc-lib.toml"),
1026 python_exemptions,
1027 );
1028 assert_eq!(kept.unwrap(), vec![violation]);
1029 }
1030
1031 #[test]
1032 fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
1033 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
1034 std::fs::create_dir_all(&dir).unwrap();
1035 std::fs::write(dir.join("widget_test.py"), "def test_widget():\n pass\n").unwrap();
1036 let config_path = dir.join("testing-conventions.toml");
1037 std::fs::write(
1038 &config_path,
1039 "[[python.exempt]]\n\
1040 path = \"widget_test.py\"\n\
1041 rules = [\"no-monkeypatch\"]\n\
1042 reason = \"synthetic waiver for the resolution paths\"\n",
1043 )
1044 .unwrap();
1045 let violation = |file: PathBuf| lint::Violation {
1046 file,
1047 line: 1,
1048 rule: "no-monkeypatch",
1049 message: "synthetic".to_string(),
1050 };
1051 let waived = violation(dir.join("widget_test.py"));
1052 let kept_in_root = violation(dir.join("other_test.py"));
1053 let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1054 let kept = apply_waivers(
1055 vec![waived, kept_in_root.clone(), outside_root.clone()],
1056 &dir,
1057 &config_path,
1058 python_exemptions,
1059 );
1060 let _ = std::fs::remove_dir_all(&dir);
1061 assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1062 }
1063
1064 #[test]
1065 fn help_flag_returns_clap_display_help() {
1066 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1067 let clap_err = err
1068 .downcast_ref::<clap::Error>()
1069 .expect("error should be a clap::Error");
1070 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1071 }
1072
1073 #[test]
1074 fn version_flag_returns_clap_display_version() {
1075 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1076 let clap_err = err
1077 .downcast_ref::<clap::Error>()
1078 .expect("error should be a clap::Error");
1079 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1080 }
1081}