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