Skip to main content

testing_conventions/
lib.rs

1pub mod co_change;
2pub mod colocated_test;
3pub mod config;
4pub mod coverage;
5pub mod e2e;
6pub mod isolation;
7pub mod lint;
8pub mod packaging;
9pub mod patch_coverage;
10pub mod ts;
11pub mod violation;
12pub mod workflow;
13
14use std::path::{Path, PathBuf};
15
16use clap::{CommandFactory, Parser, Subcommand};
17
18#[derive(Parser, Debug)]
19#[command(
20    name = "testing-conventions",
21    version,
22    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
23    long_about = None,
24)]
25pub struct Cli {
26    #[command(subcommand)]
27    command: Option<Command>,
28}
29
30#[derive(Subcommand, Debug)]
31enum Command {
32    /// Check the repository against its testing-conventions config.
33    Check,
34    /// Unit-test conventions.
35    Unit {
36        #[command(subcommand)]
37        rule: UnitRule,
38    },
39    /// Integration-test conventions.
40    Integration {
41        #[command(subcommand)]
42        rule: IntegrationRule,
43    },
44    /// Packaging conventions: test files must not ship in the built artifact.
45    Packaging {
46        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
47        path: PathBuf,
48        /// Language convention to enforce (required).
49        #[arg(long, value_enum)]
50        language: colocated_test::Language,
51    },
52    /// Workflow guard: every `testing-conventions` invocation in a CI workflow must
53    /// name a subcommand this binary still exposes (guards the `@v0` path, #92).
54    Workflow {
55        /// Workflow file (or a directory of them) to scan.
56        path: PathBuf,
57    },
58    /// End-to-end-test conventions.
59    E2e {
60        #[command(subcommand)]
61        command: E2eCommand,
62    },
63}
64
65/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
66#[derive(Subcommand, Debug)]
67enum UnitRule {
68    /// Check that every source file has a colocated, matching-named unit test.
69    ColocatedTest {
70        /// Directory to scan recursively.
71        path: PathBuf,
72        /// Language convention to enforce (required).
73        #[arg(long, value_enum)]
74        language: colocated_test::Language,
75        /// testing-conventions config file providing the `exempt` list. Optional:
76        /// if the file is absent, no files are exempt.
77        #[arg(long, default_value = "testing-conventions.toml")]
78        config: PathBuf,
79    },
80    /// Check that the unit suite meets the configured coverage floor.
81    Coverage {
82        /// Directory whose unit suite is run and measured.
83        path: PathBuf,
84        /// Language convention to enforce (required).
85        #[arg(long, value_enum)]
86        language: colocated_test::Language,
87        /// testing-conventions config file with the coverage thresholds and
88        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
89        /// table — is absent, the language's sane default floor is used and
90        /// nothing is exempt.
91        #[arg(long, default_value = "testing-conventions.toml")]
92        config: PathBuf,
93    },
94    /// Check that every line a git diff touches is covered by the unit suite
95    /// (patch / changed-line coverage, #132). Diff-scoped complement to the
96    /// whole-suite `unit coverage` floor: only the `<base>...HEAD` changed lines
97    /// must be covered.
98    PatchCoverage {
99        /// Directory whose unit suite is run and measured; also where git runs.
100        path: PathBuf,
101        /// Language convention to enforce (required). Python only for now — the
102        /// TypeScript and Rust twins are separate items.
103        #[arg(long, value_enum)]
104        language: colocated_test::Language,
105        /// Base ref to diff against: the check compares `<base>...HEAD`, the
106        /// changes this branch introduced (what a PR shows). Defaults to
107        /// `origin/main`; override for a different base or an explicit range.
108        #[arg(long, default_value = "origin/main")]
109        base: String,
110        /// testing-conventions config file supplying the coverage `exempt` list.
111        /// Optional: if the file is absent, nothing is exempt.
112        #[arg(long, default_value = "testing-conventions.toml")]
113        config: PathBuf,
114    },
115    /// Check that unit tests isolate the unit under test (Rust, TypeScript).
116    Isolation {
117        /// Crate root / source dir to scan recursively.
118        path: PathBuf,
119        /// Language convention to enforce (required).
120        #[arg(long, value_enum)]
121        language: isolation::Language,
122        /// testing-conventions config file providing the `exempt` list (waivers).
123        /// Optional: if the file is absent, nothing is waived.
124        #[arg(long, default_value = "testing-conventions.toml")]
125        config: PathBuf,
126    },
127    /// Check that a source file changed in a git diff also changed its colocated
128    /// test (#33). Commit-scoped: a modified or deleted source whose colocated
129    /// test stays unchanged is a stale-test risk.
130    CoChange {
131        /// Directory to inspect (the repo root, or a subtree); also where git runs.
132        path: PathBuf,
133        /// Language convention to enforce (required). Python/TypeScript only —
134        /// Rust units are inline `#[cfg(test)]`, so a sibling test can't go stale.
135        #[arg(long, value_enum)]
136        language: colocated_test::Language,
137        /// Base ref to diff against: the check compares `<base>...HEAD`, the
138        /// changes this branch introduced (what a PR shows). Required.
139        #[arg(long)]
140        base: String,
141        /// testing-conventions config file providing the `exempt` list. Optional:
142        /// if the file is absent, no source is exempt from co-changing.
143        #[arg(long, default_value = "testing-conventions.toml")]
144        config: PathBuf,
145    },
146}
147
148/// Languages the integration-test lints support — its own set (Python,
149/// TypeScript, Rust), distinct from the file-pairing `colocated_test::Language`,
150/// so adding Rust here doesn't touch the colocated-test/coverage rules.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
152pub enum IntegrationLintLanguage {
153    /// Python test files (`*_test.py`, `test_*.py`, `conftest.py`).
154    #[value(name = "python")]
155    Python,
156    /// TypeScript test files (`*.test.{ts,tsx,mts,cts}`).
157    #[value(name = "typescript")]
158    TypeScript,
159    /// Rust integration crates under `tests/`.
160    #[value(name = "rust")]
161    Rust,
162}
163
164/// Lints enforced on integration tests (mocking mechanism & style, and more to
165/// come). The README's "Integration" taxonomy.
166#[derive(Subcommand, Debug)]
167enum IntegrationRule {
168    /// Lint integration test files for mocking mechanism & style (Python, TypeScript, Rust).
169    Lint {
170        /// Directory to scan recursively for test files.
171        path: PathBuf,
172        /// Language convention to enforce (required).
173        #[arg(long, value_enum)]
174        language: IntegrationLintLanguage,
175        /// testing-conventions config file providing the `exempt` list (waivers).
176        /// Optional: if the file is absent, nothing is waived.
177        #[arg(long, default_value = "testing-conventions.toml")]
178        config: PathBuf,
179    },
180}
181
182/// E2E attestation commands (#17): record a local e2e run and (later, #68)
183/// verify in CI that the latest code commit is attested.
184#[derive(Subcommand, Debug)]
185enum E2eCommand {
186    /// Run the e2e suite and write a committed attestation naming the current commit.
187    Attest {
188        /// The e2e command to run (e.g. `pnpm run e2e`), executed via the shell.
189        command: String,
190    },
191    /// Verify the committed attestation names the latest code commit (the CI gate).
192    Verify,
193}
194
195pub fn run<I, T>(args: I) -> anyhow::Result<i32>
196where
197    I: IntoIterator<Item = T>,
198    T: Into<std::ffi::OsString> + Clone,
199{
200    let cli = Cli::try_parse_from(args)?;
201    match cli.command {
202        // The config-driven `check` umbrella isn't wired yet; the scaffold
203        // proves the wiring while individual rules land under their test-kind
204        // group (e.g. `unit colocated-test`).
205        Some(Command::Check) | None => Ok(0),
206        Some(Command::Unit { rule }) => match rule {
207            UnitRule::ColocatedTest {
208                path,
209                language,
210                config,
211            } => run_unit_colocated_test(&path, language, &config),
212            UnitRule::Coverage {
213                path,
214                language,
215                config,
216            } => run_unit_coverage(&path, language, &config),
217            UnitRule::PatchCoverage {
218                path,
219                language,
220                base,
221                config,
222            } => run_unit_patch_coverage(&path, &base, language, &config),
223            UnitRule::Isolation {
224                path,
225                language,
226                config,
227            } => run_unit_isolation(&path, language, &config),
228            UnitRule::CoChange {
229                path,
230                language,
231                base,
232                config,
233            } => run_unit_co_change(&path, &base, language, &config),
234        },
235        Some(Command::Integration { rule }) => match rule {
236            IntegrationRule::Lint {
237                path,
238                language,
239                config,
240            } => run_integration_lint(&path, language, &config),
241        },
242        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
243        Some(Command::Workflow { path }) => run_workflow(&path),
244        Some(Command::E2e { command }) => match command {
245            E2eCommand::Attest { command } => run_e2e_attest(&command),
246            E2eCommand::Verify => run_e2e_verify(),
247        },
248    }
249}
250
251/// The binary's own clap command tree — the source of truth for which subcommands
252/// it exposes. The `workflow` guard (#92) checks a workflow's invocations against
253/// it, so a renamed or removed subcommand is caught the moment they diverge.
254pub fn command() -> clap::Command {
255    Cli::command()
256}
257
258/// Run the unit-test colocated-test check over `root` for `language`, reporting orphans.
259///
260/// Loads the `colocated-test`-rule exemptions from the config at `config_path` (no
261/// config file → no exemptions). Returns `0` when every source file has its
262/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
263fn run_unit_colocated_test(
264    root: &Path,
265    language: colocated_test::Language,
266    config_path: &Path,
267) -> anyhow::Result<i32> {
268    let exempt = colocated_test_exemptions(root, language, config_path)?;
269    let orphans = match language {
270        // Rust units are inline `#[cfg(test)]` modules, so "colocated" means a test
271        // module in the same file, not a sibling file (#40).
272        colocated_test::Language::Rust => colocated_test::missing_inline_tests(root, &exempt)?,
273        _ => colocated_test::missing_unit_tests(root, language, &exempt)?,
274    };
275    if orphans.is_empty() {
276        return Ok(0);
277    }
278    let (label, summary) = match language {
279        colocated_test::Language::Rust => (
280            "missing inline `#[cfg(test)]` tests",
281            "source file(s) with testable code but no inline `#[cfg(test)]` module \
282             (add an inline test module, or an `exempt` entry with a reason)",
283        ),
284        _ => (
285            "missing colocated unit test",
286            "source file(s) missing a colocated unit test \
287             (add a colocated test, or an `exempt` entry with a reason)",
288        ),
289    };
290    for orphan in &orphans {
291        eprintln!("{label}: {}", orphan.display());
292    }
293    eprintln!("error: {} {summary}", orphans.len());
294    Ok(1)
295}
296
297/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
298/// from the config at `config_path`. A missing config file means no exemptions —
299/// the check still runs, just with nothing exempted.
300fn colocated_test_exemptions(
301    root: &Path,
302    language: colocated_test::Language,
303    config_path: &Path,
304) -> anyhow::Result<std::collections::BTreeSet<String>> {
305    if !config_path.exists() {
306        return Ok(std::collections::BTreeSet::new());
307    }
308    let config = config::load_config(config_path)?;
309    config::resolve_exempt(
310        root,
311        config.exemptions(language),
312        config::Rule::ColocatedTest,
313    )
314}
315
316/// Run the commit-scoped `co-change` check (#33) over `root` for `language`,
317/// diffing `<base>...HEAD`. Returns `0` when every changed source file also
318/// changed its colocated test; otherwise prints each stale source to stderr and
319/// returns `1`.
320///
321/// Loads the `co-change`-rule exemptions from the config at `config_path` (no
322/// config file → no exemptions); an exempt source needn't co-change. Rejects
323/// `--language rust`: Rust units are inline `#[cfg(test)]` in the same file, so a
324/// sibling test can't go stale (mirrors how `unit coverage` rejects Rust).
325fn run_unit_co_change(
326    root: &Path,
327    base: &str,
328    language: colocated_test::Language,
329    config_path: &Path,
330) -> anyhow::Result<i32> {
331    if language == colocated_test::Language::Rust {
332        anyhow::bail!(
333            "`unit co-change` supports `--language python` / `typescript`; Rust units \
334             are inline `#[cfg(test)]` in the same file, so a sibling test can't go stale"
335        );
336    }
337    let exempt = co_change_exemptions(root, language, config_path)?;
338    let stale = co_change::stale_sources(root, base, language, &exempt)?;
339    if stale.is_empty() {
340        return Ok(0);
341    }
342    for source in &stale {
343        eprintln!(
344            "source changed without its colocated test: {}",
345            source.display()
346        );
347    }
348    eprintln!(
349        "error: {} source file(s) changed without their colocated test co-changing \
350         (update the test, or add an `exempt` entry with a reason)",
351        stale.len()
352    );
353    Ok(1)
354}
355
356/// The `co-change`-rule exempt paths for `language`, resolved (and validated)
357/// from the config at `config_path`. A missing config file means no exemptions —
358/// every changed source must co-change its test.
359fn co_change_exemptions(
360    root: &Path,
361    language: colocated_test::Language,
362    config_path: &Path,
363) -> anyhow::Result<std::collections::BTreeSet<String>> {
364    if !config_path.exists() {
365        return Ok(std::collections::BTreeSet::new());
366    }
367    let config = config::load_config(config_path)?;
368    config::resolve_exempt(root, config.exemptions(language), config::Rule::CoChange)
369}
370
371/// Combine the independent coverage outcomes for one run — the configured floor
372/// and the non-regression ratchet (#131). Passes only when every outcome passes;
373/// otherwise fails, joining each reason so a run breaching both the floor and the
374/// baseline reports both.
375fn combine_outcomes(outcomes: impl IntoIterator<Item = coverage::Outcome>) -> coverage::Outcome {
376    let reasons: Vec<String> = outcomes
377        .into_iter()
378        .filter_map(|outcome| match outcome {
379            coverage::Outcome::Pass => None,
380            coverage::Outcome::Fail(reason) => Some(reason),
381        })
382        .collect();
383    if reasons.is_empty() {
384        coverage::Outcome::Pass
385    } else {
386        coverage::Outcome::Fail(reasons.join("; "))
387    }
388}
389
390/// Run the unit-test coverage check over `root` for `language`, enforcing the
391/// floor (and, for Python, the non-regression ratchet) from the config at
392/// `config_path`. Returns `0` when the checks pass, `1` otherwise.
393///
394/// Coverage is zero-config by default (#80): a missing config file — or a config
395/// with no `[<language>].coverage` table — falls back to the language's sane
396/// default floor ([`config::PythonCoverage::default`] /
397/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
398/// and `integration lint` treat an absent config as "nothing exempt". A present
399/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
400fn run_unit_coverage(
401    root: &Path,
402    language: colocated_test::Language,
403    config_path: &Path,
404) -> anyhow::Result<i32> {
405    let config = if config_path.exists() {
406        config::load_config(config_path)?
407    } else {
408        config::Config::default()
409    };
410    let outcome = match language {
411        colocated_test::Language::Python => {
412            let python = config.python.unwrap_or_default();
413            let coverage = python.coverage.unwrap_or_default();
414            let thresholds = coverage::Thresholds {
415                fail_under: coverage.fail_under,
416                branch: coverage.branch,
417            };
418            let omit: Vec<String> =
419                config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
420                    .into_iter()
421                    .collect();
422            // Measure once, then enforce both the floor and the non-regression
423            // ratchet (#131): a committed baseline beside `root` records the last
424            // total, and a drop below it fails even when the floor is still met.
425            let report = coverage::measure_report(root, &omit)?;
426            let baseline = coverage::read_baseline(root)?
427                .and_then(|baseline| baseline.python)
428                .map(|python| python.percent_covered);
429            combine_outcomes([
430                coverage::evaluate(&report, thresholds),
431                coverage::evaluate_ratchet(report.totals.percent_covered, baseline),
432            ])
433        }
434        colocated_test::Language::TypeScript => {
435            let typescript = config.typescript.unwrap_or_default();
436            let coverage = typescript.coverage.unwrap_or_default();
437            let thresholds = coverage::TypeScriptThresholds {
438                lines: coverage.lines,
439                branches: coverage.branches,
440                functions: coverage.functions,
441                statements: coverage.statements,
442            };
443            let exclude: Vec<String> =
444                config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
445                    .into_iter()
446                    .collect();
447            coverage::measure_typescript(root, thresholds, &exclude)?
448        }
449        colocated_test::Language::Rust => anyhow::bail!(
450            "`unit coverage` supports `--language python` / `typescript`; \
451             Rust coverage (`cargo llvm-cov`) is a separate item"
452        ),
453    };
454    match outcome {
455        coverage::Outcome::Pass => Ok(0),
456        coverage::Outcome::Fail(reason) => {
457            eprintln!("error: coverage check failed — {reason}");
458            Ok(1)
459        }
460    }
461}
462
463/// Run the patch (changed-line) coverage check over `root` for `language`,
464/// diffing `<base>...HEAD` and requiring every changed line to be covered by the
465/// unit suite (#132). Returns `0` when every changed line is covered; otherwise
466/// prints each uncovered line to stderr and returns `1`.
467///
468/// Python only this slice — the TypeScript and Rust twins are later items under
469/// #46 (mirroring how `unit coverage` is staged). The `coverage`-rule exemptions
470/// from the config at `config_path` lift a file's changed lines (a missing config
471/// file → nothing exempt), reusing the floor's exemption surface (#32).
472fn run_unit_patch_coverage(
473    root: &Path,
474    base: &str,
475    language: colocated_test::Language,
476    config_path: &Path,
477) -> anyhow::Result<i32> {
478    match language {
479        colocated_test::Language::Python => {}
480        colocated_test::Language::TypeScript => anyhow::bail!(
481            "`unit patch-coverage` supports `--language python`; \
482             the TypeScript twin is a separate item"
483        ),
484        colocated_test::Language::Rust => anyhow::bail!(
485            "`unit patch-coverage` supports `--language python`; \
486             the Rust twin (`cargo llvm-cov`) is a separate item"
487        ),
488    }
489    let omit = patch_coverage_exemptions(root, config_path)?;
490    let uncovered = patch_coverage::check(root, base, &omit)?;
491    if uncovered.is_empty() {
492        return Ok(0);
493    }
494    for u in &uncovered {
495        eprintln!(
496            "changed line not covered by the unit suite: {}:{}",
497            u.file, u.line
498        );
499    }
500    eprintln!(
501        "error: {} changed line(s) not covered by the unit suite \
502         (add a unit test, or a `coverage` exempt entry with a reason)",
503        uncovered.len()
504    );
505    Ok(1)
506}
507
508/// The `coverage`-rule exempt paths resolved from the config at `config_path`
509/// (Python `[python].exempt`), as `root`-relative `--omit` patterns. A missing
510/// config file means nothing is exempt. Mirrors `run_unit_coverage`'s Python arm,
511/// so a file waived from the floor is waived from patch coverage too.
512fn patch_coverage_exemptions(root: &Path, config_path: &Path) -> anyhow::Result<Vec<String>> {
513    if !config_path.exists() {
514        return Ok(Vec::new());
515    }
516    let config = config::load_config(config_path)?;
517    let python = config.python.unwrap_or_default();
518    Ok(
519        config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
520            .into_iter()
521            .collect(),
522    )
523}
524
525/// Run the unit-isolation check over `root` for `language`, printing each
526/// violation to stderr as `path:line: rule — message` and returning `1` when any
527/// are found, `0` otherwise.
528fn run_unit_isolation(
529    root: &Path,
530    language: isolation::Language,
531    config_path: &Path,
532) -> anyhow::Result<i32> {
533    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
534        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
535        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
536            c.exemptions(colocated_test::Language::TypeScript)
537        }),
538        isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
539            c.exemptions(colocated_test::Language::Python)
540        }),
541    };
542    let violations = apply_waivers(raw, root, config_path, select)?;
543    if violations.is_empty() {
544        return Ok(0);
545    }
546    for v in &violations {
547        eprintln!(
548            "{}:{}: {} — {}",
549            v.file.display(),
550            v.line,
551            v.rule,
552            v.message
553        );
554    }
555    eprintln!("error: {} isolation violation(s)", violations.len());
556    Ok(1)
557}
558
559/// Run the integration-test lints over `root` for `language`, printing each
560/// violation to stderr as `path:line: rule — message` and returning `1` when any
561/// are found, `0` otherwise.
562fn run_integration_lint(
563    root: &Path,
564    language: IntegrationLintLanguage,
565    config_path: &Path,
566) -> anyhow::Result<i32> {
567    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
568        IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
569            c.exemptions(colocated_test::Language::Python)
570        }),
571        IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
572            c.exemptions(colocated_test::Language::TypeScript)
573        }),
574        IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
575            c.rust_exemptions()
576        }),
577    };
578    let violations = apply_waivers(raw, root, config_path, select)?;
579    if violations.is_empty() {
580        return Ok(0);
581    }
582    for v in &violations {
583        eprintln!(
584            "{}:{}: {} — {}",
585            v.file.display(),
586            v.line,
587            v.rule,
588            v.message
589        );
590    }
591    eprintln!("error: {} lint violation(s)", violations.len());
592    Ok(1)
593}
594
595/// Selects a language's `[[<lang>.exempt]]` table from a loaded config — the one
596/// varying piece between the `unit isolation` and `integration lint` waiver paths.
597type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
598
599/// Drop the violations waived by the config's `exempt` list (#32/#102). A
600/// violation is waived when its `rule` is a known [`config::Rule`] and its
601/// `root`-relative path is exempt for that rule. `exemptions` selects the
602/// language's `[[<lang>.exempt]]` table from the loaded config. A missing config
603/// file waives nothing; a reason-less or stale entry errors (via `load_config` /
604/// `resolve_exempt`), so the escape hatch can't silently rot.
605fn apply_waivers(
606    violations: Vec<lint::Violation>,
607    root: &Path,
608    config_path: &Path,
609    exemptions: ExemptSelect,
610) -> anyhow::Result<Vec<lint::Violation>> {
611    use std::collections::hash_map::Entry;
612
613    if !config_path.exists() {
614        return Ok(violations);
615    }
616    let config = config::load_config(config_path)?;
617    let exempt = exemptions(&config);
618    // Resolve each rule's exempt set once (and surface a stale entry as an error).
619    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
620        std::collections::HashMap::new();
621    let mut kept = Vec::new();
622    for violation in violations {
623        let waived = match config::Rule::from_id(violation.rule) {
624            Some(rule) => {
625                let exempt_paths = match resolved.entry(rule) {
626                    Entry::Occupied(entry) => entry.into_mut(),
627                    Entry::Vacant(entry) => {
628                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
629                    }
630                };
631                violation
632                    .file
633                    .strip_prefix(root)
634                    .ok()
635                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
636                    .is_some_and(|rel| exempt_paths.contains(&rel))
637            }
638            None => false,
639        };
640        if !waived {
641            kept.push(violation);
642        }
643    }
644    Ok(kept)
645}
646
647/// Run the packaging check: inspect the built artifact at `artifact` for test
648/// files that must not ship (README "Packaging"), per `language`'s test-file
649/// globs.
650///
651/// `artifact` is either an already-unpacked directory or a packed artifact the
652/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
653/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
654/// otherwise (after printing each offending path, relative to the artifact root).
655fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
656    let globs = match language {
657        colocated_test::Language::Python => vec!["*_test.py".to_string()],
658        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
659        // `#[cfg(test)]` units compile out for free; the only thing to keep out of
660        // the `.crate` source tarball is the crate-root integration `tests/` dir.
661        colocated_test::Language::Rust => vec!["tests/".to_string()],
662    };
663    let offenders = packaging::inspect(artifact, &globs)?;
664    if offenders.is_empty() {
665        return Ok(0);
666    }
667    for offender in &offenders {
668        eprintln!("test file in built artifact: {}", offender.display());
669    }
670    eprintln!(
671        "error: {} test file(s) present in the built artifact \
672         (they must be excluded from packaging)",
673        offenders.len()
674    );
675    Ok(1)
676}
677
678/// Run the workflow guard over `path` (a workflow file or directory): flag every
679/// `testing-conventions` invocation that names a subcommand this binary no longer
680/// exposes, printing each as `path:line: rule — message` and returning `1` when any
681/// are found, `0` otherwise.
682fn run_workflow(path: &Path) -> anyhow::Result<i32> {
683    let violations = workflow::check(path, &command())?;
684    if violations.is_empty() {
685        return Ok(0);
686    }
687    for v in &violations {
688        eprintln!(
689            "{}:{}: {} — {}",
690            v.file.display(),
691            v.line,
692            v.rule,
693            v.message
694        );
695    }
696    eprintln!(
697        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
698        violations.len()
699    );
700    Ok(1)
701}
702
703/// Run `command` as an e2e suite and write a committed attestation naming the
704/// current commit (#67). Force-runs: the attestation is written regardless of
705/// the command's exit code, so this exits `0` once the attestation is recorded.
706fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
707    let repo = std::env::current_dir()?;
708    let attestation = e2e::attest(&repo, command)?;
709    println!(
710        "e2e attestation recorded for commit {} (command exited {})",
711        attestation.commit, attestation.exit_code
712    );
713    Ok(0)
714}
715
716/// Verify the committed e2e attestation names the latest code commit (#68) — the
717/// CI side of the nudge. Exits `0` when fresh; otherwise prints the actionable
718/// hint and exits `1`. Never runs e2e, never judges the recorded run.
719fn run_e2e_verify() -> anyhow::Result<i32> {
720    let repo = std::env::current_dir()?;
721    match e2e::verify(&repo)? {
722        e2e::Verification::Fresh => Ok(0),
723        e2e::Verification::Missing => {
724            eprintln!(
725                "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
726            );
727            Ok(1)
728        }
729        e2e::Verification::Stale { attested, latest } => {
730            eprintln!(
731                "e2e attestation out of date: attested {}, latest code commit {} — \
732                 run `testing-conventions e2e attest '<your e2e command>'`",
733                &attested[..attested.len().min(7)],
734                &latest[..latest.len().min(7)]
735            );
736            Ok(1)
737        }
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    #[test]
746    fn no_args_returns_ok_zero() {
747        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
748    }
749
750    #[test]
751    fn check_returns_ok_zero() {
752        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
753    }
754
755    #[test]
756    fn unknown_flag_errors() {
757        assert!(run(["testing-conventions", "--bogus"]).is_err());
758    }
759
760    #[test]
761    fn help_flag_returns_clap_display_help() {
762        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
763        let clap_err = err
764            .downcast_ref::<clap::Error>()
765            .expect("error should be a clap::Error");
766        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
767    }
768
769    #[test]
770    fn version_flag_returns_clap_display_version() {
771        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
772        let clap_err = err
773            .downcast_ref::<clap::Error>()
774            .expect("error should be a clap::Error");
775        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
776    }
777
778    #[test]
779    fn unit_coverage_rejects_rust() {
780        // Zero-config: with no config file the default config is used, so this
781        // reaches the language arm (which bails for Rust) without any fixture.
782        let err = run([
783            "testing-conventions",
784            "unit",
785            "coverage",
786            "pkg",
787            "--language",
788            "rust",
789        ])
790        .unwrap_err();
791        assert!(err.to_string().contains("separate item"), "got: {err}");
792    }
793}