Skip to main content

testing_conventions/
isolation.rs

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