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