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;
16pub mod workflow;
17
18use std::path::{Path, PathBuf};
19
20use clap::{CommandFactory, Parser, Subcommand};
21
22#[derive(Parser, Debug)]
23#[command(
24 name = "testing-conventions",
25 version,
26 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
27 long_about = None,
28)]
29pub struct Cli {
30 #[command(subcommand)]
31 command: Option<Command>,
32}
33
34#[derive(Subcommand, Debug)]
35enum Command {
36 Install {
41 #[arg(default_value = "AGENTS.md")]
43 path: PathBuf,
44 },
45 Unit {
47 #[command(subcommand)]
48 rule: UnitRule,
49 },
50 Integration {
52 #[command(subcommand)]
53 rule: IntegrationRule,
54 },
55 Packaging {
57 path: PathBuf,
59 #[arg(long, value_enum)]
61 language: colocated_test::Language,
62 },
63 #[command(hide = true)]
68 Workflow {
69 path: PathBuf,
71 },
72 E2e {
74 #[command(subcommand)]
75 command: E2eCommand,
76 },
77}
78
79#[derive(Subcommand, Debug)]
80enum UnitRule {
81 ColocatedTest {
87 path: PathBuf,
89 #[arg(long, value_enum)]
91 language: colocated_test::Language,
92 #[arg(long)]
98 base: Option<String>,
99 #[arg(long, default_value = "testing-conventions.toml")]
102 config: PathBuf,
103 },
104 Coverage {
109 path: PathBuf,
111 #[arg(long, value_enum)]
113 language: colocated_test::Language,
114 #[arg(long)]
120 base: Option<String>,
121 #[arg(long, default_value = "testing-conventions.toml")]
126 config: PathBuf,
127 },
128 OneFunctionPerFile {
132 path: PathBuf,
134 #[arg(long, value_enum)]
136 language: colocated_test::Language,
137 #[arg(long, default_value = "testing-conventions.toml")]
142 config: PathBuf,
143 },
144 Lint {
146 path: PathBuf,
148 #[arg(long, value_enum)]
150 language: isolation::Language,
151 #[arg(long, default_value = "testing-conventions.toml")]
154 config: PathBuf,
155 },
156 Mutation {
162 path: PathBuf,
164 #[arg(long, value_enum)]
166 language: colocated_test::Language,
167 #[arg(long)]
171 base: Option<String>,
172 #[arg(long, default_value = "testing-conventions.toml")]
175 config: PathBuf,
176 #[arg(long = "ts-mutation-adapter", hide = true)]
180 ts_adapter: Option<PathBuf>,
181 },
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
186pub enum IntegrationLintLanguage {
187 #[value(name = "python")]
189 Python,
190 #[value(name = "typescript")]
192 TypeScript,
193 #[value(name = "rust")]
195 Rust,
196}
197
198#[derive(Subcommand, Debug)]
199enum IntegrationRule {
200 Lint {
202 path: PathBuf,
204 #[arg(long, value_enum)]
206 language: IntegrationLintLanguage,
207 #[arg(long, default_value = "testing-conventions.toml")]
210 config: PathBuf,
211 },
212}
213
214#[derive(Subcommand, Debug)]
215enum E2eCommand {
216 Attest {
220 command: String,
222 },
223 Verify {
225 #[arg(default_value = ".")]
228 path: PathBuf,
229 #[arg(long)]
232 scope: Option<PathBuf>,
233 #[arg(long)]
240 base: Option<String>,
241 #[arg(long = "extra-scope")]
246 extra_scope: Vec<PathBuf>,
247 #[arg(long = "exclude")]
251 exclude: Vec<PathBuf>,
252 },
253 Slug {
256 branch: Option<String>,
258 },
259}
260
261pub fn run<I, T>(args: I) -> anyhow::Result<i32>
262where
263 I: IntoIterator<Item = T>,
264 T: Into<std::ffi::OsString> + Clone,
265{
266 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
269 let cli = Cli::try_parse_from(args)?;
270 match cli.command {
271 None => Ok(0),
272 Some(Command::Unit { rule }) => match rule {
273 UnitRule::ColocatedTest {
274 path,
275 language,
276 base,
277 config,
278 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
279 UnitRule::Coverage {
280 path,
281 language,
282 base,
283 config,
284 } => run_unit_coverage(&path, language, base.as_deref(), &config),
285 UnitRule::OneFunctionPerFile {
286 path,
287 language,
288 config,
289 } => run_unit_one_function(&path, language, &config),
290 UnitRule::Lint {
291 path,
292 language,
293 config,
294 } => run_unit_lint(&path, language, &config),
295 UnitRule::Mutation {
296 path,
297 language,
298 base,
299 config,
300 ts_adapter,
301 } => run_unit_mutation(
302 &path,
303 language,
304 base.as_deref(),
305 &config,
306 ts_adapter.as_deref(),
307 ),
308 },
309 Some(Command::Integration { rule }) => match rule {
310 IntegrationRule::Lint {
311 path,
312 language,
313 config,
314 } => run_integration_lint(&path, language, &config),
315 },
316 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
317 Some(Command::Workflow { path }) => run_workflow(&path),
318 Some(Command::E2e { command }) => match command {
319 E2eCommand::Attest { command } => run_e2e_attest(&command),
320 E2eCommand::Verify {
321 path,
322 scope,
323 base,
324 extra_scope,
325 exclude,
326 } => run_e2e_verify(
327 &path,
328 scope.as_deref(),
329 base.as_deref(),
330 &extra_scope,
331 &exclude,
332 ),
333 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
334 },
335 Some(Command::Install { path }) => {
336 agents::install(&path)?;
337 Ok(0)
338 }
339 }
340}
341
342pub fn command() -> clap::Command {
344 Cli::command()
345}
346
347fn run_unit_colocated_test(
350 root: &Path,
351 language: colocated_test::Language,
352 base: Option<&str>,
353 config_path: &Path,
354) -> anyhow::Result<i32> {
355 if base.is_some() && language == colocated_test::Language::Rust {
356 anyhow::bail!(
357 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
358 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
359 );
360 }
361 let presence_clean = report_colocated_presence(root, language, config_path)?;
362 let co_change_clean = match base {
363 Some(base) => report_co_change(root, base, language, config_path)?,
364 None => true,
365 };
366 Ok(if presence_clean && co_change_clean {
367 0
368 } else {
369 1
370 })
371}
372
373fn report_colocated_presence(
376 root: &Path,
377 language: colocated_test::Language,
378 config_path: &Path,
379) -> anyhow::Result<bool> {
380 let exempt = colocated_test_exemptions(root, language, config_path)?;
381 let orphans = match language {
382 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
383 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
384 };
385 if orphans.is_empty() {
386 return Ok(true);
387 }
388 let (label, summary) = match language {
389 colocated_test::Language::Rust => (
390 "missing inline `#[cfg(test)]` tests",
391 "source file(s) with testable code but no inline `#[cfg(test)]` module \
392 (add an inline test module, or an `exempt` entry with a reason)",
393 ),
394 _ => (
395 "missing colocated unit test",
396 "source file(s) missing a colocated unit test \
397 (add a colocated test, or an `exempt` entry with a reason)",
398 ),
399 };
400 for orphan in &orphans {
401 eprintln!("{label}: {}", orphan.display());
402 }
403 eprintln!("error: {} {summary}", orphans.len());
404 Ok(false)
405}
406
407fn colocated_test_exemptions(
409 root: &Path,
410 language: colocated_test::Language,
411 config_path: &Path,
412) -> anyhow::Result<std::collections::BTreeSet<String>> {
413 if !config_path.exists() {
414 return Ok(std::collections::BTreeSet::new());
415 }
416 let config = config::load_config(config_path)?;
417 config::resolve_exempt(
418 root,
419 config.exemptions(language),
420 config::Rule::ColocatedTest,
421 )
422}
423
424fn report_co_change(
427 root: &Path,
428 base: &str,
429 language: colocated_test::Language,
430 config_path: &Path,
431) -> anyhow::Result<bool> {
432 let exempt = co_change_exemptions(root, language, config_path)?;
433 let stale = co_change::stale_sources(root, base, language, &exempt)?;
434 if stale.is_empty() {
435 return Ok(true);
436 }
437 for source in &stale {
438 eprintln!(
439 "source changed without its colocated test: {}",
440 source.display()
441 );
442 }
443 eprintln!(
444 "error: {} source file(s) changed without their colocated test co-changing \
445 (update the test, or add an `exempt` entry with a reason)",
446 stale.len()
447 );
448 Ok(false)
449}
450
451fn co_change_exemptions(
453 root: &Path,
454 language: colocated_test::Language,
455 config_path: &Path,
456) -> anyhow::Result<std::collections::BTreeSet<String>> {
457 if !config_path.exists() {
458 return Ok(std::collections::BTreeSet::new());
459 }
460 let config = config::load_config(config_path)?;
461 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
462}
463
464fn split_scopes(
466 scopes: std::collections::BTreeMap<String, config::LineScope>,
467) -> (
468 Vec<String>,
469 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
470) {
471 let mut whole_file = Vec::new();
472 let mut line_scoped = std::collections::BTreeMap::new();
473 for (path, scope) in scopes {
474 match scope {
475 config::LineScope::WholeFile => whole_file.push(path),
476 config::LineScope::Lines(lines) => {
477 line_scoped.insert(path, lines);
478 }
479 }
480 }
481 (whole_file, line_scoped)
482}
483
484fn run_unit_coverage(
487 root: &Path,
488 language: colocated_test::Language,
489 base: Option<&str>,
490 config_path: &Path,
491) -> anyhow::Result<i32> {
492 let config = if config_path.exists() {
493 config::load_config(config_path)?
494 } else {
495 config::Config::default()
496 };
497 let outcome = match language {
498 colocated_test::Language::Python => {
499 let python = config.python.unwrap_or_default();
500 let coverage = python.coverage.unwrap_or_default();
501 let thresholds = coverage::Thresholds {
502 fail_under: coverage.fail_under,
503 branch: coverage.branch,
504 };
505 let (omit, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
506 root,
507 &python.exempt,
508 config::Rule::Coverage,
509 )?);
510 match base {
511 Some(base) => {
512 patch_coverage::measure(root, base, thresholds, &omit, &exempt_lines)?
513 }
514 None if exempt_lines.is_empty() => coverage::measure(root, thresholds, &omit)?,
515 None => {
516 patch_coverage::measure_line_exempt(root, thresholds, &omit, &exempt_lines)?
517 }
518 }
519 }
520 colocated_test::Language::TypeScript => {
521 let typescript = config.typescript.unwrap_or_default();
522 let coverage = typescript.coverage.unwrap_or_default();
523 let thresholds = coverage::TypeScriptThresholds {
524 lines: coverage.lines,
525 branches: coverage.branches,
526 functions: coverage.functions,
527 statements: coverage.statements,
528 };
529 let (exclude, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
530 root,
531 &typescript.exempt,
532 config::Rule::Coverage,
533 )?);
534 match base {
535 Some(base) => patch_coverage::measure_typescript(
536 root,
537 base,
538 thresholds,
539 &exclude,
540 &exempt_lines,
541 )?,
542 None if exempt_lines.is_empty() => {
543 coverage::measure_typescript(root, thresholds, &exclude)?
544 }
545 None => patch_coverage::measure_line_exempt_typescript(
546 root,
547 thresholds,
548 &exclude,
549 &exempt_lines,
550 )?,
551 }
552 }
553 colocated_test::Language::Rust => {
554 let rust = config.rust.unwrap_or_default();
555 let coverage = rust.coverage.unwrap_or_default();
556 let thresholds = coverage::RustThresholds {
557 regions: coverage.regions,
558 lines: coverage.lines,
559 functions: coverage.functions,
560 branch: coverage.branch,
561 };
562 let (ignore, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
563 root,
564 &rust.exempt,
565 config::Rule::Coverage,
566 )?);
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 (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
616 root,
617 &rust.exempt,
618 config::Rule::Mutation,
619 )?);
620 mutation::measure_rust(root, &exempt, &exempt_lines, base, &rust.features)?
621 }
622 colocated_test::Language::TypeScript => {
623 let typescript = config.typescript.unwrap_or_default();
624 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
625 root,
626 &typescript.exempt,
627 config::Rule::Mutation,
628 )?);
629 let adapter = ts_adapter.ok_or_else(|| {
630 anyhow::anyhow!(
631 "the TypeScript mutation adapter path is required: pass \
632 `--ts-mutation-adapter <path>`. The npm `testing-conventions` CLI appends it \
633 automatically — run the rule through that CLI, not the raw binary."
634 )
635 })?;
636 mutation::measure_typescript(root, &exempt, &exempt_lines, base, adapter)?
637 }
638 colocated_test::Language::Python => {
639 let python = config.python.unwrap_or_default();
640 let (exempt, exempt_lines) = split_scopes(config::resolve_exempt_scoped(
641 root,
642 &python.exempt,
643 config::Rule::Mutation,
644 )?);
645 mutation::measure_python(root, &exempt, &exempt_lines, base)?
646 }
647 };
648 let (count, survivors) = match measurement {
649 mutation::Measurement::EngineNotRun => {
650 println!("unit mutation: no mutatable changed lines — engine not run");
651 return Ok(0);
652 }
653 mutation::Measurement::Tested { count, survivors } => (count, survivors),
654 };
655 if survivors.is_empty() {
656 if count == 0 {
657 println!("unit mutation: the engine found no mutants to test");
658 } else {
659 println!(
660 "unit mutation: no surviving mutants — every mutation was caught \
661 ({count} mutant(s) tested)"
662 );
663 }
664 return Ok(0);
665 }
666
667 eprintln!(
668 "error: {} unexplained surviving mutant(s) — kill each with an assertion, or lift an \
669 equivalent/defensive one with a reason-required `[[<language>.exempt]] rules = [\"mutation\"]`:",
670 survivors.len()
671 );
672 for survivor in &survivors {
673 eprintln!(
674 " {}:{}: {}",
675 survivor.file, survivor.line, survivor.description
676 );
677 }
678 Ok(1)
679}
680
681fn run_unit_one_function(
684 root: &Path,
685 language: colocated_test::Language,
686 config_path: &Path,
687) -> anyhow::Result<i32> {
688 let threshold = if config_path.exists() {
689 config::load_config(config_path)?.one_function_threshold(language)
690 } else {
691 config::Config::default().one_function_threshold(language)
692 };
693 let Some(max_lines) = threshold else {
694 let key = match language {
695 colocated_test::Language::Python => "python",
696 colocated_test::Language::TypeScript => "typescript",
697 colocated_test::Language::Rust => "rust",
698 };
699 println!(
700 "unit one-function-per-file: not enabled for {key} — \
701 set `[{key}].one_function_per_file` to opt in"
702 );
703 return Ok(0);
704 };
705 let raw = one_function::find_violations(root, language, max_lines)?;
706 let select: ExemptSelect = match language {
707 colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
708 colocated_test::Language::TypeScript => {
709 |c| c.exemptions(colocated_test::Language::TypeScript)
710 }
711 colocated_test::Language::Rust => |c| c.rust_exemptions(),
712 };
713 let violations = apply_waivers(raw, root, config_path, select)?;
714 if violations.is_empty() {
715 return Ok(0);
716 }
717 for v in &violations {
718 eprintln!(
719 "{}:{}: {} — {}",
720 v.file.display(),
721 v.line,
722 v.rule,
723 v.message
724 );
725 }
726 eprintln!(
727 "error: {} function(s) sharing a file with another function over the \
728 {max_lines}-line threshold (move each to its own module, or add an \
729 `exempt` entry with a reason)",
730 violations.len()
731 );
732 Ok(1)
733}
734
735fn run_unit_lint(
738 root: &Path,
739 language: isolation::Language,
740 config_path: &Path,
741) -> anyhow::Result<i32> {
742 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
743 isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
744 isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
745 c.exemptions(colocated_test::Language::TypeScript)
746 }),
747 isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
748 c.exemptions(colocated_test::Language::Python)
749 }),
750 };
751 let violations = apply_waivers(raw, root, config_path, select)?;
752 if violations.is_empty() {
753 return Ok(0);
754 }
755 for v in &violations {
756 eprintln!(
757 "{}:{}: {} — {}",
758 v.file.display(),
759 v.line,
760 v.rule,
761 v.message
762 );
763 }
764 eprintln!("error: {} isolation violation(s)", violations.len());
765 Ok(1)
766}
767
768fn run_integration_lint(
771 root: &Path,
772 language: IntegrationLintLanguage,
773 config_path: &Path,
774) -> anyhow::Result<i32> {
775 let manifest = match language {
776 IntegrationLintLanguage::Python => "pyproject.toml",
777 IntegrationLintLanguage::TypeScript => "package.json",
778 IntegrationLintLanguage::Rust => "Cargo.toml",
779 };
780 let package_root = tiers::package_root(root, manifest);
781 let scan_root = package_root.as_deref().unwrap_or(root);
782 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
783 IntegrationLintLanguage::Python => (
784 match &package_root {
785 Some(package_root) => lint::find_suite_violations(package_root)?,
786 None => lint::find_violations(root)?,
787 },
788 |c| c.exemptions(colocated_test::Language::Python),
789 ),
790 IntegrationLintLanguage::TypeScript => (
791 match &package_root {
792 Some(package_root) => ts::find_suite_violations(package_root)?,
793 None => ts::find_integration_violations(root)?,
794 },
795 |c| c.exemptions(colocated_test::Language::TypeScript),
796 ),
797 IntegrationLintLanguage::Rust => {
798 (isolation::find_integration_violations(scan_root)?, |c| {
799 c.rust_exemptions()
800 })
801 }
802 };
803 let violations = apply_waivers(raw, scan_root, config_path, select)?;
804 if violations.is_empty() {
805 return Ok(0);
806 }
807 for v in &violations {
808 eprintln!(
809 "{}:{}: {} — {}",
810 v.file.display(),
811 v.line,
812 v.rule,
813 v.message
814 );
815 }
816 eprintln!("error: {} lint violation(s)", violations.len());
817 Ok(1)
818}
819
820type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
822
823fn apply_waivers(
825 violations: Vec<lint::Violation>,
826 root: &Path,
827 config_path: &Path,
828 exemptions: ExemptSelect,
829) -> anyhow::Result<Vec<lint::Violation>> {
830 use std::collections::hash_map::Entry;
831
832 if !config_path.exists() {
833 return Ok(violations);
834 }
835 let config = config::load_config(config_path)?;
836 let exempt = exemptions(&config);
837 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
838 std::collections::HashMap::new();
839 let mut kept = Vec::new();
840 for violation in violations {
841 let waived = match config::Rule::from_id(violation.rule) {
842 Some(rule) => {
843 let exempt_paths = match resolved.entry(rule) {
844 Entry::Occupied(entry) => entry.into_mut(),
845 Entry::Vacant(entry) => {
846 entry.insert(config::resolve_exempt(root, exempt, rule)?)
847 }
848 };
849 violation
850 .file
851 .strip_prefix(root)
852 .ok()
853 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
854 .is_some_and(|rel| exempt_paths.contains(&rel))
855 }
856 None => false,
857 };
858 if !waived {
859 kept.push(violation);
860 }
861 }
862 Ok(kept)
863}
864
865fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
868 let globs = match language {
869 colocated_test::Language::Python => vec!["*_test.py".to_string()],
870 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
871 colocated_test::Language::Rust => vec!["tests/".to_string()],
873 };
874 let offenders = packaging::inspect(artifact, &globs)?;
875 if offenders.is_empty() {
876 return Ok(0);
877 }
878 for offender in &offenders {
879 eprintln!("test file in built artifact: {}", offender.display());
880 }
881 eprintln!(
882 "error: {} test file(s) present in the built artifact \
883 (they must be excluded from packaging)",
884 offenders.len()
885 );
886 Ok(1)
887}
888
889fn run_workflow(path: &Path) -> anyhow::Result<i32> {
892 let violations = workflow::check(path, &command())?;
893 if violations.is_empty() {
894 return Ok(0);
895 }
896 for v in &violations {
897 eprintln!(
898 "{}:{}: {} — {}",
899 v.file.display(),
900 v.line,
901 v.rule,
902 v.message
903 );
904 }
905 eprintln!(
906 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
907 violations.len()
908 );
909 Ok(1)
910}
911
912fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
915 let repo = std::env::current_dir()?;
916 let attestation = e2e::attest(&repo, command)?;
917 if attestation.exit_code != 0 {
918 eprintln!(
919 "e2e command `{command}` exited {}; a receipt records a run that passed — \
920 fix the failure and attest again",
921 attestation.exit_code
922 );
923 return Ok(attestation.exit_code);
924 }
925 println!(
926 "e2e receipt recorded for branch {} at {}/{}.json",
927 attestation.branch,
928 e2e::RECEIPTS_DIR,
929 e2e::branch_slug(&attestation.branch),
930 );
931 Ok(0)
932}
933
934fn run_e2e_verify(
938 path: &Path,
939 scope: Option<&Path>,
940 base: Option<&str>,
941 extra_scopes: &[PathBuf],
942 excludes: &[PathBuf],
943) -> anyhow::Result<i32> {
944 match e2e::verify_extra_scoped(path, scope.unwrap_or(path), base, extra_scopes, excludes)? {
945 e2e::Verification::Fresh => Ok(0),
946 e2e::Verification::Missing => {
947 eprintln!(
948 "no e2e receipt answers this change — run \
949 `testing-conventions e2e attest '<your e2e command>'`; the command is \
950 your judgment: the full suite, a targeted subset, or a no-op"
951 );
952 Ok(1)
953 }
954 }
955}
956
957fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
959 let slug = match branch {
960 Some(name) => e2e::branch_slug(name),
961 None => {
962 let repo = std::env::current_dir()?;
963 e2e::branch_slug(&e2e::current_branch(&repo)?)
964 }
965 };
966 println!("{slug}");
967 Ok(0)
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973
974 #[test]
975 fn no_args_returns_ok_zero() {
976 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
977 }
978
979 #[test]
980 fn unknown_flag_errors() {
981 assert!(run(["testing-conventions", "--bogus"]).is_err());
982 }
983
984 #[test]
985 fn help_flag_returns_clap_display_help() {
986 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
987 let clap_err = err
988 .downcast_ref::<clap::Error>()
989 .expect("error should be a clap::Error");
990 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
991 }
992
993 #[test]
994 fn version_flag_returns_clap_display_version() {
995 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
996 let clap_err = err
997 .downcast_ref::<clap::Error>()
998 .expect("error should be a clap::Error");
999 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1000 }
1001}