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 and TypeScript — the
102        /// Rust twin (`cargo llvm-cov`) is a separate item.
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/// Run the unit-test coverage check over `root` for `language`, enforcing the
372/// floor from the config at `config_path`. Returns `0` when the floor is met,
373/// `1` otherwise.
374///
375/// Coverage is zero-config by default (#80): a missing config file — or a config
376/// with no `[<language>].coverage` table — falls back to the language's sane
377/// default floor ([`config::PythonCoverage::default`] /
378/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
379/// and `integration lint` treat an absent config as "nothing exempt". A present
380/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
381fn run_unit_coverage(
382    root: &Path,
383    language: colocated_test::Language,
384    config_path: &Path,
385) -> anyhow::Result<i32> {
386    let config = if config_path.exists() {
387        config::load_config(config_path)?
388    } else {
389        config::Config::default()
390    };
391    let outcome = match language {
392        colocated_test::Language::Python => {
393            let python = config.python.unwrap_or_default();
394            let coverage = python.coverage.unwrap_or_default();
395            let thresholds = coverage::Thresholds {
396                fail_under: coverage.fail_under,
397                branch: coverage.branch,
398            };
399            let omit: Vec<String> =
400                config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
401                    .into_iter()
402                    .collect();
403            coverage::measure(root, thresholds, &omit)?
404        }
405        colocated_test::Language::TypeScript => {
406            let typescript = config.typescript.unwrap_or_default();
407            let coverage = typescript.coverage.unwrap_or_default();
408            let thresholds = coverage::TypeScriptThresholds {
409                lines: coverage.lines,
410                branches: coverage.branches,
411                functions: coverage.functions,
412                statements: coverage.statements,
413            };
414            let exclude: Vec<String> =
415                config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
416                    .into_iter()
417                    .collect();
418            coverage::measure_typescript(root, thresholds, &exclude)?
419        }
420        colocated_test::Language::Rust => anyhow::bail!(
421            "`unit coverage` supports `--language python` / `typescript`; \
422             Rust coverage (`cargo llvm-cov`) is a separate item"
423        ),
424    };
425    match outcome {
426        coverage::Outcome::Pass => Ok(0),
427        coverage::Outcome::Fail(reason) => {
428            eprintln!("error: coverage check failed — {reason}");
429            Ok(1)
430        }
431    }
432}
433
434/// Run the patch (changed-line) coverage check over `root` for `language`,
435/// diffing `<base>...HEAD` and requiring every changed line to be covered by the
436/// unit suite (Python #132, TypeScript #135). Returns `0` when every changed line
437/// is covered; otherwise prints each uncovered line to stderr and returns `1`.
438///
439/// Python runs coverage.py and TypeScript runs vitest; both reuse the same
440/// `<base>...HEAD` diff machinery. The Rust twin (`cargo llvm-cov`) is a later
441/// item under #46 (mirroring how `unit coverage` is staged). The `coverage`-rule
442/// exemptions from the config at `config_path` lift a file's changed lines (a
443/// missing config file → nothing exempt), reusing the floor's exemption surface
444/// (#32).
445fn run_unit_patch_coverage(
446    root: &Path,
447    base: &str,
448    language: colocated_test::Language,
449    config_path: &Path,
450) -> anyhow::Result<i32> {
451    let exempt = patch_coverage_exemptions(root, config_path, language)?;
452    let uncovered = match language {
453        colocated_test::Language::Python => patch_coverage::check(root, base, &exempt)?,
454        colocated_test::Language::TypeScript => {
455            patch_coverage::check_typescript(root, base, &exempt)?
456        }
457        colocated_test::Language::Rust => anyhow::bail!(
458            "`unit patch-coverage` supports `--language python` / `typescript`; \
459             the Rust twin (`cargo llvm-cov`) is a separate item"
460        ),
461    };
462    if uncovered.is_empty() {
463        return Ok(0);
464    }
465    for u in &uncovered {
466        eprintln!(
467            "changed line not covered by the unit suite: {}:{}",
468            u.file, u.line
469        );
470    }
471    eprintln!(
472        "error: {} changed line(s) not covered by the unit suite \
473         (add a unit test, or a `coverage` exempt entry with a reason)",
474        uncovered.len()
475    );
476    Ok(1)
477}
478
479/// The `coverage`-rule exempt paths for `language` resolved from the config at
480/// `config_path` (the `[<language>].exempt` table), as `root`-relative patterns. A
481/// missing config file means nothing is exempt. Mirrors `run_unit_coverage`, so a
482/// file waived from the floor is waived from patch coverage too.
483fn patch_coverage_exemptions(
484    root: &Path,
485    config_path: &Path,
486    language: colocated_test::Language,
487) -> anyhow::Result<Vec<String>> {
488    if !config_path.exists() {
489        return Ok(Vec::new());
490    }
491    let config = config::load_config(config_path)?;
492    Ok(
493        config::resolve_exempt(root, config.exemptions(language), config::Rule::Coverage)?
494            .into_iter()
495            .collect(),
496    )
497}
498
499/// Run the unit-isolation check over `root` for `language`, printing each
500/// violation to stderr as `path:line: rule — message` and returning `1` when any
501/// are found, `0` otherwise.
502fn run_unit_isolation(
503    root: &Path,
504    language: isolation::Language,
505    config_path: &Path,
506) -> anyhow::Result<i32> {
507    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
508        isolation::Language::Rust => (isolation::find_violations(root)?, |c| c.rust_exemptions()),
509        isolation::Language::TypeScript => (ts::find_unit_violations(root)?, |c| {
510            c.exemptions(colocated_test::Language::TypeScript)
511        }),
512        isolation::Language::Python => (lint::find_unit_isolation_violations(root)?, |c| {
513            c.exemptions(colocated_test::Language::Python)
514        }),
515    };
516    let violations = apply_waivers(raw, root, config_path, select)?;
517    if violations.is_empty() {
518        return Ok(0);
519    }
520    for v in &violations {
521        eprintln!(
522            "{}:{}: {} — {}",
523            v.file.display(),
524            v.line,
525            v.rule,
526            v.message
527        );
528    }
529    eprintln!("error: {} isolation violation(s)", violations.len());
530    Ok(1)
531}
532
533/// Run the integration-test lints over `root` for `language`, printing each
534/// violation to stderr as `path:line: rule — message` and returning `1` when any
535/// are found, `0` otherwise.
536fn run_integration_lint(
537    root: &Path,
538    language: IntegrationLintLanguage,
539    config_path: &Path,
540) -> anyhow::Result<i32> {
541    let (raw, select): (Vec<lint::Violation>, ExemptSelect) = match language {
542        IntegrationLintLanguage::Python => (lint::find_violations(root)?, |c| {
543            c.exemptions(colocated_test::Language::Python)
544        }),
545        IntegrationLintLanguage::TypeScript => (ts::find_integration_violations(root)?, |c| {
546            c.exemptions(colocated_test::Language::TypeScript)
547        }),
548        IntegrationLintLanguage::Rust => (isolation::find_integration_violations(root)?, |c| {
549            c.rust_exemptions()
550        }),
551    };
552    let violations = apply_waivers(raw, root, config_path, select)?;
553    if violations.is_empty() {
554        return Ok(0);
555    }
556    for v in &violations {
557        eprintln!(
558            "{}:{}: {} — {}",
559            v.file.display(),
560            v.line,
561            v.rule,
562            v.message
563        );
564    }
565    eprintln!("error: {} lint violation(s)", violations.len());
566    Ok(1)
567}
568
569/// Selects a language's `[[<lang>.exempt]]` table from a loaded config — the one
570/// varying piece between the `unit isolation` and `integration lint` waiver paths.
571type ExemptSelect = fn(&config::Config) -> &[config::Exemption];
572
573/// Drop the violations waived by the config's `exempt` list (#32/#102). A
574/// violation is waived when its `rule` is a known [`config::Rule`] and its
575/// `root`-relative path is exempt for that rule. `exemptions` selects the
576/// language's `[[<lang>.exempt]]` table from the loaded config. A missing config
577/// file waives nothing; a reason-less or stale entry errors (via `load_config` /
578/// `resolve_exempt`), so the escape hatch can't silently rot.
579fn apply_waivers(
580    violations: Vec<lint::Violation>,
581    root: &Path,
582    config_path: &Path,
583    exemptions: ExemptSelect,
584) -> anyhow::Result<Vec<lint::Violation>> {
585    use std::collections::hash_map::Entry;
586
587    if !config_path.exists() {
588        return Ok(violations);
589    }
590    let config = config::load_config(config_path)?;
591    let exempt = exemptions(&config);
592    // Resolve each rule's exempt set once (and surface a stale entry as an error).
593    let mut resolved: std::collections::HashMap<config::Rule, std::collections::BTreeSet<String>> =
594        std::collections::HashMap::new();
595    let mut kept = Vec::new();
596    for violation in violations {
597        let waived = match config::Rule::from_id(violation.rule) {
598            Some(rule) => {
599                let exempt_paths = match resolved.entry(rule) {
600                    Entry::Occupied(entry) => entry.into_mut(),
601                    Entry::Vacant(entry) => {
602                        entry.insert(config::resolve_exempt(root, exempt, rule)?)
603                    }
604                };
605                violation
606                    .file
607                    .strip_prefix(root)
608                    .ok()
609                    .map(|rel| rel.to_string_lossy().replace('\\', "/"))
610                    .is_some_and(|rel| exempt_paths.contains(&rel))
611            }
612            None => false,
613        };
614        if !waived {
615            kept.push(violation);
616        }
617    }
618    Ok(kept)
619}
620
621/// Run the packaging check: inspect the built artifact at `artifact` for test
622/// files that must not ship (README "Packaging"), per `language`'s test-file
623/// globs.
624///
625/// `artifact` is either an already-unpacked directory or a packed artifact the
626/// rule unpacks itself — a Python wheel (`.whl`) today; the TypeScript (#73) and
627/// Rust (#74) archives follow. Returns `0` when no test file is present, `1`
628/// otherwise (after printing each offending path, relative to the artifact root).
629fn run_packaging(artifact: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
630    let globs = match language {
631        colocated_test::Language::Python => vec!["*_test.py".to_string()],
632        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
633        // `#[cfg(test)]` units compile out for free; the only thing to keep out of
634        // the `.crate` source tarball is the crate-root integration `tests/` dir.
635        colocated_test::Language::Rust => vec!["tests/".to_string()],
636    };
637    let offenders = packaging::inspect(artifact, &globs)?;
638    if offenders.is_empty() {
639        return Ok(0);
640    }
641    for offender in &offenders {
642        eprintln!("test file in built artifact: {}", offender.display());
643    }
644    eprintln!(
645        "error: {} test file(s) present in the built artifact \
646         (they must be excluded from packaging)",
647        offenders.len()
648    );
649    Ok(1)
650}
651
652/// Run the workflow guard over `path` (a workflow file or directory): flag every
653/// `testing-conventions` invocation that names a subcommand this binary no longer
654/// exposes, printing each as `path:line: rule — message` and returning `1` when any
655/// are found, `0` otherwise.
656fn run_workflow(path: &Path) -> anyhow::Result<i32> {
657    let violations = workflow::check(path, &command())?;
658    if violations.is_empty() {
659        return Ok(0);
660    }
661    for v in &violations {
662        eprintln!(
663            "{}:{}: {} — {}",
664            v.file.display(),
665            v.line,
666            v.rule,
667            v.message
668        );
669    }
670    eprintln!(
671        "error: {} workflow invocation(s) name a subcommand this binary no longer exposes",
672        violations.len()
673    );
674    Ok(1)
675}
676
677/// Run `command` as an e2e suite and write a committed attestation naming the
678/// current commit (#67). Force-runs: the attestation is written regardless of
679/// the command's exit code, so this exits `0` once the attestation is recorded.
680fn run_e2e_attest(command: &str) -> anyhow::Result<i32> {
681    let repo = std::env::current_dir()?;
682    let attestation = e2e::attest(&repo, command)?;
683    println!(
684        "e2e attestation recorded for commit {} (command exited {})",
685        attestation.commit, attestation.exit_code
686    );
687    Ok(0)
688}
689
690/// Verify the committed e2e attestation names the latest code commit (#68) — the
691/// CI side of the nudge. Exits `0` when fresh; otherwise prints the actionable
692/// hint and exits `1`. Never runs e2e, never judges the recorded run.
693fn run_e2e_verify() -> anyhow::Result<i32> {
694    let repo = std::env::current_dir()?;
695    match e2e::verify(&repo)? {
696        e2e::Verification::Fresh => Ok(0),
697        e2e::Verification::Missing => {
698            eprintln!(
699                "e2e attestation missing — run `testing-conventions e2e attest '<your e2e command>'`"
700            );
701            Ok(1)
702        }
703        e2e::Verification::Stale { attested, latest } => {
704            eprintln!(
705                "e2e attestation out of date: attested {}, latest code commit {} — \
706                 run `testing-conventions e2e attest '<your e2e command>'`",
707                &attested[..attested.len().min(7)],
708                &latest[..latest.len().min(7)]
709            );
710            Ok(1)
711        }
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn no_args_returns_ok_zero() {
721        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
722    }
723
724    #[test]
725    fn check_returns_ok_zero() {
726        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
727    }
728
729    #[test]
730    fn unknown_flag_errors() {
731        assert!(run(["testing-conventions", "--bogus"]).is_err());
732    }
733
734    #[test]
735    fn help_flag_returns_clap_display_help() {
736        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
737        let clap_err = err
738            .downcast_ref::<clap::Error>()
739            .expect("error should be a clap::Error");
740        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
741    }
742
743    #[test]
744    fn version_flag_returns_clap_display_version() {
745        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
746        let clap_err = err
747            .downcast_ref::<clap::Error>()
748            .expect("error should be a clap::Error");
749        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
750    }
751
752    #[test]
753    fn unit_coverage_rejects_rust() {
754        // Zero-config: with no config file the default config is used, so this
755        // reaches the language arm (which bails for Rust) without any fixture.
756        let err = run([
757            "testing-conventions",
758            "unit",
759            "coverage",
760            "pkg",
761            "--language",
762            "rust",
763        ])
764        .unwrap_err();
765        assert!(err.to_string().contains("separate item"), "got: {err}");
766    }
767}