Skip to main content

testing_conventions/
isolation.rs

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