Skip to main content

testing_conventions/
lib.rs

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