Skip to main content

testing_conventions/
lib.rs

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