Skip to main content

testing_conventions/
isolation.rs

1//! Rust unit-isolation lint for inline `#[cfg(test)]` modules. The AST walk is a
2//! deterministic `syn` heuristic; its precision limits live in `internals/rust/isolation.md`.
3
4use std::collections::BTreeSet;
5use std::path::{Path, PathBuf};
6
7use anyhow::{anyhow, Context, Result};
8use syn::spanned::Spanned;
9use syn::visit::{self, Visit};
10
11pub use crate::violation::Violation;
12
13const RULE_CALL: &str = "no-out-of-module-call";
14const RULE_IMPORT: &str = "no-out-of-module-import";
15const RULE_DOUBLE: &str = "no-first-party-double";
16
17/// The `unit lint` language selector.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
19pub enum Language {
20    /// Inline `#[cfg(test)]` modules in `*.rs` files (`no-out-of-module-call`).
21    #[value(name = "rust")]
22    Rust,
23    /// `*.test.{ts,tsx,mts,cts}` unit tests (`unmocked-collaborator`);
24    /// the detector lives in [`crate::ts`].
25    #[value(name = "typescript")]
26    TypeScript,
27    /// `*_test.py` / `test_*.py` colocated unit tests (`unmocked-collaborator`);
28    /// the detector lives in [`crate::lint`].
29    #[value(name = "python")]
30    Python,
31}
32
33/// Every isolation violation in the unit source under `scan_root`, sorted by `(file, line)`.
34/// `crate_root`'s `Cargo.toml` names the external crates, so a scan pointed at `src/` still
35/// sees the dependency set. `tests/`, `benches/`, `examples/`, and `target/` are not unit
36/// source, so a local build changes no result.
37pub fn find_violations(
38    scan_root: impl AsRef<Path>,
39    crate_root: impl AsRef<Path>,
40) -> Result<Vec<Violation>> {
41    let root = scan_root.as_ref();
42    let deps = external_deps(crate_root.as_ref())?;
43
44    let mut files = Vec::new();
45    crate::colocated_test::collect_rust_source_files(root, &mut files)?;
46    files.sort();
47
48    let mut violations = Vec::new();
49    for file in &files {
50        let source = std::fs::read_to_string(file)
51            .with_context(|| format!("reading source file `{}`", file.display()))?;
52        let ast = syn::parse_file(&source)
53            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
54        let mut visitor = IsolationVisitor {
55            file,
56            deps: &deps,
57            test_depth: 0,
58            violations: Vec::new(),
59        };
60        visitor.visit_file(&ast);
61        violations.append(&mut visitor.violations);
62    }
63
64    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
65    Ok(violations)
66}
67
68/// Every `no-first-party-double` violation in the `tests/` crates under crate root `root`.
69/// An integration test runs first-party code for real, so doubling it is the error;
70/// doubling an external crate is fine.
71pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
72    let root = root.as_ref();
73    let first_party = first_party_crates(root)?;
74
75    let mut files = Vec::new();
76    collect_rust_files(root, &mut files)?;
77    files.retain(|file| is_integration_test(root, file));
78    files.sort();
79
80    let mut violations = Vec::new();
81    for file in &files {
82        let source = std::fs::read_to_string(file)
83            .with_context(|| format!("reading source file `{}`", file.display()))?;
84        let ast = syn::parse_file(&source)
85            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
86        let mut visitor = DoubleVisitor {
87            file,
88            first_party: &first_party,
89            violations: Vec::new(),
90        };
91        visitor.visit_file(&ast);
92        violations.append(&mut visitor.violations);
93    }
94
95    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
96    Ok(violations)
97}
98
99/// Walks one integration-test file, flagging a `#[double]` of a first-party crate.
100struct DoubleVisitor<'a> {
101    file: &'a Path,
102    first_party: &'a BTreeSet<String>,
103    violations: Vec<Violation>,
104}
105
106impl<'ast> Visit<'ast> for DoubleVisitor<'_> {
107    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
108        if has_double_attr(&node.attrs) {
109            let mut imports = Vec::new();
110            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
111            if let Some((segs, is_glob)) = imports.iter().find(|(segs, _)| {
112                segs.first()
113                    .is_some_and(|root| self.first_party.contains(root))
114            }) {
115                self.violations.push(Violation {
116                    file: self.file.to_path_buf(),
117                    line: node.span().start().line,
118                    rule: RULE_DOUBLE,
119                    message: format!(
120                        "integration test doubles first-party `{}` with `#[double]`; \
121                         run first-party code for real — only external crates may be doubled",
122                        render_use(segs, *is_glob),
123                    ),
124                });
125            }
126        }
127        visit::visit_item_use(self, node);
128    }
129}
130
131/// `true` for a `#[double]` / `#[mockall_double::double]` attribute.
132fn has_double_attr(attrs: &[syn::Attribute]) -> bool {
133    attrs.iter().any(|attr| {
134        attr.path()
135            .segments
136            .last()
137            .is_some_and(|seg| seg.ident == "double")
138    })
139}
140
141/// The crate's own `[package].name` plus every `path` dependency, hyphens normalized to
142/// underscores. A `tests/` crate names the library under test by crate name rather than
143/// `crate::`, so the name is what a `#[double]` import is matched against.
144fn first_party_crates(root: &Path) -> Result<BTreeSet<String>> {
145    let manifest = root.join("Cargo.toml");
146    let mut set = BTreeSet::new();
147    if !manifest.is_file() {
148        return Ok(set);
149    }
150    let text = std::fs::read_to_string(&manifest)
151        .with_context(|| format!("reading `{}`", manifest.display()))?;
152    let value: toml::Value =
153        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
154
155    if let Some(name) = value
156        .get("package")
157        .and_then(|package| package.get("name"))
158        .and_then(toml::Value::as_str)
159    {
160        set.insert(name.replace('-', "_"));
161    }
162    for table_name in ["dependencies", "dev-dependencies"] {
163        if let Some(table) = value.get(table_name).and_then(toml::Value::as_table) {
164            for (name, spec) in table {
165                if spec.as_table().is_some_and(|t| t.contains_key("path")) {
166                    set.insert(name.replace('-', "_"));
167                }
168            }
169        }
170    }
171    Ok(set)
172}
173
174/// `true` when `file` (under `root`) is a Rust integration test — a `*.rs` file with a
175/// `tests` component. An inline `#[cfg(test)]` unit test doubles its collaborators by
176/// design; only a `tests/` crate runs first-party code for real.
177fn is_integration_test(root: &Path, file: &Path) -> bool {
178    file.strip_prefix(root)
179        .unwrap_or(file)
180        .components()
181        .any(|component| component.as_os_str() == "tests")
182}
183
184/// Walks one parsed file, flagging out-of-module calls inside `#[cfg(test)]` modules.
185struct IsolationVisitor<'a> {
186    file: &'a Path,
187    deps: &'a BTreeSet<String>,
188    test_depth: usize,
189    violations: Vec<Violation>,
190}
191
192impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
193    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
194        let is_test = has_cfg_test(&node.attrs);
195        if is_test {
196            self.test_depth += 1;
197        }
198        visit::visit_item_mod(self, node);
199        if is_test {
200            self.test_depth -= 1;
201        }
202    }
203
204    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
205        if self.test_depth > 0 {
206            if let syn::Expr::Path(path_expr) = node.func.as_ref() {
207                if let Some(kind) = classify(&path_expr.path, self.deps) {
208                    self.violations.push(Violation {
209                        file: self.file.to_path_buf(),
210                        line: node.span().start().line,
211                        rule: RULE_CALL,
212                        message: format!(
213                            "unit test calls `{}` out of its own module ({kind}); \
214                             inject a trait double for effectful collaborators",
215                            render_path(&path_expr.path),
216                        ),
217                    });
218                }
219            }
220        }
221        visit::visit_expr_call(self, node);
222    }
223
224    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
225        if self.test_depth > 0 {
226            let mut imports = Vec::new();
227            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
228            for (segs, is_glob) in &imports {
229                if let Some(kind) = classify_use(segs, *is_glob, self.deps) {
230                    self.violations.push(Violation {
231                        file: self.file.to_path_buf(),
232                        line: node.span().start().line,
233                        rule: RULE_IMPORT,
234                        message: format!(
235                            "unit test imports `{}` out of its own module ({kind}); \
236                             import the unit or a named pure value instead",
237                            render_use(segs, *is_glob),
238                        ),
239                    });
240                }
241            }
242        }
243        visit::visit_item_use(self, node);
244    }
245}
246
247/// Why a call's leading path is out-of-module, or `None` when it stays in-module or is
248/// unresolvable — an unresolvable path is not flagged, the `syn` heuristic's known limit.
249fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
250    let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
251    if is_pure_call_path(&segs) {
252        return None;
253    }
254    match segs.first().map(String::as_str)? {
255        "self" | "Self" => None,
256        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
257        "crate" => Some("first-party module"),
258        "std" => is_effectful_std(&segs).then_some("effectful std"),
259        // `core`/`alloc` carry no effectful APIs.
260        "core" | "alloc" => None,
261        // A local type or fn, including one imported by `super::*`, is in-module.
262        other => deps.contains(other).then_some("external crate"),
263    }
264}
265
266fn is_pure_call_path(segs: &[String]) -> bool {
267    const PATHS: &[&str] = &[
268        "clap::Command::new",
269        "clap::Arg::new",
270        "clap::Error::new",
271        "syn::parse_str",
272        "syn::parse_file",
273        "toml::from_str",
274        "zip::ZipWriter::new",
275        "zip::write::SimpleFileOptions::default",
276        "flate2::write::GzEncoder::new",
277        "flate2::Compression::default",
278        "tar::Builder::new",
279        "tar::Header::new_gnu",
280        "std::process::ExitStatus::from_raw",
281    ];
282    PATHS.contains(&segs.join("::").as_str())
283}
284
285fn is_pure_import_path(segs: &[String]) -> bool {
286    const PATHS: &[&str] = &[
287        "clap::error::ErrorKind",
288        "std::os::unix::process::ExitStatusExt",
289    ];
290    PATHS.contains(&segs.join("::").as_str())
291}
292
293/// `true` for an effectful `std` path — net, process, env, threads, OS, the clock, or
294/// real-handle I/O. Pure `std` stays in-module: `internals/rust/testing.md` makes
295/// `io::Cursor` the idiomatic in-memory unit-test tool.
296///
297/// `fs` is the deliberate carve-out. Rust privacy makes the inline `#[cfg(test)]` module the
298/// only tier that can reach a private item, so a private path-walker can be tested nowhere
299/// else — and its argument is a directory that has to exist. `env::temp_dir` rides along
300/// because it only names a writable directory; the rest of `env` reads ambient state the test
301/// never created, which is the collaborator this rule exists to catch.
302fn is_effectful_std(segs: &[String]) -> bool {
303    match segs.get(1).map(String::as_str) {
304        Some("net" | "process" | "thread" | "os") => true,
305        Some("env") => segs.get(2).map(String::as_str) != Some("temp_dir"),
306        Some("io") => matches!(
307            segs.get(2).map(String::as_str),
308            Some("stdin" | "stdout" | "stderr")
309        ),
310        Some("time") => {
311            matches!(
312                segs.get(2).map(String::as_str),
313                Some("SystemTime" | "Instant")
314            ) && segs.get(3).map(String::as_str) == Some("now")
315        }
316        _ => false,
317    }
318}
319
320/// Flatten a `use` tree into `(path, is_glob)` leaves: `use a::{b, c::*}` yields
321/// `([a, b], false)` and `([a, c], true)`. A rename is judged by its source path.
322fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
323    match tree {
324        syn::UseTree::Path(path) => {
325            prefix.push(path.ident.to_string());
326            flatten_use(&path.tree, prefix, out);
327            prefix.pop();
328        }
329        syn::UseTree::Name(name) => {
330            let mut full = prefix.clone();
331            full.push(name.ident.to_string());
332            out.push((full, false));
333        }
334        syn::UseTree::Rename(rename) => {
335            let mut full = prefix.clone();
336            full.push(rename.ident.to_string());
337            out.push((full, false));
338        }
339        syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
340        syn::UseTree::Group(group) => {
341            for item in &group.items {
342                flatten_use(item, prefix, out);
343            }
344        }
345    }
346}
347
348/// Why a `use` reaches out of the test's own module, or `None` when it stays in-module.
349/// The one legal glob is `super::*`; a named import is judged by its root like a call.
350fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
351    if !is_glob && is_pure_import_path(segs) {
352        return None;
353    }
354    match segs.first().map(String::as_str)? {
355        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
356        "self" | "Self" => None,
357        "crate" => Some("first-party module"),
358        "std" if is_effectful_std(segs) => Some("effectful std"),
359        // A glob of anything but `super` is foreign, even for pure `std`.
360        "std" | "core" | "alloc" => is_glob.then_some("glob import"),
361        other => {
362            if deps.contains(other) {
363                Some("external crate")
364            } else {
365                is_glob.then_some("glob import")
366            }
367        }
368    }
369}
370
371/// Render a flattened import for the message: `a::b`, or `a::b::*` for a glob.
372fn render_use(segs: &[String], is_glob: bool) -> String {
373    let mut out = segs.join("::");
374    if is_glob {
375        if !out.is_empty() {
376            out.push_str("::");
377        }
378        out.push('*');
379    }
380    out
381}
382
383/// Render a path back to `a::b::c` for the message; generic args are dropped.
384fn render_path(path: &syn::Path) -> String {
385    let mut out = String::new();
386    if path.leading_colon.is_some() {
387        out.push_str("::");
388    }
389    for (i, seg) in path.segments.iter().enumerate() {
390        if i > 0 {
391            out.push_str("::");
392        }
393        out.push_str(&seg.ident.to_string());
394    }
395    out
396}
397
398/// `true` when `attrs` carries a `#[cfg(test)]` gate, including `cfg(all(test, …))` and
399/// `cfg(any(test, …))` — the signal for an inline unit-test module.
400pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
401    attrs.iter().any(|attr| {
402        attr.path().is_ident("cfg")
403            && attr
404                .meta
405                .require_list()
406                .map(|list| cfg_mentions_test(list.tokens.clone()))
407                .unwrap_or(false)
408    })
409}
410
411/// `true` when a `cfg(...)` predicate positively requires `test`. `#[cfg(not(test))]` gates
412/// production code for non-test builds, and a `feature = "test"` string never counts.
413fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
414    cfg_requires_test(tokens, false)
415}
416
417/// `true` when a bare `test` ident is reached under an even number of enclosing `not(...)`
418/// groups. `negated` flips inside each `not(...)`, so `not(test)` does not qualify.
419fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
420    let mut iter = tokens.into_iter().peekable();
421    while let Some(tt) = iter.next() {
422        match tt {
423            proc_macro2::TokenTree::Ident(id) if id == "not" => {
424                // `not` applies to the group immediately following it.
425                if let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() {
426                    let stream = group.stream();
427                    iter.next();
428                    if cfg_requires_test(stream, !negated) {
429                        return true;
430                    }
431                }
432            }
433            proc_macro2::TokenTree::Ident(id) => {
434                if !negated && id == "test" {
435                    return true;
436                }
437            }
438            proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
439                return true;
440            }
441            _ => {}
442        }
443    }
444    false
445}
446
447/// The 1-based lines of the Rust items a `#[cfg(not(test))]` gate keeps out of a test build.
448///
449/// The unit tier runs `--lib --bins`, which sets `cfg(test)`, so no test reaches those lines.
450/// `mutation` drops their mutants — unkillable by construction — and `coverage` drops their
451/// regions, which the binary target's test harness instruments as 0-hit. Unparseable source
452/// yields no lines, so both checks keep judging what they already judged.
453pub(crate) fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
454    let Ok(ast) = syn::parse_file(source) else {
455        return BTreeSet::new();
456    };
457    let mut hidden = HiddenItems::default();
458    hidden.visit_file(&ast);
459    hidden.lines
460}
461
462/// Collects the line ranges of gated items. A gated `mod` or `impl` covers everything inside it,
463/// so recording the whole span is enough and the walk need not track nesting.
464#[derive(Default)]
465struct HiddenItems {
466    lines: BTreeSet<u32>,
467}
468
469impl HiddenItems {
470    fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
471        if !has_cfg_not_test(attrs) {
472            return;
473        }
474        let span = node.span();
475        self.lines
476            .extend(span.start().line as u32..=span.end().line as u32);
477    }
478}
479
480impl<'ast> Visit<'ast> for HiddenItems {
481    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
482        self.gated(&node.attrs, node);
483        visit::visit_item_fn(self, node);
484    }
485
486    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
487        self.gated(&node.attrs, node);
488        visit::visit_item_mod(self, node);
489    }
490
491    fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
492        self.gated(&node.attrs, node);
493        visit::visit_item_impl(self, node);
494    }
495
496    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
497        self.gated(&node.attrs, node);
498        visit::visit_impl_item_fn(self, node);
499    }
500}
501
502/// `true` when `attrs` carry a `cfg` gate that no test build can satisfy — `#[cfg(not(test))]`
503/// and `#[cfg(all(not(test), unix))]`, but not `#[cfg(any(not(test), unix))]`, which still
504/// compiles under `cargo test`.
505pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
506    attrs.iter().any(|attr| {
507        attr.path().is_ident("cfg")
508            && attr
509                .meta
510                .require_list()
511                .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
512                .unwrap_or(false)
513    })
514}
515
516/// A `cfg(...)` predicate's truth with `test` set and every other condition unknown. Only a
517/// definite [`CfgTruth::False`] proves the item is compiled out of a test build.
518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519enum CfgTruth {
520    False,
521    True,
522    Unknown,
523}
524
525/// Evaluate a whole `cfg(...)` predicate list. A `cfg` attribute holds exactly one predicate;
526/// anything else is malformed and not ours to judge.
527fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
528    match cfg_predicates(tokens).as_slice() {
529        [only] => *only,
530        _ => CfgTruth::Unknown,
531    }
532}
533
534/// Evaluate each comma-separated predicate in a `not(…)` / `all(…)` / `any(…)` group.
535fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
536    let mut out = Vec::new();
537    let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
538    for tt in tokens {
539        match &tt {
540            proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
541                if !current.is_empty() {
542                    out.push(cfg_predicate(&current));
543                    current.clear();
544                }
545            }
546            _ => current.push(tt),
547        }
548    }
549    if !current.is_empty() {
550        out.push(cfg_predicate(&current));
551    }
552    out
553}
554
555/// Evaluate one predicate with `test` set. A bare `test` is true, the three combinators recurse,
556/// and everything else — `unix`, `feature = "x"`, an unknown combinator — is
557/// [`CfgTruth::Unknown`].
558fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
559    use proc_macro2::TokenTree;
560    match tokens {
561        [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
562        [TokenTree::Ident(id), TokenTree::Group(group)] => {
563            let inner = cfg_predicates(group.stream());
564            match id.to_string().as_str() {
565                // `not` takes exactly one predicate; a malformed `not()` is undecidable, not true.
566                "not" => match inner.as_slice() {
567                    [only] => cfg_negate(*only),
568                    _ => CfgTruth::Unknown,
569                },
570                "all" => cfg_all(&inner),
571                "any" => cfg_any(&inner),
572                _ => CfgTruth::Unknown,
573            }
574        }
575        _ => CfgTruth::Unknown,
576    }
577}
578
579/// `all(…)`: false if any part is false, unknown if any part is unknown. An empty `all()` is true.
580fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
581    if parts.contains(&CfgTruth::False) {
582        CfgTruth::False
583    } else if parts.contains(&CfgTruth::Unknown) {
584        CfgTruth::Unknown
585    } else {
586        CfgTruth::True
587    }
588}
589
590/// `any(…)`: true if any part is true, unknown if any part is unknown. An empty `any()` is false.
591fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
592    if parts.contains(&CfgTruth::True) {
593        CfgTruth::True
594    } else if parts.contains(&CfgTruth::Unknown) {
595        CfgTruth::Unknown
596    } else {
597        CfgTruth::False
598    }
599}
600
601/// `not(…)`: an unknown stays unknown, so a gate we cannot decide never drops a mutant.
602fn cfg_negate(truth: CfgTruth) -> CfgTruth {
603    match truth {
604        CfgTruth::False => CfgTruth::True,
605        CfgTruth::True => CfgTruth::False,
606        CfgTruth::Unknown => CfgTruth::Unknown,
607    }
608}
609
610/// The crate's `[dependencies]` names, hyphens normalized to underscores — the external
611/// crates whose calls are out-of-module. `[dev-dependencies]` are excluded: a unit test
612/// uses its framework (`mockall`, `rstest`, …) for real.
613fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
614    let manifest = root.join("Cargo.toml");
615    if !manifest.is_file() {
616        return Ok(BTreeSet::new());
617    }
618    let text = std::fs::read_to_string(&manifest)
619        .with_context(|| format!("reading `{}`", manifest.display()))?;
620    let value: toml::Value =
621        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
622    let mut deps = BTreeSet::new();
623    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
624        for name in table.keys() {
625            deps.insert(name.replace('-', "_"));
626        }
627    }
628    Ok(deps)
629}
630
631fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
632    let entries =
633        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
634    for entry in entries {
635        let path = crate::walk::dir_entry(entry, dir)?.path();
636        if path.is_dir() {
637            collect_rust_files(&path, out)?;
638        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
639            out.push(path);
640        }
641    }
642    Ok(())
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use std::sync::atomic::{AtomicU64, Ordering};
649
650    /// Run the visitor over a source snippet with the given external-crate deps.
651    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
652        let ast = syn::parse_file(src).expect("snippet parses");
653        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
654        let mut visitor = IsolationVisitor {
655            file: Path::new("snippet.rs"),
656            deps: &dep_set,
657            test_depth: 0,
658            violations: Vec::new(),
659        };
660        visitor.visit_file(&ast);
661        visitor.violations
662    }
663
664    #[test]
665    fn flags_each_out_of_module_form() {
666        let src = "\
667#[cfg(test)]
668mod tests {
669    use super::*;
670    #[test]
671    fn t() {
672        let _ = crate::store::load();
673        let _ = std::net::TcpStream::connect(\"x\");
674        let _ = rand::random::<u8>();
675        let _ = super::super::util::help();
676    }
677}
678";
679        let violations = violations_in(src, &["rand"]);
680        assert_eq!(violations.len(), 4, "got {violations:?}");
681        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
682    }
683
684    #[test]
685    fn allows_in_module_calls() {
686        let src = "\
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::io::Cursor;
691    #[test]
692    fn t() {
693        let _ = super::widget();
694        let _ = self::helper();
695        let _ = Cursor::new(b\"x\");
696        let _ = std::collections::HashMap::<u8, u8>::new();
697        assert_eq!(1, 1);
698    }
699}
700";
701        assert!(violations_in(src, &["rand"]).is_empty());
702    }
703
704    #[test]
705    fn ignores_calls_outside_test_modules() {
706        let src = "fn run() { let _ = crate::other::go(); }";
707        assert!(violations_in(src, &[]).is_empty());
708    }
709
710    #[test]
711    fn reports_the_call_line() {
712        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
713        let src = "\
714#[cfg(test)]
715mod tests {
716    fn t() {
717        let _ = crate::other::go();
718    }
719}
720";
721        let violations = violations_in(src, &[]);
722        assert_eq!(violations.len(), 1);
723        assert_eq!(violations[0].line, 4);
724    }
725
726    #[test]
727    fn effectful_std_policy() {
728        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
729        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
730        assert!(is_effectful_std(&segs("std::env::var")));
731        assert!(is_effectful_std(&segs("std::env")));
732        assert!(is_effectful_std(&segs("std::process::exit")));
733        assert!(is_effectful_std(&segs("std::thread::sleep")));
734        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
735        assert!(is_effectful_std(&segs("std::io::stdout")));
736        assert!(!is_effectful_std(&segs("std::fs::read")));
737        assert!(!is_effectful_std(&segs("std::fs")));
738        assert!(!is_effectful_std(&segs("std::env::temp_dir")));
739        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
740        assert!(!is_effectful_std(&segs("std::io::Cursor")));
741        assert!(!is_effectful_std(&segs("std::time::Duration")));
742        assert!(!is_effectful_std(&segs("std::cmp::min")));
743    }
744
745    #[test]
746    fn classify_leading_segment() {
747        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
748        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
749        assert_eq!(classify(&path("super::foo"), &deps), None);
750        assert_eq!(classify(&path("self::foo"), &deps), None);
751        assert_eq!(classify(&path("Local::new"), &deps), None);
752        assert_eq!(
753            classify(&path("super::super::foo"), &deps),
754            Some("ancestor module")
755        );
756        assert_eq!(
757            classify(&path("crate::a::b"), &deps),
758            Some("first-party module")
759        );
760        assert_eq!(
761            classify(&path("rand::random"), &deps),
762            Some("external crate")
763        );
764        assert_eq!(
765            classify(&path("std::net::TcpStream::connect"), &deps),
766            Some("effectful std")
767        );
768        assert_eq!(classify(&path("std::fs::read"), &deps), None);
769        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
770    }
771
772    #[test]
773    fn recognizes_cfg_test_attribute() {
774        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
775        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
776        assert!(has_cfg_test(
777            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
778        ));
779        assert!(!has_cfg_test(
780            &module("#[cfg(feature = \"test\")] mod t {}").attrs
781        ));
782        assert!(!has_cfg_test(&module("mod t {}").attrs));
783        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
784        assert!(!has_cfg_test(
785            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
786        ));
787        assert!(!has_cfg_test(
788            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
789        ));
790        assert!(has_cfg_test(
791            &module("#[cfg(not(not(test)))] mod t {}").attrs
792        ));
793    }
794
795    #[test]
796    fn flags_each_foreign_import() {
797        let src = "\
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use super::Thing;
802    use crate::other::*;
803    use crate::other::Named;
804    use rand::Rng;
805    use std::net;
806    use std::fs;
807    use std::collections::HashMap;
808    use std::io::Cursor;
809}
810";
811        // Flagged: the crate glob, the crate named import, `rand`, and `std::net`. `std::fs`
812        // is not — a unit test may build the tree its unit walks.
813        let violations = violations_in(src, &["rand"]);
814        assert_eq!(violations.len(), 4, "got {violations:?}");
815        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
816    }
817
818    #[test]
819    fn classify_use_roots() {
820        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
821        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
822        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
823        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
824        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
825        assert_eq!(
826            classify_use(&segs("std::collections::HashMap"), false, &deps),
827            None
828        );
829        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
830        assert_eq!(
831            classify_use(&segs("super::super"), true, &deps),
832            Some("ancestor module")
833        );
834        assert_eq!(
835            classify_use(&segs("crate::other"), true, &deps),
836            Some("first-party module")
837        );
838        assert_eq!(
839            classify_use(&segs("crate::other::Named"), false, &deps),
840            Some("first-party module")
841        );
842        assert_eq!(
843            classify_use(&segs("rand::Rng"), false, &deps),
844            Some("external crate")
845        );
846        assert_eq!(
847            classify_use(&segs("std::net"), false, &deps),
848            Some("effectful std")
849        );
850        assert_eq!(classify_use(&segs("std::fs"), false, &deps), None);
851        assert_eq!(
852            classify_use(&segs("std::collections"), true, &deps),
853            Some("glob import")
854        );
855    }
856
857    #[test]
858    fn imports_outside_test_modules_are_ignored() {
859        let src = "use crate::other::*; fn run() {}";
860        assert!(violations_in(src, &[]).is_empty());
861    }
862
863    /// Run the `#[double]` detector over an integration-test snippet.
864    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
865        let ast = syn::parse_file(src).expect("snippet parses");
866        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
867        let mut visitor = DoubleVisitor {
868            file: Path::new("integration.rs"),
869            first_party: &set,
870            violations: Vec::new(),
871        };
872        visitor.visit_file(&ast);
873        visitor.violations
874    }
875
876    #[test]
877    fn flags_double_of_first_party_only() {
878        let src = "\
879use mockall_double::double;
880#[double]
881use widget::Renderer;
882#[double]
883use rand::rngs::ThreadRng;
884#[double]
885use crate::support::Helper;
886";
887        // Only `widget` is first-party: `rand` is external and `crate::` is the test crate.
888        let violations = integration_violations_in(src, &["widget"]);
889        assert_eq!(violations.len(), 1, "got {violations:?}");
890        assert_eq!(violations[0].rule, RULE_DOUBLE);
891    }
892
893    #[test]
894    fn ignores_use_without_double() {
895        let src = "use widget::Renderer; fn t() {}";
896        assert!(integration_violations_in(src, &["widget"]).is_empty());
897    }
898
899    #[test]
900    fn recognizes_double_attribute() {
901        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
902        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
903        assert!(has_double_attr(
904            &item("#[mockall_double::double] use a::B;").attrs
905        ));
906        assert!(!has_double_attr(
907            &item("#[allow(unused_imports)] use a::B;").attrs
908        ));
909        assert!(!has_double_attr(&item("use a::B;").attrs));
910    }
911
912    struct TempTree(PathBuf);
913
914    impl TempTree {
915        fn new(files: &[(&str, &str)]) -> Self {
916            static COUNTER: AtomicU64 = AtomicU64::new(0);
917            let root = std::env::temp_dir().join(format!(
918                "tc-isolation-{}-{}",
919                std::process::id(),
920                COUNTER.fetch_add(1, Ordering::Relaxed),
921            ));
922            for (rel, content) in files {
923                let path = root.join(rel);
924                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
925                std::fs::write(path, content).unwrap();
926            }
927            std::fs::create_dir_all(&root).unwrap();
928            TempTree(root)
929        }
930
931        fn path(&self) -> &Path {
932            &self.0
933        }
934    }
935
936    impl Drop for TempTree {
937        fn drop(&mut self) {
938            let _ = std::fs::remove_dir_all(&self.0);
939        }
940    }
941
942    #[test]
943    fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
944        let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
945        assert!(first_party_crates(tree.path()).unwrap().is_empty());
946        assert!(external_deps(tree.path()).unwrap().is_empty());
947    }
948
949    #[test]
950    fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
951        let tree = TempTree::new(&[(
952            "Cargo.toml",
953            "[package]\n\
954             name = \"my-crate\"\n\n\
955             [dependencies]\n\
956             sibling-lib = { path = \"../sibling-lib\" }\n\
957             rand = \"0.8\"\n\n\
958             [dev-dependencies]\n\
959             test-support = { path = \"../test-support\" }\n\
960             mockall = \"0.13\"\n",
961        )]);
962
963        let first_party = first_party_crates(tree.path()).unwrap();
964        assert_eq!(
965            first_party,
966            ["my_crate", "sibling_lib", "test_support"]
967                .iter()
968                .map(|s| (*s).to_string())
969                .collect::<BTreeSet<String>>(),
970            "the crate's own name and every path dep, hyphens normalized"
971        );
972
973        let external = external_deps(tree.path()).unwrap();
974        assert_eq!(
975            external,
976            ["rand", "sibling_lib"]
977                .iter()
978                .map(|s| (*s).to_string())
979                .collect::<BTreeSet<String>>(),
980            "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
981        );
982    }
983
984    #[test]
985    fn a_call_through_a_non_path_callee_is_left_alone() {
986        let src = "\
987#[cfg(test)]
988mod tests {
989    #[test]
990    fn t() {
991        let _ = (make())(1);
992    }
993}
994";
995        assert!(
996            violations_in(src, &["rand"]).is_empty(),
997            "a callee that is not a path carries no leading segment to classify"
998        );
999    }
1000
1001    #[test]
1002    fn a_renamed_import_is_judged_by_its_source_path() {
1003        let src = "\
1004#[cfg(test)]
1005mod tests {
1006    use crate::other::Thing as Local;
1007    use super::Widget as W;
1008}
1009";
1010        let violations = violations_in(src, &[]);
1011        assert_eq!(violations.len(), 1, "got {violations:?}");
1012        let m = &violations[0].message;
1013        assert!(
1014            m.contains("crate::other::Thing"),
1015            "the message names the source path, not the alias: {m}"
1016        );
1017    }
1018
1019    #[test]
1020    fn a_grouped_import_is_flattened_leaf_by_leaf() {
1021        let src = "\
1022#[cfg(test)]
1023mod tests {
1024    use crate::other::{Named, deeper::Other};
1025    use super::{Widget, helper};
1026}
1027";
1028        let violations = violations_in(src, &[]);
1029        assert_eq!(violations.len(), 2, "got {violations:?}");
1030        let (first, second) = (&violations[0].message, &violations[1].message);
1031        assert!(first.contains("crate::other::Named"), "{first}");
1032        assert!(second.contains("crate::other::deeper::Other"), "{second}");
1033    }
1034
1035    #[test]
1036    fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
1037        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
1038        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
1039        assert_eq!(
1040            classify_use(&segs("helpers"), true, &deps),
1041            Some("glob import"),
1042            "a glob is foreign even when `syn` cannot resolve its root"
1043        );
1044        assert_eq!(
1045            classify_use(&segs("helpers::Thing"), false, &deps),
1046            None,
1047            "a named import of an unresolvable root is the heuristic's documented limit"
1048        );
1049    }
1050
1051    #[test]
1052    fn a_leading_colon_survives_into_the_message() {
1053        let src = "\
1054#[cfg(test)]
1055mod tests {
1056    #[test]
1057    fn t() {
1058        let _ = ::std::net::TcpStream::connect(\"x\");
1059    }
1060}
1061";
1062        let violations = violations_in(src, &[]);
1063        assert_eq!(violations.len(), 1, "got {violations:?}");
1064        let m = &violations[0].message;
1065        assert!(m.contains("`::std::net::TcpStream::connect`"), "{m}");
1066    }
1067
1068    #[test]
1069    fn a_bare_cfg_not_is_not_a_test_module() {
1070        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
1071        assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
1072    }
1073
1074    #[test]
1075    fn an_unreadable_unit_source_names_the_file() {
1076        let tree = TempTree::new(&[("src/widget.rs", "")]);
1077        std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
1078        let err = find_violations(tree.path(), tree.path()).unwrap_err();
1079        assert!(
1080            format!("{err:#}").contains("reading source file"),
1081            "got: {err:#}"
1082        );
1083    }
1084
1085    #[test]
1086    fn an_unparsable_unit_source_names_the_file() {
1087        let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
1088        let err = find_violations(tree.path(), tree.path()).unwrap_err();
1089        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1090    }
1091
1092    #[test]
1093    fn an_unreadable_integration_source_names_the_file() {
1094        let tree = TempTree::new(&[("tests/int.rs", "")]);
1095        std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
1096        let err = find_integration_violations(tree.path()).unwrap_err();
1097        assert!(
1098            format!("{err:#}").contains("reading source file"),
1099            "got: {err:#}"
1100        );
1101    }
1102
1103    #[test]
1104    fn an_unparsable_integration_source_names_the_file() {
1105        let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1106        let err = find_integration_violations(tree.path()).unwrap_err();
1107        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1108    }
1109
1110    #[test]
1111    fn integration_violations_are_sorted_by_file_and_line() {
1112        let tree = TempTree::new(&[
1113            (
1114                "Cargo.toml",
1115                "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1116            ),
1117            (
1118                "tests/int.rs",
1119                "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1120            ),
1121        ]);
1122        let violations = find_integration_violations(tree.path()).unwrap();
1123        assert_eq!(violations.len(), 2, "got {violations:?}");
1124        assert!(violations[0].line < violations[1].line);
1125    }
1126
1127    #[test]
1128    fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1129        let tree = TempTree::new(&[("Cargo.toml", "")]);
1130        std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1131        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1132        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1133        assert!(first.contains("reading"), "got: {first}");
1134        assert!(external.contains("reading"), "got: {external}");
1135    }
1136
1137    #[test]
1138    fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1139        let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1140        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1141        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1142        assert!(first.contains("parsing"), "got: {first}");
1143        assert!(external.contains("parsing"), "got: {external}");
1144    }
1145
1146    #[test]
1147    fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1148        let tree = TempTree::new(&[(
1149            "Cargo.toml",
1150            "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1151        )]);
1152        let first = first_party_crates(tree.path()).unwrap();
1153        assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1154        assert!(external_deps(tree.path()).unwrap().is_empty());
1155    }
1156
1157    #[test]
1158    fn a_registry_only_dependency_table_feeds_external_deps() {
1159        let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1160        let external = external_deps(tree.path()).unwrap();
1161        assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1162    }
1163
1164    #[test]
1165    fn a_missing_root_is_an_error_for_integration_collection() {
1166        let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1167        assert!(
1168            format!("{err:#}").contains("reading directory"),
1169            "got: {err:#}"
1170        );
1171    }
1172
1173    #[test]
1174    fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1175        let source = "\
1176#[cfg(not(test))]
1177pub fn main() -> u8 {
1178    run()
1179}
1180
1181fn run() -> u8 {
1182    1
1183}
1184";
1185        assert_eq!(
1186            lines_hidden_from_tests(source),
1187            BTreeSet::from([1, 2, 3, 4])
1188        );
1189    }
1190
1191    #[test]
1192    fn a_gated_module_hides_everything_inside_it() {
1193        let source = "\
1194#[cfg(not(test))]
1195mod real {
1196    pub fn go() -> u8 {
1197        1
1198    }
1199}
1200";
1201        assert_eq!(
1202            lines_hidden_from_tests(source),
1203            BTreeSet::from([1, 2, 3, 4, 5, 6])
1204        );
1205    }
1206
1207    #[test]
1208    fn a_gated_method_hides_only_that_method() {
1209        let source = "\
1210impl Runner {
1211    #[cfg(not(test))]
1212    fn go(&self) -> u8 {
1213        1
1214    }
1215
1216    fn stay(&self) -> u8 {
1217        2
1218    }
1219}
1220";
1221        assert_eq!(
1222            lines_hidden_from_tests(source),
1223            BTreeSet::from([2, 3, 4, 5])
1224        );
1225    }
1226
1227    #[test]
1228    fn an_ungated_file_hides_nothing() {
1229        let source = "#[cfg(test)]\nmod tests {\n    fn t() {}\n}\n\nfn go() -> u8 {\n    1\n}\n";
1230
1231        assert!(lines_hidden_from_tests(source).is_empty());
1232    }
1233
1234    #[test]
1235    fn unparseable_source_hides_nothing() {
1236        assert!(lines_hidden_from_tests("fn go( {").is_empty());
1237    }
1238
1239    /// Whether `attr` on a plain function hides it from the test build.
1240    fn hides_under(attr: &str) -> bool {
1241        !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n    1\n}}\n")).is_empty()
1242    }
1243
1244    #[test]
1245    fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1246        assert!(hides_under("#[cfg(not(test))]"));
1247        assert!(hides_under("#[cfg(all(not(test), unix))]"));
1248        assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1249        assert!(hides_under("#[cfg(any())]"));
1250        assert!(hides_under("#[cfg(not(all()))]"));
1251    }
1252
1253    #[test]
1254    fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1255        assert!(!hides_under("#[cfg(test)]"));
1256        assert!(!hides_under("#[cfg(unix)]"));
1257        assert!(!hides_under("#[cfg(feature = \"x\")]"));
1258        assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1259        assert!(!hides_under("#[cfg(not(not(test)))]"));
1260        assert!(!hides_under("#[cfg(all())]"));
1261        assert!(!hides_under("#[inline]"));
1262    }
1263
1264    #[test]
1265    fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1266        assert!(!hides_under("#[cfg(not(unix))]"));
1267        assert!(!hides_under("#[cfg(all(test, unix))]"));
1268    }
1269
1270    #[test]
1271    fn a_malformed_gate_hides_nothing() {
1272        assert!(!hides_under("#[cfg(not())]"));
1273        assert!(!hides_under("#[cfg(nope(test))]"));
1274        assert!(!hides_under("#[cfg(not(test), unix)]"));
1275    }
1276}