Skip to main content

testing_conventions/
isolation.rs

1//! Rust unit-isolation lint: an inline `#[cfg(test)] mod` may call only into
2//! the unit under test — its parent module, reached via `super::`. A call *out of
3//! the test's own module* — into another first-party module (`crate::…`), an
4//! external crate, or effectful `std` — is a violation. Inject a trait double
5//! (hand-rolled or `mockall`) instead; the compiler checks the double.
6//!
7//! Detection is AST-based: each `*.rs` file under the crate root is parsed with
8//! `syn` and its `#[cfg(test)]` modules are walked with a [`Visit`]or. This is the
9//! deterministic `syn` heuristic; full name-resolution precision is a future
10//! `dylint` pass. The design and its precision limits live in
11//! `internals/rust/isolation.md`.
12//!
13//! Implemented detectors:
14//! - **`no-out-of-module-call`** (D1): a call expression `A::…::f(…)` inside a
15//!   `#[cfg(test)]` module whose leading segment `A` reaches out of the module —
16//!   `crate::` (first-party, another module), `super::super::…` (an ancestor),
17//!   an external crate from `Cargo.toml`, or effectful `std`. A single `super::`,
18//!   `self`/`Self`, a bare/unqualified call, and pure `std` (incl. `io::Cursor`)
19//!   stay in-module and are not flagged.
20//! - **`no-out-of-module-import`** (D2): a `use` inside a `#[cfg(test)]` module
21//!   that brings in a foreign surface — a glob of anything but `super::*`, or a
22//!   named import rooted at `crate::`, an external crate, or effectful `std`.
23//!   `use super::*` / `use super::Thing` (the unit under test), `self`, and pure
24//!   `std` (e.g. `collections`, `io::Cursor`) are in-module. Catches a collaborator
25//!   imported then called unqualified, which D1's call check can't see.
26
27use std::collections::BTreeSet;
28use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use syn::spanned::Spanned;
32use syn::visit::{self, Visit};
33
34pub use crate::violation::Violation;
35
36/// Rule id reported for an out-of-module call (D1).
37const RULE_CALL: &str = "no-out-of-module-call";
38/// Rule id reported for an out-of-module `use` import (D2).
39const RULE_IMPORT: &str = "no-out-of-module-import";
40/// Rule id reported for doubling a first-party item in an integration test.
41const RULE_DOUBLE: &str = "no-first-party-double";
42
43/// A language whose unit-isolation convention can be checked (Python is a
44/// separate detector). Each detector lives in its own module; this enum is the
45/// shared `unit lint` language selector.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
47pub enum Language {
48    /// Inline `#[cfg(test)]` modules in `*.rs` files (`no-out-of-module-call`).
49    #[value(name = "rust")]
50    Rust,
51    /// `*.test.{ts,tsx,mts,cts}` unit tests (`unmocked-collaborator`);
52    /// the detector lives in [`crate::ts`].
53    #[value(name = "typescript")]
54    TypeScript,
55    /// `*_test.py` / `test_*.py` colocated unit tests (`unmocked-collaborator`);
56    /// the detector lives in [`crate::lint`].
57    #[value(name = "python")]
58    Python,
59}
60
61/// Scan the Rust source files under `root` and return every isolation violation,
62/// sorted by `(file, line)` for deterministic output.
63///
64/// `root` is the crate root: its `Cargo.toml` names the external crates whose
65/// calls are out-of-module. The scan reuses the colocated-test unit-source walk, so
66/// it reads only the crate's own unit source (`src/`, `lib.rs`, `main.rs`) and skips
67/// the non-unit trees — `tests/` integration crates, `benches/`, `examples/`, and the
68/// `target/` build directory — exactly as the presence rule does. A locally-built
69/// crate is therefore scanned the same as a fresh checkout: a `target/` build artifact
70/// or a broken fixture under `tests/` neither aborts the rule nor is false-flagged.
71pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
72    let root = root.as_ref();
73    let deps = external_deps(root)?;
74
75    let mut files = Vec::new();
76    crate::colocated_test::collect_rust_source_files(root, &mut files)?;
77    files.sort();
78
79    let mut violations = Vec::new();
80    for file in &files {
81        let source = std::fs::read_to_string(file)
82            .with_context(|| format!("reading source file `{}`", file.display()))?;
83        let ast = syn::parse_file(&source)
84            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
85        let mut visitor = IsolationVisitor {
86            file,
87            deps: &deps,
88            test_depth: 0,
89            violations: Vec::new(),
90        };
91        visitor.visit_file(&ast);
92        violations.append(&mut visitor.violations);
93    }
94
95    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
96    Ok(violations)
97}
98
99/// Scan the Rust integration crates under `root` (the `*.rs` files in a `tests/`
100/// directory) and return every `no-first-party-double` violation — a `#[double]`
101/// import of a first-party item. An integration test runs first-party code for
102/// real, so doubling it is the error; doubling an external crate is fine. `root`
103/// is the crate root; its `Cargo.toml` names the first-party crates.
104pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
105    let root = root.as_ref();
106    let first_party = first_party_crates(root)?;
107
108    let mut files = Vec::new();
109    collect_rust_files(root, &mut files)?;
110    files.retain(|file| is_integration_test(root, file));
111    files.sort();
112
113    let mut violations = Vec::new();
114    for file in &files {
115        let source = std::fs::read_to_string(file)
116            .with_context(|| format!("reading source file `{}`", file.display()))?;
117        let ast = syn::parse_file(&source)
118            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
119        let mut visitor = DoubleVisitor {
120            file,
121            first_party: &first_party,
122            violations: Vec::new(),
123        };
124        visitor.visit_file(&ast);
125        violations.append(&mut visitor.violations);
126    }
127
128    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
129    Ok(violations)
130}
131
132/// Walks one parsed integration-test file, flagging a `#[double]` import whose
133/// path names a first-party crate.
134struct DoubleVisitor<'a> {
135    file: &'a Path,
136    first_party: &'a BTreeSet<String>,
137    violations: Vec<Violation>,
138}
139
140impl<'ast> Visit<'ast> for DoubleVisitor<'_> {
141    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
142        if has_double_attr(&node.attrs) {
143            let mut imports = Vec::new();
144            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
145            // One finding per `#[double] use`: flag if any leaf is first-party.
146            if let Some((segs, is_glob)) = imports.iter().find(|(segs, _)| {
147                segs.first()
148                    .is_some_and(|root| self.first_party.contains(root))
149            }) {
150                self.violations.push(Violation {
151                    file: self.file.to_path_buf(),
152                    line: node.span().start().line,
153                    rule: RULE_DOUBLE,
154                    message: format!(
155                        "integration test doubles first-party `{}` with `#[double]`; \
156                         run first-party code for real — only external crates may be doubled",
157                        render_use(segs, *is_glob),
158                    ),
159                });
160            }
161        }
162        visit::visit_item_use(self, node);
163    }
164}
165
166/// `true` when `attrs` carries a `#[double]` (or `#[mockall_double::double]`)
167/// attribute — `mockall_double` swapping a real item for its mock.
168fn has_double_attr(attrs: &[syn::Attribute]) -> bool {
169    attrs.iter().any(|attr| {
170        attr.path()
171            .segments
172            .last()
173            .is_some_and(|seg| seg.ident == "double")
174    })
175}
176
177/// The crate's first-party crates: its own `[package].name` plus every `path`
178/// dependency (your own crates, run for real), hyphens normalized to underscores.
179/// In a `tests/` integration crate the library under test is referenced by its
180/// crate name (not `crate::`, which is the test crate itself). Registry deps —
181/// including `mockall` / `mockall_double` — are external and absent here. Empty
182/// when there is no `Cargo.toml` at `root`.
183fn first_party_crates(root: &Path) -> Result<BTreeSet<String>> {
184    let manifest = root.join("Cargo.toml");
185    let mut set = BTreeSet::new();
186    if !manifest.is_file() {
187        return Ok(set);
188    }
189    let text = std::fs::read_to_string(&manifest)
190        .with_context(|| format!("reading `{}`", manifest.display()))?;
191    let value: toml::Value =
192        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
193
194    if let Some(name) = value
195        .get("package")
196        .and_then(|package| package.get("name"))
197        .and_then(toml::Value::as_str)
198    {
199        set.insert(name.replace('-', "_"));
200    }
201    for table_name in ["dependencies", "dev-dependencies"] {
202        if let Some(table) = value.get(table_name).and_then(toml::Value::as_table) {
203            for (name, spec) in table {
204                if spec.as_table().is_some_and(|t| t.contains_key("path")) {
205                    set.insert(name.replace('-', "_"));
206                }
207            }
208        }
209    }
210    Ok(set)
211}
212
213/// `true` when `file` (under `root`) is a Rust integration test — a `*.rs` file
214/// with a `tests` directory in its `root`-relative path. Unit tests are inline
215/// `#[cfg(test)]` in `src/`, where doubling a collaborator is correct isolation;
216/// only `tests/` crates run first-party for real and so are integration subjects.
217fn is_integration_test(root: &Path, file: &Path) -> bool {
218    file.strip_prefix(root)
219        .unwrap_or(file)
220        .components()
221        .any(|component| component.as_os_str() == "tests")
222}
223
224/// Walks one parsed file, flagging out-of-module calls inside `#[cfg(test)]`
225/// modules. `test_depth` counts how deep we are inside such modules, so a call in
226/// non-test code is ignored.
227struct IsolationVisitor<'a> {
228    file: &'a Path,
229    deps: &'a BTreeSet<String>,
230    test_depth: usize,
231    violations: Vec<Violation>,
232}
233
234impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
235    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
236        let is_test = has_cfg_test(&node.attrs);
237        if is_test {
238            self.test_depth += 1;
239        }
240        visit::visit_item_mod(self, node);
241        if is_test {
242            self.test_depth -= 1;
243        }
244    }
245
246    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
247        if self.test_depth > 0 {
248            if let syn::Expr::Path(path_expr) = node.func.as_ref() {
249                if let Some(kind) = classify(&path_expr.path, self.deps) {
250                    self.violations.push(Violation {
251                        file: self.file.to_path_buf(),
252                        line: node.span().start().line,
253                        rule: RULE_CALL,
254                        message: format!(
255                            "unit test calls `{}` out of its own module ({kind}); \
256                             inject a trait double — only `super::` is in-module",
257                            render_path(&path_expr.path),
258                        ),
259                    });
260                }
261            }
262        }
263        visit::visit_expr_call(self, node);
264    }
265
266    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
267        if self.test_depth > 0 {
268            let mut imports = Vec::new();
269            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
270            for (segs, is_glob) in &imports {
271                if let Some(kind) = classify_use(segs, *is_glob, self.deps) {
272                    self.violations.push(Violation {
273                        file: self.file.to_path_buf(),
274                        line: node.span().start().line,
275                        rule: RULE_IMPORT,
276                        message: format!(
277                            "unit test imports `{}` out of its own module ({kind}); \
278                             only `super::` (the unit) and pure `std` belong in a unit test",
279                            render_use(segs, *is_glob),
280                        ),
281                    });
282                }
283            }
284        }
285        visit::visit_item_use(self, node);
286    }
287}
288
289/// Why a call's leading path is out-of-module, or `None` when the call stays
290/// in-module (or is unresolvable, and so deliberately not flagged — the `syn`
291/// heuristic's documented limit).
292fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
293    let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
294    match segs.first().map(String::as_str)? {
295        // `self` / `Self` are local; a single `super::` is the unit under test.
296        "self" | "Self" => None,
297        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
298        "crate" => Some("first-party module"),
299        "std" => is_effectful_std(&segs).then_some("effectful std"),
300        // `core`/`alloc` carry no effectful APIs.
301        "core" | "alloc" => None,
302        // Any other leading segment is in-module unless it names an external
303        // crate; a local type/fn (incl. `super::*`-imported) is not flagged.
304        other => deps.contains(other).then_some("external crate"),
305    }
306}
307
308/// `true` for an effectful `std` path — filesystem, network, process, env,
309/// threads, OS, the clock (`SystemTime::now` / `Instant::now`), or real-handle
310/// I/O (`stdin`/`stdout`/`stderr`). Pure std is allowed: `std::io::Cursor` and the
311/// I/O traits, `time::Duration`, `collections`, `fmt`, … — `internals/rust/`
312/// `testing.md` makes `Cursor` the idiomatic in-memory unit-test tool.
313fn is_effectful_std(segs: &[String]) -> bool {
314    match segs.get(1).map(String::as_str) {
315        Some("fs" | "net" | "process" | "env" | "thread" | "os") => true,
316        Some("io") => matches!(
317            segs.get(2).map(String::as_str),
318            Some("stdin" | "stdout" | "stderr")
319        ),
320        Some("time") => {
321            matches!(
322                segs.get(2).map(String::as_str),
323                Some("SystemTime" | "Instant")
324            ) && segs.get(3).map(String::as_str) == Some("now")
325        }
326        _ => false,
327    }
328}
329
330/// Flatten a `use` tree into `(path, is_glob)` leaves: `use a::{b, c::*}` yields
331/// `([a, b], false)` and `([a, c], true)`. A rename (`use a::b as c`) is judged by
332/// its source path `[a, b]`.
333fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
334    match tree {
335        syn::UseTree::Path(path) => {
336            prefix.push(path.ident.to_string());
337            flatten_use(&path.tree, prefix, out);
338            prefix.pop();
339        }
340        syn::UseTree::Name(name) => {
341            let mut full = prefix.clone();
342            full.push(name.ident.to_string());
343            out.push((full, false));
344        }
345        syn::UseTree::Rename(rename) => {
346            let mut full = prefix.clone();
347            full.push(rename.ident.to_string());
348            out.push((full, false));
349        }
350        syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
351        syn::UseTree::Group(group) => {
352            for item in &group.items {
353                flatten_use(item, prefix, out);
354            }
355        }
356    }
357}
358
359/// Why a `use` import reaches out of the test's own module, or `None` when it
360/// stays in-module. The one legal glob is `super::*`; any other glob is foreign. A
361/// named import is judged by its root like a call — `crate::`, an external crate,
362/// or effectful `std` are out; `super`/`self`, pure `std`, and a local name are in.
363fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
364    match segs.first().map(String::as_str)? {
365        // `super::*` / `super::Thing` are the unit under test; `super::super::…`
366        // reaches past it.
367        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
368        "self" | "Self" => None,
369        "crate" => Some("first-party module"),
370        "std" if is_effectful_std(segs) => Some("effectful std"),
371        // Pure `std` / `core` / `alloc`: a named import is in-module, but a glob of
372        // anything but `super` is foreign.
373        "std" | "core" | "alloc" => is_glob.then_some("glob import"),
374        other => {
375            if deps.contains(other) {
376                Some("external crate")
377            } else {
378                // A local module/type: a named import is in-module; a non-`super`
379                // glob is still foreign.
380                is_glob.then_some("glob import")
381            }
382        }
383    }
384}
385
386/// Render a flattened import for the message: `a::b`, or `a::b::*` for a glob.
387fn render_use(segs: &[String], is_glob: bool) -> String {
388    let mut out = segs.join("::");
389    if is_glob {
390        if !out.is_empty() {
391            out.push_str("::");
392        }
393        out.push('*');
394    }
395    out
396}
397
398/// Render a path back to `a::b::c` for the message (idents only; generic args
399/// dropped).
400fn render_path(path: &syn::Path) -> String {
401    let mut out = String::new();
402    if path.leading_colon.is_some() {
403        out.push_str("::");
404    }
405    for (i, seg) in path.segments.iter().enumerate() {
406        if i > 0 {
407            out.push_str("::");
408        }
409        out.push_str(&seg.ident.to_string());
410    }
411    out
412}
413
414/// `true` when `attrs` carries a `#[cfg(test)]` gate (including `cfg(all(test, …))`
415/// / `cfg(any(test, …))`) — the signal for an inline unit-test module. Shared with
416/// the colocated-test presence rule ([`crate::colocated_test::missing_inline_tests`]).
417pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
418    attrs.iter().any(|attr| {
419        attr.path().is_ident("cfg")
420            && attr
421                .meta
422                .require_list()
423                .map(|list| cfg_mentions_test(list.tokens.clone()))
424                .unwrap_or(false)
425    })
426}
427
428/// `true` when a `cfg(...)` token stream *positively requires* the `test` ident —
429/// a bare `test` reached under an **even** number of `not(...)` negations (recursing
430/// into `all(...)` / `any(...)` groups). `#[cfg(not(test))]` gates production code
431/// for non-test builds, so it does not count; a `feature = "test"` string
432/// literal never counts.
433fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
434    cfg_requires_test(tokens, false)
435}
436
437/// Walk a `cfg(...)` predicate's tokens, returning `true` when a bare `test` ident
438/// is reached with `negated == false` — i.e. under an even number of enclosing
439/// `not(...)` groups. `negated` flips for the contents of each `not(...)`, so
440/// `not(test)` (and any oddly-negated `test`) is not a positively-required test cfg.
441fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
442    let mut iter = tokens.into_iter().peekable();
443    while let Some(tt) = iter.next() {
444        match tt {
445            proc_macro2::TokenTree::Ident(id) if id == "not" => {
446                // `not` applies to the immediately following group; recurse into it
447                // with the negation parity flipped.
448                if let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() {
449                    let stream = group.stream();
450                    iter.next();
451                    if cfg_requires_test(stream, !negated) {
452                        return true;
453                    }
454                }
455            }
456            proc_macro2::TokenTree::Ident(id) => {
457                if !negated && id == "test" {
458                    return true;
459                }
460            }
461            proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
462                return true;
463            }
464            _ => {}
465        }
466    }
467    false
468}
469
470/// The crate's normal `[dependencies]` names (hyphens normalized to underscores,
471/// the form used in paths) — the external crates whose calls are out-of-module.
472/// `[dev-dependencies]` are test tooling (`mockall`, `rstest`, …) and are
473/// deliberately excluded: a unit test uses its framework for real. Returns an
474/// empty set when there is no `Cargo.toml` at `root`.
475fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
476    let manifest = root.join("Cargo.toml");
477    if !manifest.is_file() {
478        return Ok(BTreeSet::new());
479    }
480    let text = std::fs::read_to_string(&manifest)
481        .with_context(|| format!("reading `{}`", manifest.display()))?;
482    let value: toml::Value =
483        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
484    let mut deps = BTreeSet::new();
485    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
486        for name in table.keys() {
487            deps.insert(name.replace('-', "_"));
488        }
489    }
490    Ok(deps)
491}
492
493/// Recursively collect every `*.rs` file under `dir` into `out`.
494fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
495    let entries =
496        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
497    for entry in entries {
498        let path = entry
499            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
500            .path();
501        if path.is_dir() {
502            collect_rust_files(&path, out)?;
503        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
504            out.push(path);
505        }
506    }
507    Ok(())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    /// Run the visitor over a source snippet with the given external-crate deps.
515    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
516        let ast = syn::parse_file(src).expect("snippet parses");
517        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
518        let mut visitor = IsolationVisitor {
519            file: Path::new("snippet.rs"),
520            deps: &dep_set,
521            test_depth: 0,
522            violations: Vec::new(),
523        };
524        visitor.visit_file(&ast);
525        visitor.violations
526    }
527
528    #[test]
529    fn flags_each_out_of_module_form() {
530        let src = "\
531#[cfg(test)]
532mod tests {
533    use super::*;
534    #[test]
535    fn t() {
536        let _ = crate::store::load();
537        let _ = std::fs::read(\"x\");
538        let _ = rand::random::<u8>();
539        let _ = super::super::util::help();
540    }
541}
542";
543        let violations = violations_in(src, &["rand"]);
544        assert_eq!(violations.len(), 4, "got {violations:?}");
545        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
546    }
547
548    #[test]
549    fn allows_in_module_calls() {
550        let src = "\
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use std::io::Cursor;
555    #[test]
556    fn t() {
557        let _ = super::widget();
558        let _ = self::helper();
559        let _ = Cursor::new(b\"x\");
560        let _ = std::collections::HashMap::<u8, u8>::new();
561        assert_eq!(1, 1);
562    }
563}
564";
565        assert!(violations_in(src, &["rand"]).is_empty());
566    }
567
568    #[test]
569    fn ignores_calls_outside_test_modules() {
570        let src = "fn run() { let _ = crate::other::go(); }";
571        assert!(violations_in(src, &[]).is_empty());
572    }
573
574    #[test]
575    fn reports_the_call_line() {
576        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
577        let src = "\
578#[cfg(test)]
579mod tests {
580    fn t() {
581        let _ = crate::other::go();
582    }
583}
584";
585        let violations = violations_in(src, &[]);
586        assert_eq!(violations.len(), 1);
587        assert_eq!(violations[0].line, 4);
588    }
589
590    #[test]
591    fn effectful_std_policy() {
592        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
593        // effectful — flagged
594        assert!(is_effectful_std(&segs("std::fs::read")));
595        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
596        assert!(is_effectful_std(&segs("std::env::var")));
597        assert!(is_effectful_std(&segs("std::process::exit")));
598        assert!(is_effectful_std(&segs("std::thread::sleep")));
599        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
600        assert!(is_effectful_std(&segs("std::io::stdout")));
601        // pure — allowed
602        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
603        assert!(!is_effectful_std(&segs("std::io::Cursor")));
604        assert!(!is_effectful_std(&segs("std::time::Duration")));
605        assert!(!is_effectful_std(&segs("std::cmp::min")));
606    }
607
608    #[test]
609    fn classify_leading_segment() {
610        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
611        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
612        assert_eq!(classify(&path("super::foo"), &deps), None);
613        assert_eq!(classify(&path("self::foo"), &deps), None);
614        assert_eq!(classify(&path("Local::new"), &deps), None);
615        assert_eq!(
616            classify(&path("super::super::foo"), &deps),
617            Some("ancestor module")
618        );
619        assert_eq!(
620            classify(&path("crate::a::b"), &deps),
621            Some("first-party module")
622        );
623        assert_eq!(
624            classify(&path("rand::random"), &deps),
625            Some("external crate")
626        );
627        assert_eq!(
628            classify(&path("std::fs::read"), &deps),
629            Some("effectful std")
630        );
631        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
632    }
633
634    #[test]
635    fn recognizes_cfg_test_attribute() {
636        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
637        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
638        assert!(has_cfg_test(
639            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
640        ));
641        assert!(!has_cfg_test(
642            &module("#[cfg(feature = \"test\")] mod t {}").attrs
643        ));
644        assert!(!has_cfg_test(&module("mod t {}").attrs));
645        // `not(test)` gates production code, not a test module.
646        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
647        assert!(!has_cfg_test(
648            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
649        ));
650        assert!(!has_cfg_test(
651            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
652        ));
653        // Even negation parity restores a positively-required `test`.
654        assert!(has_cfg_test(
655            &module("#[cfg(not(not(test)))] mod t {}").attrs
656        ));
657    }
658
659    #[test]
660    fn flags_each_foreign_import() {
661        let src = "\
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use super::Thing;
666    use crate::other::*;
667    use crate::other::Named;
668    use rand::Rng;
669    use std::fs;
670    use std::collections::HashMap;
671    use std::io::Cursor;
672}
673";
674        // Flagged: the crate glob, the crate named import, the external crate, and
675        // effectful `std::fs` — not `super::*` / `super::Thing` / pure std.
676        let violations = violations_in(src, &["rand"]);
677        assert_eq!(violations.len(), 4, "got {violations:?}");
678        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
679    }
680
681    #[test]
682    fn classify_use_roots() {
683        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
684        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
685        // in-module (None)
686        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
687        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
688        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
689        assert_eq!(
690            classify_use(&segs("std::collections::HashMap"), false, &deps),
691            None
692        );
693        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
694        // out-of-module
695        assert_eq!(
696            classify_use(&segs("super::super"), true, &deps),
697            Some("ancestor module")
698        );
699        assert_eq!(
700            classify_use(&segs("crate::other"), true, &deps),
701            Some("first-party module")
702        );
703        assert_eq!(
704            classify_use(&segs("crate::other::Named"), false, &deps),
705            Some("first-party module")
706        );
707        assert_eq!(
708            classify_use(&segs("rand::Rng"), false, &deps),
709            Some("external crate")
710        );
711        assert_eq!(
712            classify_use(&segs("std::fs"), false, &deps),
713            Some("effectful std")
714        );
715        // a non-`super` glob is foreign even for pure std
716        assert_eq!(
717            classify_use(&segs("std::collections"), true, &deps),
718            Some("glob import")
719        );
720    }
721
722    #[test]
723    fn imports_outside_test_modules_are_ignored() {
724        let src = "use crate::other::*; fn run() {}";
725        assert!(violations_in(src, &[]).is_empty());
726    }
727
728    /// Run the `#[double]` detector over an integration-test snippet.
729    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
730        let ast = syn::parse_file(src).expect("snippet parses");
731        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
732        let mut visitor = DoubleVisitor {
733            file: Path::new("integration.rs"),
734            first_party: &set,
735            violations: Vec::new(),
736        };
737        visitor.visit_file(&ast);
738        visitor.violations
739    }
740
741    #[test]
742    fn flags_double_of_first_party_only() {
743        let src = "\
744use mockall_double::double;
745#[double]
746use widget::Renderer;
747#[double]
748use rand::rngs::ThreadRng;
749#[double]
750use crate::support::Helper;
751";
752        // Only the first-party `widget` double is flagged; `rand` (external) and
753        // `crate::` (the test crate itself, not the library under test) are not.
754        let violations = integration_violations_in(src, &["widget"]);
755        assert_eq!(violations.len(), 1, "got {violations:?}");
756        assert_eq!(violations[0].rule, RULE_DOUBLE);
757    }
758
759    #[test]
760    fn ignores_use_without_double() {
761        let src = "use widget::Renderer; fn t() {}";
762        assert!(integration_violations_in(src, &["widget"]).is_empty());
763    }
764
765    #[test]
766    fn recognizes_double_attribute() {
767        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
768        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
769        assert!(has_double_attr(
770            &item("#[mockall_double::double] use a::B;").attrs
771        ));
772        assert!(!has_double_attr(
773            &item("#[allow(unused_imports)] use a::B;").attrs
774        ));
775        assert!(!has_double_attr(&item("use a::B;").attrs));
776    }
777}