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