Skip to main content

testing_conventions/
lib.rs

1pub mod colocated_test;
2pub mod config;
3pub mod coverage;
4pub mod lint;
5pub mod packaging;
6
7use std::path::{Path, PathBuf};
8
9use clap::{Parser, Subcommand};
10
11#[derive(Parser, Debug)]
12#[command(
13    name = "testing-conventions",
14    version,
15    about = "Enforce testing conventions in libraries (Python, TypeScript, and Rust).",
16    long_about = None,
17)]
18pub struct Cli {
19    #[command(subcommand)]
20    command: Option<Command>,
21}
22
23#[derive(Subcommand, Debug)]
24enum Command {
25    /// Check the repository against its testing-conventions config.
26    Check,
27    /// Unit-test conventions.
28    Unit {
29        #[command(subcommand)]
30        rule: UnitRule,
31    },
32    /// Integration-test conventions.
33    Integration {
34        #[command(subcommand)]
35        rule: IntegrationRule,
36    },
37    /// Packaging conventions: test files must not ship in the built artifact.
38    Packaging {
39        /// Root of the built artifact to inspect (e.g. an unpacked wheel or `dist/`).
40        path: PathBuf,
41        /// Language convention to enforce (required).
42        #[arg(long, value_enum)]
43        language: colocated_test::Language,
44    },
45}
46
47/// Rules enforced on the unit-test suite (the README's "Unit" taxonomy).
48#[derive(Subcommand, Debug)]
49enum UnitRule {
50    /// Check that every source file has a colocated, matching-named unit test.
51    ColocatedTest {
52        /// Directory to scan recursively.
53        path: PathBuf,
54        /// Language convention to enforce (required).
55        #[arg(long, value_enum)]
56        language: colocated_test::Language,
57        /// testing-conventions config file providing the `exempt` list. Optional:
58        /// if the file is absent, no files are exempt.
59        #[arg(long, default_value = "testing-conventions.toml")]
60        config: PathBuf,
61    },
62    /// Check that the unit suite meets the configured coverage floor.
63    Coverage {
64        /// Directory whose unit suite is run and measured.
65        path: PathBuf,
66        /// Language convention to enforce (required).
67        #[arg(long, value_enum)]
68        language: colocated_test::Language,
69        /// testing-conventions config file with the coverage thresholds and
70        /// `exempt` list. Optional: if the file — or its `[<language>].coverage`
71        /// table — is absent, the language's sane default floor is used and
72        /// nothing is exempt.
73        #[arg(long, default_value = "testing-conventions.toml")]
74        config: PathBuf,
75    },
76}
77
78/// Lints enforced on integration tests (mocking mechanism & style, and more to
79/// come). The README's "Integration" taxonomy.
80#[derive(Subcommand, Debug)]
81enum IntegrationRule {
82    /// Lint integration test files for mocking mechanism & style (Python).
83    Lint {
84        /// Directory to scan recursively for Python test files.
85        path: PathBuf,
86        /// Language convention to enforce (required).
87        #[arg(long, value_enum)]
88        language: colocated_test::Language,
89        /// testing-conventions config file providing the `exempt` list (waivers).
90        /// Optional: if the file is absent, nothing is waived.
91        #[arg(long, default_value = "testing-conventions.toml")]
92        config: PathBuf,
93    },
94}
95
96pub fn run<I, T>(args: I) -> anyhow::Result<i32>
97where
98    I: IntoIterator<Item = T>,
99    T: Into<std::ffi::OsString> + Clone,
100{
101    let cli = Cli::try_parse_from(args)?;
102    match cli.command {
103        // The config-driven `check` umbrella isn't wired yet; the scaffold
104        // proves the wiring while individual rules land under their test-kind
105        // group (e.g. `unit colocated-test`).
106        Some(Command::Check) | None => Ok(0),
107        Some(Command::Unit { rule }) => match rule {
108            UnitRule::ColocatedTest {
109                path,
110                language,
111                config,
112            } => run_unit_colocated_test(&path, language, &config),
113            UnitRule::Coverage {
114                path,
115                language,
116                config,
117            } => run_unit_coverage(&path, language, &config),
118        },
119        Some(Command::Integration { rule }) => match rule {
120            IntegrationRule::Lint {
121                path,
122                language,
123                config,
124            } => run_integration_lint(&path, language, &config),
125        },
126        Some(Command::Packaging { path, language }) => run_packaging(&path, language),
127    }
128}
129
130/// Run the unit-test colocated-test check over `root` for `language`, reporting orphans.
131///
132/// Loads the `colocated-test`-rule exemptions from the config at `config_path` (no
133/// config file → no exemptions). Returns `0` when every source file has its
134/// colocated unit test; otherwise prints each orphan to stderr and returns `1`.
135fn run_unit_colocated_test(
136    root: &Path,
137    language: colocated_test::Language,
138    config_path: &Path,
139) -> anyhow::Result<i32> {
140    let exempt = colocated_test_exemptions(root, language, config_path)?;
141    let orphans = colocated_test::missing_unit_tests(root, language, &exempt)?;
142    if orphans.is_empty() {
143        return Ok(0);
144    }
145    for orphan in &orphans {
146        eprintln!("missing colocated unit test: {}", orphan.display());
147    }
148    eprintln!(
149        "error: {} source file(s) missing a colocated unit test \
150         (add a colocated test, or an `exempt` entry with a reason)",
151        orphans.len()
152    );
153    Ok(1)
154}
155
156/// The `colocated-test`-rule exempt paths for `language`, resolved (and validated)
157/// from the config at `config_path`. A missing config file means no exemptions —
158/// the check still runs, just with nothing exempted.
159fn colocated_test_exemptions(
160    root: &Path,
161    language: colocated_test::Language,
162    config_path: &Path,
163) -> anyhow::Result<std::collections::BTreeSet<String>> {
164    if !config_path.exists() {
165        return Ok(std::collections::BTreeSet::new());
166    }
167    let config = config::load_config(config_path)?;
168    config::resolve_exempt(
169        root,
170        config.exemptions(language),
171        config::Rule::ColocatedTest,
172    )
173}
174
175/// Run the unit-test coverage check over `root` for `language`, enforcing the
176/// floor from the config at `config_path`. Returns `0` when the floor is met,
177/// `1` otherwise.
178///
179/// Coverage is zero-config by default (#80): a missing config file — or a config
180/// with no `[<language>].coverage` table — falls back to the language's sane
181/// default floor ([`config::PythonCoverage::default`] /
182/// [`config::TypeScriptCoverage::default`]), the same way `unit colocated-test`
183/// and `integration lint` treat an absent config as "nothing exempt". A present
184/// `coverage` table overrides the default; `coverage`-rule exemptions still apply.
185fn run_unit_coverage(
186    root: &Path,
187    language: colocated_test::Language,
188    config_path: &Path,
189) -> anyhow::Result<i32> {
190    let config = if config_path.exists() {
191        config::load_config(config_path)?
192    } else {
193        config::Config::default()
194    };
195    let outcome = match language {
196        colocated_test::Language::Python => {
197            let python = config.python.unwrap_or_default();
198            let coverage = python.coverage.unwrap_or_default();
199            let thresholds = coverage::Thresholds {
200                fail_under: coverage.fail_under,
201                branch: coverage.branch,
202            };
203            let omit: Vec<String> =
204                config::resolve_exempt(root, &python.exempt, config::Rule::Coverage)?
205                    .into_iter()
206                    .collect();
207            coverage::measure(root, thresholds, &omit)?
208        }
209        colocated_test::Language::TypeScript => {
210            let typescript = config.typescript.unwrap_or_default();
211            let coverage = typescript.coverage.unwrap_or_default();
212            let thresholds = coverage::TypeScriptThresholds {
213                lines: coverage.lines,
214                branches: coverage.branches,
215                functions: coverage.functions,
216                statements: coverage.statements,
217            };
218            let exclude: Vec<String> =
219                config::resolve_exempt(root, &typescript.exempt, config::Rule::Coverage)?
220                    .into_iter()
221                    .collect();
222            coverage::measure_typescript(root, thresholds, &exclude)?
223        }
224    };
225    match outcome {
226        coverage::Outcome::Pass => Ok(0),
227        coverage::Outcome::Fail(reason) => {
228            eprintln!("error: coverage check failed — {reason}");
229            Ok(1)
230        }
231    }
232}
233
234/// Run the integration-test lints over `root` for `language`, printing each
235/// violation to stderr as `path:line: rule — message` and returning `1` when any
236/// are found, `0` otherwise.
237fn run_integration_lint(
238    root: &Path,
239    language: colocated_test::Language,
240    config_path: &Path,
241) -> anyhow::Result<i32> {
242    match language {
243        colocated_test::Language::Python => {}
244        colocated_test::Language::TypeScript => {
245            anyhow::bail!("`integration lint` supports `--language python` only for now")
246        }
247    }
248    let waived = lint_waivers(root, language, config_path)?;
249    let violations: Vec<lint::Violation> = lint::find_violations(root)?
250        .into_iter()
251        .filter(|v| !is_waived(v, root, &waived))
252        .collect();
253    if violations.is_empty() {
254        return Ok(0);
255    }
256    for v in &violations {
257        eprintln!(
258            "{}:{}: {} — {}",
259            v.file.display(),
260            v.line,
261            v.rule,
262            v.message
263        );
264    }
265    eprintln!("error: {} lint violation(s)", violations.len());
266    Ok(1)
267}
268
269/// The `no-constant-patch` waivers (root-relative paths) from the config at
270/// `config_path` — the only waivable lint (#52). A missing config file means
271/// nothing is waived.
272fn lint_waivers(
273    root: &Path,
274    language: colocated_test::Language,
275    config_path: &Path,
276) -> anyhow::Result<std::collections::BTreeSet<String>> {
277    if !config_path.exists() {
278        return Ok(std::collections::BTreeSet::new());
279    }
280    let config = config::load_config(config_path)?;
281    config::resolve_exempt(
282        root,
283        config.exemptions(language),
284        config::Rule::NoConstantPatch,
285    )
286}
287
288/// `true` when `violation` is a `no-constant-patch` finding in a waived file.
289fn is_waived(
290    violation: &lint::Violation,
291    root: &Path,
292    waived: &std::collections::BTreeSet<String>,
293) -> bool {
294    violation.rule == "no-constant-patch"
295        && violation
296            .file
297            .strip_prefix(root)
298            .ok()
299            .map(|rel| rel.to_string_lossy().replace('\\', "/"))
300            .is_some_and(|rel| waived.contains(&rel))
301}
302
303/// Run the packaging check: scan the built artifact at `root` for test files
304/// that must not ship (README "Packaging"), per `language`'s test-file globs.
305///
306/// `root` is the already-unpacked artifact (e.g. an unpacked wheel, or a `dist/`
307/// tree); the per-language slices (#72/#73/#74) prepend the build step that
308/// produces it. Returns `0` when no test file is present, `1` otherwise (after
309/// printing each offending path).
310fn run_packaging(root: &Path, language: colocated_test::Language) -> anyhow::Result<i32> {
311    let globs = match language {
312        colocated_test::Language::Python => vec!["*_test.py".to_string()],
313        colocated_test::Language::TypeScript => vec!["*.test.*".to_string()],
314    };
315    let offenders = packaging::scan(root, &globs)?;
316    if offenders.is_empty() {
317        return Ok(0);
318    }
319    for offender in &offenders {
320        eprintln!("test file in built artifact: {}", offender.display());
321    }
322    eprintln!(
323        "error: {} test file(s) present in the built artifact \
324         (they must be excluded from packaging)",
325        offenders.len()
326    );
327    Ok(1)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn no_args_returns_ok_zero() {
336        assert_eq!(run(["testing-conventions"]).unwrap(), 0);
337    }
338
339    #[test]
340    fn check_returns_ok_zero() {
341        assert_eq!(run(["testing-conventions", "check"]).unwrap(), 0);
342    }
343
344    #[test]
345    fn unknown_flag_errors() {
346        assert!(run(["testing-conventions", "--bogus"]).is_err());
347    }
348
349    #[test]
350    fn help_flag_returns_clap_display_help() {
351        let err = run(["testing-conventions", "--help"]).expect_err("--help should bubble");
352        let clap_err = err
353            .downcast_ref::<clap::Error>()
354            .expect("error should be a clap::Error");
355        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayHelp);
356    }
357
358    #[test]
359    fn version_flag_returns_clap_display_version() {
360        let err = run(["testing-conventions", "--version"]).expect_err("--version should bubble");
361        let clap_err = err
362            .downcast_ref::<clap::Error>()
363            .expect("error should be a clap::Error");
364        assert_eq!(clap_err.kind(), clap::error::ErrorKind::DisplayVersion);
365    }
366}