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