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