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;
15mod rust_patch_coverage;
16mod subprocess_seam;
17pub mod tiers;
18pub mod ts;
19mod unit_coverage;
20mod unit_mutation;
21pub mod violation;
22mod walk;
23pub mod workflow;
24pub mod workflow_lint;
25
26use std::path::{Path, PathBuf};
27
28use clap::{CommandFactory, Parser, Subcommand};
29
30#[derive(Parser, Debug)]
31#[command(
32 name = "testing-conventions",
33 version,
34 about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
35 long_about = None,
36)]
37pub struct Cli {
38 #[command(subcommand)]
39 command: Option<Command>,
40}
41
42#[derive(Subcommand, Debug)]
43enum Command {
44 Install {
49 #[arg(default_value = "AGENTS.md")]
51 path: PathBuf,
52 },
53 Unit {
55 #[command(subcommand)]
56 rule: UnitRule,
57 },
58 Integration {
60 #[command(subcommand)]
61 rule: IntegrationRule,
62 },
63 Packaging {
65 path: PathBuf,
68 #[arg(long, value_enum)]
71 language: Option<colocated_test::Language>,
72 },
73 #[command(hide = true)]
78 Workflow {
79 path: PathBuf,
81 },
82 E2e {
84 #[command(subcommand)]
85 command: E2eCommand,
86 },
87 WorkflowLint {
91 #[arg(default_value = ".github")]
93 path: PathBuf,
94 },
95 Changelog {
98 #[arg(long)]
100 base: String,
101 #[arg(default_value = ".")]
103 path: PathBuf,
104 },
105}
106
107#[derive(Subcommand, Debug)]
108enum UnitRule {
109 ColocatedTest {
115 path: PathBuf,
117 #[arg(long, value_enum)]
119 language: colocated_test::Language,
120 #[arg(long)]
126 base: Option<String>,
127 #[arg(long, default_value = "testing-conventions.toml")]
130 config: PathBuf,
131 },
132 Coverage {
137 path: PathBuf,
139 #[arg(long, value_enum)]
141 language: colocated_test::Language,
142 #[arg(long)]
148 base: Option<String>,
149 #[arg(long, default_value = "testing-conventions.toml")]
154 config: PathBuf,
155 },
156 OneFunctionPerFile {
160 path: PathBuf,
162 #[arg(long, value_enum)]
164 language: colocated_test::Language,
165 #[arg(long, default_value = "testing-conventions.toml")]
170 config: PathBuf,
171 },
172 Lint {
174 path: PathBuf,
176 #[arg(long, value_enum)]
178 language: isolation::Language,
179 #[arg(long, default_value = "testing-conventions.toml")]
182 config: PathBuf,
183 },
184 Mutation {
190 path: PathBuf,
192 #[arg(long, value_enum)]
194 language: colocated_test::Language,
195 #[arg(long)]
199 base: Option<String>,
200 #[arg(long, default_value = "testing-conventions.toml")]
203 config: PathBuf,
204 #[arg(long = "ts-mutation-adapter", hide = true)]
208 ts_adapter: Option<PathBuf>,
209 },
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
214pub enum IntegrationLintLanguage {
215 #[value(name = "python")]
217 Python,
218 #[value(name = "typescript")]
220 TypeScript,
221 #[value(name = "rust")]
223 Rust,
224}
225
226#[derive(Subcommand, Debug)]
227enum IntegrationRule {
228 Lint {
230 path: PathBuf,
232 #[arg(long, value_enum)]
234 language: IntegrationLintLanguage,
235 #[arg(long, default_value = "testing-conventions.toml")]
238 config: PathBuf,
239 },
240}
241
242#[derive(Subcommand, Debug)]
243enum E2eCommand {
244 Attest {
248 command: String,
250 },
251 Verify {
253 #[arg(default_value = ".")]
256 path: PathBuf,
257 #[arg(long)]
260 scope: Option<PathBuf>,
261 #[arg(long)]
268 base: Option<String>,
269 #[arg(long = "extra-scope")]
274 extra_scope: Vec<PathBuf>,
275 #[arg(long = "exclude")]
279 exclude: Vec<PathBuf>,
280 #[arg(long)]
283 branch: Option<String>,
284 },
285 Slug {
288 branch: Option<String>,
290 },
291}
292
293pub fn run<I, T>(args: I) -> anyhow::Result<i32>
294where
295 I: IntoIterator<Item = T>,
296 T: Into<std::ffi::OsString> + Clone,
297{
298 eprintln!("testing-conventions {}", env!("CARGO_PKG_VERSION"));
301 let cli = Cli::try_parse_from(args)?;
302 match cli.command {
303 None => Ok(0),
304 Some(Command::Unit { rule }) => match rule {
305 UnitRule::ColocatedTest {
306 path,
307 language,
308 base,
309 config,
310 } => run_unit_colocated_test(&path, language, base.as_deref(), &config),
311 UnitRule::Coverage {
312 path,
313 language,
314 base,
315 config,
316 } => unit_coverage::run(&path, language, base.as_deref(), &config),
317 UnitRule::OneFunctionPerFile {
318 path,
319 language,
320 config,
321 } => run_unit_one_function(&path, language, &config),
322 UnitRule::Lint {
323 path,
324 language,
325 config,
326 } => run_unit_lint(&path, language, &config),
327 UnitRule::Mutation {
328 path,
329 language,
330 base,
331 config,
332 ts_adapter,
333 } => unit_mutation::run(
334 &path,
335 language,
336 base.as_deref(),
337 &config,
338 ts_adapter.as_deref(),
339 ),
340 },
341 Some(Command::Integration { rule }) => match rule {
342 IntegrationRule::Lint {
343 path,
344 language,
345 config,
346 } => run_integration_lint(&path, language, &config),
347 },
348 Some(Command::Packaging { path, language }) => run_packaging(&path, language),
349 Some(Command::Changelog { base, path }) => run_changelog(&base, &path),
350 Some(Command::Workflow { path }) => run_workflow(&path),
351 Some(Command::WorkflowLint { path }) => run_workflow_lint(&path),
352 Some(Command::E2e { command }) => match command {
353 E2eCommand::Attest { command } => run_e2e_attest(&command),
354 E2eCommand::Verify {
355 path,
356 scope,
357 base,
358 extra_scope,
359 exclude,
360 branch,
361 } => run_e2e_verify(
362 &path,
363 scope.as_deref(),
364 base.as_deref(),
365 &extra_scope,
366 &exclude,
367 branch.as_deref(),
368 ),
369 E2eCommand::Slug { branch } => run_e2e_slug(branch.as_deref()),
370 },
371 Some(Command::Install { path }) => {
372 agents::install(&path)?;
373 Ok(0)
374 }
375 }
376}
377
378pub fn command() -> clap::Command {
380 Cli::command()
381}
382
383fn run_unit_colocated_test(
386 root: &Path,
387 language: colocated_test::Language,
388 base: Option<&str>,
389 config_path: &Path,
390) -> anyhow::Result<i32> {
391 if base.is_some() && language == colocated_test::Language::Rust {
392 anyhow::bail!(
393 "`unit colocated-test --base` supports `--language python` / `typescript`; Rust \
394 units are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
395 );
396 }
397 let presence_clean = report_colocated_presence(root, language, config_path)?;
398 let co_change_clean = match base {
399 Some(base) => report_co_change(root, base, language, config_path)?,
400 None => true,
401 };
402 Ok(if presence_clean && co_change_clean {
403 0
404 } else {
405 1
406 })
407}
408
409fn report_colocated_presence(
412 root: &Path,
413 language: colocated_test::Language,
414 config_path: &Path,
415) -> anyhow::Result<bool> {
416 let exempt = colocated_test_exemptions(root, language, config_path)?;
417 let orphans = match language {
418 colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
419 _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
420 };
421 if orphans.is_empty() {
422 return Ok(true);
423 }
424 let (label, summary) = match language {
425 colocated_test::Language::Rust => (
426 "missing inline `#[cfg(test)]` tests",
427 "source file(s) with testable code but no inline `#[cfg(test)]` module \
428 (add an inline test module, or an `exempt` entry with a reason)",
429 ),
430 _ => (
431 "missing colocated unit test",
432 "source file(s) missing a colocated unit test \
433 (add a colocated test, or an `exempt` entry with a reason)",
434 ),
435 };
436 for orphan in &orphans {
437 eprintln!("{label}: {}", orphan.display());
438 }
439 eprintln!("error: {} {summary}", orphans.len());
440 Ok(false)
441}
442
443fn colocated_test_exemptions(
445 root: &Path,
446 language: colocated_test::Language,
447 config_path: &Path,
448) -> anyhow::Result<std::collections::BTreeSet<String>> {
449 if !config_path.exists() {
450 return Ok(std::collections::BTreeSet::new());
451 }
452 let config = config::load_config(config_path)?;
453 config::resolve_exempt(
454 root,
455 config.exemptions(language),
456 config::Rule::ColocatedTest,
457 )
458}
459
460fn report_co_change(
463 root: &Path,
464 base: &str,
465 language: colocated_test::Language,
466 config_path: &Path,
467) -> anyhow::Result<bool> {
468 let exempt = co_change_exemptions(root, language, config_path)?;
469 let stale = co_change::stale_sources(root, base, language, &exempt)?;
470 if stale.is_empty() {
471 return Ok(true);
472 }
473 for source in &stale {
474 eprintln!(
475 "source changed without its colocated test: {}",
476 source.display()
477 );
478 }
479 eprintln!(
480 "error: {} source file(s) changed without their colocated test co-changing \
481 (update the test, or add an `exempt` entry with a reason)",
482 stale.len()
483 );
484 Ok(false)
485}
486
487fn co_change_exemptions(
489 root: &Path,
490 language: colocated_test::Language,
491 config_path: &Path,
492) -> anyhow::Result<std::collections::BTreeSet<String>> {
493 if !config_path.exists() {
494 return Ok(std::collections::BTreeSet::new());
495 }
496 let config = config::load_config(config_path)?;
497 config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
498}
499
500fn split_scopes(
502 scopes: std::collections::BTreeMap<String, config::LineScope>,
503) -> (
504 Vec<String>,
505 std::collections::BTreeMap<String, std::collections::BTreeSet<u32>>,
506) {
507 let mut whole_file = Vec::new();
508 let mut line_scoped = std::collections::BTreeMap::new();
509 for (path, scope) in scopes {
510 match scope {
511 config::LineScope::WholeFile => whole_file.push(path),
512 config::LineScope::Lines(lines) => {
513 line_scoped.insert(path, lines);
514 }
515 }
516 }
517 (whole_file, line_scoped)
518}
519
520fn run_unit_one_function(
523 root: &Path,
524 language: colocated_test::Language,
525 config_path: &Path,
526) -> anyhow::Result<i32> {
527 let threshold = if config_path.exists() {
528 config::load_config(config_path)?.one_function_threshold(language)
529 } else {
530 config::Config::default().one_function_threshold(language)
531 };
532 let key = match language {
533 colocated_test::Language::Python => "python",
534 colocated_test::Language::TypeScript => "typescript",
535 colocated_test::Language::Rust => "rust",
536 };
537 let Some(max_lines) = threshold else {
538 println!(
539 "unit one-function-per-file: not enabled for {key} — \
540 set `[{key}].one_function_per_file` to opt in"
541 );
542 return Ok(0);
543 };
544 let (raw, scanned) = one_function::find_violations(root, language, max_lines)?;
545 let select: ExemptSelect = match language {
546 colocated_test::Language::Python => |c| c.exemptions(colocated_test::Language::Python),
547 colocated_test::Language::TypeScript => {
548 |c| c.exemptions(colocated_test::Language::TypeScript)
549 }
550 colocated_test::Language::Rust => |c| c.rust_exemptions(),
551 };
552 let violations = apply_waivers(raw, root, config_path, select)?;
553 if violations.is_empty() {
554 eprintln!("one-function-per-file: scanned {scanned} file(s), 0 violations");
555 return Ok(0);
556 }
557 for v in &violations {
558 eprintln!(
559 "{}:{}: {} — {}",
560 v.file.display(),
561 v.line,
562 v.rule,
563 v.message
564 );
565 }
566 eprintln!(
567 "error: {} function(s) sharing a file with another function over the \
568 {max_lines}-line threshold (move each to its own module, or add an \
569 `exempt` entry with a reason)",
570 violations.len()
571 );
572 Ok(1)
573}
574
575fn run_unit_lint(
579 root: &Path,
580 language: isolation::Language,
581 config_path: &Path,
582) -> anyhow::Result<i32> {
583 let crate_root = tiers::package_root(root, "Cargo.toml");
584 let (raw, select, waiver_root): (Vec<lint::Violation>, ExemptSelect, &Path) = match language {
585 isolation::Language::Rust => {
586 let crate_root = crate_root.as_deref().unwrap_or(root);
587 (
588 isolation::find_violations(root, crate_root)?,
589 |c| c.rust_exemptions(),
590 crate_root,
591 )
592 }
593 isolation::Language::TypeScript => (
594 ts::find_unit_violations(root)?,
595 |c| c.exemptions(colocated_test::Language::TypeScript),
596 root,
597 ),
598 isolation::Language::Python => (
599 lint::find_unit_isolation_violations(root)?,
600 |c| c.exemptions(colocated_test::Language::Python),
601 root,
602 ),
603 };
604 let violations = apply_waivers(raw, waiver_root, config_path, select)?;
605 if violations.is_empty() {
606 return Ok(0);
607 }
608 for v in &violations {
609 eprintln!(
610 "{}:{}: {} — {}",
611 v.file.display(),
612 v.line,
613 v.rule,
614 v.message
615 );
616 }
617 eprintln!("error: {} isolation violation(s)", violations.len());
618 Ok(1)
619}
620
621fn run_integration_lint(
624 root: &Path,
625 language: IntegrationLintLanguage,
626 config_path: &Path,
627) -> anyhow::Result<i32> {
628 let manifest = match language {
629 IntegrationLintLanguage::Python => "pyproject.toml",
630 IntegrationLintLanguage::TypeScript => "package.json",
631 IntegrationLintLanguage::Rust => "Cargo.toml",
632 };
633 let package_root = tiers::package_root(root, manifest);
634 let scan_root = package_root.as_deref().unwrap_or(root);
635 let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
636 IntegrationLintLanguage::Python => (
637 match &package_root {
638 Some(package_root) => lint::find_suite_violations(package_root)?,
639 None => lint::find_violations(root)?,
640 },
641 |c| c.exemptions(colocated_test::Language::Python),
642 ),
643 IntegrationLintLanguage::TypeScript => (
644 match &package_root {
645 Some(package_root) => ts::find_suite_violations(package_root)?,
646 None => ts::find_integration_violations(root)?,
647 },
648 |c| c.exemptions(colocated_test::Language::TypeScript),
649 ),
650 IntegrationLintLanguage::Rust => {
651 (isolation::find_integration_violations(scan_root)?, |c| {
652 c.rust_exemptions()
653 })
654 }
655 };
656 let violations = apply_waivers(raw, scan_root, config_path, select)?;
657 if violations.is_empty() {
658 return Ok(0);
659 }
660 for v in &violations {
661 eprintln!(
662 "{}:{}: {} — {}",
663 v.file.display(),
664 v.line,
665 v.rule,
666 v.message
667 );
668 }
669 eprintln!("error: {} lint violation(s)", violations.len());
670 Ok(1)
671}
672
673type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
675
676fn apply_waivers(
678 violations: Vec<lint::Violation>,
679 root: &Path,
680 config_path: &Path,
681 exemptions: ExemptSelect,
682) -> anyhow::Result<Vec<lint::Violation>> {
683 use std::collections::hash_map::Entry;
684
685 if !config_path.exists() {
686 return Ok(violations);
687 }
688 let config = config::load_config(config_path)?;
689 let exempt = exemptions(&config);
690 let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
691 std::collections::HashMap::new();
692 let mut kept = Vec::new();
693 for violation in violations {
694 let waived = match config::Rule::from_id(violation.rule) {
695 Some(rule) => {
696 let exempt_paths = match resolved.entry(rule) {
697 Entry::Occupied(entry) => entry.into_mut(),
698 Entry::Vacant(entry) => {
699 entry.insert(config::resolve_exempt(root, exempt, rule)?)
700 }
701 };
702 violation
703 .file
704 .strip_prefix(root)
705 .ok()
706 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
707 .is_some_and(|rel| exempt_paths.contains(&rel))
708 }
709 None => false,
710 };
711 if !waived {
712 kept.push(violation);
713 }
714 }
715 Ok(kept)
716}
717
718fn run_changelog(base: &str, root: &Path) -> anyhow::Result<i32> {
721 let Some(layout) = changelog::discover_layout(root) else {
722 println!(
723 "No fragment directories under `{}`; changelog check skipped.",
724 root.display()
725 );
726 return Ok(0);
727 };
728 if changelog::has_skip_line(&changelog::commit_bodies(root, base)?) {
729 println!("A `skip-changelog:` line is present; changelog check bypassed.");
730 return Ok(0);
731 }
732 let changed = changelog::changed_files(root, base)?;
733 let added = changelog::added_files(root, base)?;
734 let migrations = changelog::migrations_enforced(root);
735 let found = changelog::findings(&layout, migrations, &changed, &added);
736 if found.is_empty() {
737 println!("Every scope that changed public surface added its fragments.");
738 return Ok(0);
739 }
740 for finding in &found {
741 match &finding.file {
742 Some(file) => println!("::error file={file}::{}", finding.message),
743 None => println!("::error::{}", finding.message),
744 }
745 }
746 Ok(1)
747}
748
749fn run_packaging(path: &Path, language: Option<colocated_test::Language>) -> anyhow::Result<i32> {
753 let distributions = match language {
754 Some(language) => vec![packaging::Distribution {
755 path: path.to_path_buf(),
756 language,
757 }],
758 None => packaging::discover(path)?,
759 };
760 if distributions.is_empty() {
761 anyhow::bail!(
762 "no recognized built distribution (`.whl`, `.tar.gz`, `.tgz`, `.crate`) at `{}`",
763 path.display()
764 );
765 }
766 let mut shipped = 0;
767 for distribution in &distributions {
768 shipped += report_shipped_test_files(distribution)?;
769 }
770 if shipped > 0 {
771 eprintln!(
772 "error: {shipped} test file(s) present in the built distribution(s) \
773 (they must be excluded from packaging)"
774 );
775 return Ok(1);
776 }
777 println!(
778 "checked {} built distribution(s); no test files shipped",
779 distributions.len()
780 );
781 Ok(0)
782}
783
784fn report_shipped_test_files(distribution: &packaging::Distribution) -> anyhow::Result<usize> {
786 let globs = match distribution.language {
787 colocated_test::Language::Python => vec!["*_test.py".to_string()],
788 colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
789 colocated_test::Language::Rust => vec!["tests/".to_string()],
791 };
792 let offenders = packaging::inspect(&distribution.path, &globs)?;
793 for offender in &offenders {
794 eprintln!(
795 "test file in built artifact `{}`: {}",
796 distribution.path.display(),
797 offender.display()
798 );
799 }
800 Ok(offenders.len())
801}
802
803fn run_workflow(path: &Path) -> anyhow::Result<i32> {
806 let violations = workflow::check(path, &command())?;
807 if violations.is_empty() {
808 return Ok(0);
809 }
810 for v in &violations {
811 eprintln!(
812 "{}:{}: {} — {}",
813 v.file.display(),
814 v.line,
815 v.rule,
816 v.message
817 );
818 }
819 eprintln!(
820 "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
821 violations.len()
822 );
823 Ok(1)
824}
825
826fn run_workflow_lint(path: &Path) -> anyhow::Result<i32> {
827 let findings = workflow_lint::scan(path)?;
828 if findings.is_empty() {
829 return Ok(0);
830 }
831 for f in &findings {
832 eprintln!(
833 "{}:{}: {} step `{}` encodes logic inline ({}) — move it into a tested package in \
834 this repository's own language, invoked as a one-line `run:`",
835 f.file.display(),
836 f.line,
837 f.kind,
838 f.step,
839 f.reasons.join("; ")
840 );
841 }
842 eprintln!(
843 "error: {} step(s) encode logic in CI YAML, where nothing tests it",
844 findings.len()
845 );
846 Ok(1)
847}
848
849fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
852 let repo = std::env::current_dir()?;
853 let attestation = e2e::attest(&repo, command)?;
854 if attestation.exit_code != 0 {
855 eprintln!(
856 "e2e command `{command}` exited {}; a receipt records a run that passed — \
857 fix the failure and attest again",
858 attestation.exit_code
859 );
860 return Ok(attestation.exit_code);
861 }
862 println!(
863 "e2e receipt recorded for branch {} at {}/{}.json",
864 attestation.branch,
865 e2e::RECEIPTS_DIR,
866 e2e::branch_slug(&attestation.branch),
867 );
868 Ok(0)
869}
870
871fn run_e2e_verify(
875 path: &Path,
876 scope: Option<&Path>,
877 base: Option<&str>,
878 extra_scopes: &[PathBuf],
879 excludes: &[PathBuf],
880 branch: Option<&str>,
881) -> anyhow::Result<i32> {
882 match e2e::verify_extra_scoped(
883 path,
884 scope.unwrap_or(path),
885 base,
886 extra_scopes,
887 excludes,
888 branch,
889 )? {
890 e2e::Verification::Fresh => Ok(0),
891 e2e::Verification::Missing => {
892 eprintln!(
893 "no e2e receipt answers this change — run \
894 `testing-conventions e2e attest '<your e2e command>'`; the command is \
895 your judgment: the full suite, a targeted subset, or a no-op"
896 );
897 Ok(1)
898 }
899 }
900}
901
902fn run_e2e_slug(branch: Option<&str>) -> anyhow::Result<i32> {
904 let slug = match branch {
905 Some(name) => e2e::branch_slug(name),
906 None => {
907 let repo = std::env::current_dir()?;
908 e2e::branch_slug(&e2e::current_branch(&repo)?)
909 }
910 };
911 println!("{slug}");
912 Ok(0)
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn no_args_returns_ok_zero() {
921 assert_eq!(run(["testing-conventions"]).unwrap(), 0);
922 }
923
924 #[test]
925 fn unknown_flag_errors() {
926 assert!(run(["testing-conventions", "--bogus"]).is_err());
927 }
928
929 #[test]
930 fn split_scopes_separates_whole_file_paths_from_line_sets() {
931 let mut scopes = std::collections::BTreeMap::new();
932 scopes.insert("shim.py".to_string(), config::LineScope::WholeFile);
933 scopes.insert(
934 "widget.py".to_string(),
935 config::LineScope::Lines(std::collections::BTreeSet::from([3])),
936 );
937 let (whole_file, line_scoped) = split_scopes(scopes);
938 assert_eq!(whole_file, vec!["shim.py".to_string()]);
939 assert_eq!(line_scoped.len(), 1);
940 assert_eq!(
941 line_scoped["widget.py"],
942 std::collections::BTreeSet::from([3])
943 );
944 }
945
946 fn python_exemptions(config: &config::Config) -> &[config::Exemption] {
947 config.exemptions(colocated_test::Language::Python)
948 }
949
950 #[test]
951 fn a_violation_with_an_unwaivable_rule_id_is_kept() {
952 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-{}", std::process::id()));
953 std::fs::create_dir_all(&dir).unwrap();
954 let config_path = dir.join("testing-conventions.toml");
955 std::fs::write(&config_path, "").unwrap();
956 let violation = lint::Violation {
957 file: dir.join("widget_test.py"),
958 line: 1,
959 rule: "not-a-waivable-rule",
960 message: "synthetic".to_string(),
961 };
962 let kept = apply_waivers(
963 vec![violation.clone()],
964 &dir,
965 &config_path,
966 python_exemptions,
967 );
968 let _ = std::fs::remove_dir_all(&dir);
969 assert_eq!(kept.unwrap(), vec![violation]);
970 }
971
972 #[test]
973 fn a_missing_config_keeps_every_violation() {
974 let violation = lint::Violation {
975 file: PathBuf::from("/tree/widget_test.py"),
976 line: 1,
977 rule: "no-monkeypatch",
978 message: "synthetic".to_string(),
979 };
980 let kept = apply_waivers(
981 vec![violation.clone()],
982 Path::new("/tree"),
983 Path::new("/nonexistent-tc-lib.toml"),
984 python_exemptions,
985 );
986 assert_eq!(kept.unwrap(), vec![violation]);
987 }
988
989 #[test]
990 fn waivers_resolve_each_rule_once_and_keep_out_of_root_files() {
991 let dir = std::env::temp_dir().join(format!("tc-lib-waiver-full-{}", std::process::id()));
992 std::fs::create_dir_all(&dir).unwrap();
993 std::fs::write(dir.join("widget_test.py"), "def test_widget():\n pass\n").unwrap();
994 let config_path = dir.join("testing-conventions.toml");
995 std::fs::write(
996 &config_path,
997 "[[python.exempt]]\n\
998 path = \"widget_test.py\"\n\
999 rules = [\"no-monkeypatch\"]\n\
1000 reason = \"synthetic waiver for the resolution paths\"\n",
1001 )
1002 .unwrap();
1003 let violation = |file: PathBuf| lint::Violation {
1004 file,
1005 line: 1,
1006 rule: "no-monkeypatch",
1007 message: "synthetic".to_string(),
1008 };
1009 let waived = violation(dir.join("widget_test.py"));
1010 let kept_in_root = violation(dir.join("other_test.py"));
1011 let outside_root = violation(PathBuf::from("/elsewhere/widget_test.py"));
1012 let kept = apply_waivers(
1013 vec![waived, kept_in_root.clone(), outside_root.clone()],
1014 &dir,
1015 &config_path,
1016 python_exemptions,
1017 );
1018 let _ = std::fs::remove_dir_all(&dir);
1019 assert_eq!(kept.unwrap(), vec![kept_in_root, outside_root]);
1020 }
1021
1022 #[test]
1023 fn help_flag_returns_clap_display_help() {
1024 let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
1025 let clap_err = err
1026 .downcast_ref::<clap::Error>()
1027 .expect("error should be a clap::Error");
1028 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
1029 }
1030
1031 #[test]
1032 fn version_flag_returns_clap_display_version() {
1033 let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
1034 let clap_err = err
1035 .downcast_ref::<clap::Error>()
1036 .expect("error should be a clap::Error");
1037 assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
1038 }
1039}