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    /// `*_test.py` / `test_*.py` colocated unit tests (`unmocked-collaborator`,
56    /// #42); 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 (the issue's bright line).
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`], #40).
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 contains a bare `test` ident (recursing
425/// into `all(...)` / `any(...)` groups). A `feature = "test"` string literal does
426/// not count.
427fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
428    tokens.into_iter().any(|tt| match tt {
429        proc_macro2::TokenTree::Ident(id) => id == "test",
430        proc_macro2::TokenTree::Group(group) => cfg_mentions_test(group.stream()),
431        _ => false,
432    })
433}
434
435/// The crate's normal `[dependencies]` names (hyphens normalized to underscores,
436/// the form used in paths) — the external crates whose calls are out-of-module.
437/// `[dev-dependencies]` are test tooling (`mockall`, `rstest`, …) and are
438/// deliberately excluded: a unit test uses its framework for real. Returns an
439/// empty set when there is no `Cargo.toml` at `root`.
440fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
441    let manifest = root.join("Cargo.toml");
442    if !manifest.is_file() {
443        return Ok(BTreeSet::new());
444    }
445    let text = std::fs::read_to_string(&manifest)
446        .with_context(|| format!("reading `{}`", manifest.display()))?;
447    let value: toml::Value =
448        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
449    let mut deps = BTreeSet::new();
450    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
451        for name in table.keys() {
452            deps.insert(name.replace('-', "_"));
453        }
454    }
455    Ok(deps)
456}
457
458/// Recursively collect every `*.rs` file under `dir` into `out`.
459fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
460    let entries =
461        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
462    for entry in entries {
463        let path = entry
464            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
465            .path();
466        if path.is_dir() {
467            collect_rust_files(&path, out)?;
468        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
469            out.push(path);
470        }
471    }
472    Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    /// Run the visitor over a source snippet with the given external-crate deps.
480    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
481        let ast = syn::parse_file(src).expect("snippet parses");
482        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
483        let mut visitor = IsolationVisitor {
484            file: Path::new("snippet.rs"),
485            deps: &dep_set,
486            test_depth: 0,
487            violations: Vec::new(),
488        };
489        visitor.visit_file(&ast);
490        visitor.violations
491    }
492
493    #[test]
494    fn flags_each_out_of_module_form() {
495        let src = "\
496#[cfg(test)]
497mod tests {
498    use super::*;
499    #[test]
500    fn t() {
501        let _ = crate::store::load();
502        let _ = std::fs::read(\"x\");
503        let _ = rand::random::<u8>();
504        let _ = super::super::util::help();
505    }
506}
507";
508        let violations = violations_in(src, &["rand"]);
509        assert_eq!(violations.len(), 4, "got {violations:?}");
510        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
511    }
512
513    #[test]
514    fn allows_in_module_calls() {
515        let src = "\
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use std::io::Cursor;
520    #[test]
521    fn t() {
522        let _ = super::widget();
523        let _ = self::helper();
524        let _ = Cursor::new(b\"x\");
525        let _ = std::collections::HashMap::<u8, u8>::new();
526        assert_eq!(1, 1);
527    }
528}
529";
530        assert!(violations_in(src, &["rand"]).is_empty());
531    }
532
533    #[test]
534    fn ignores_calls_outside_test_modules() {
535        let src = "fn run() { let _ = crate::other::go(); }";
536        assert!(violations_in(src, &[]).is_empty());
537    }
538
539    #[test]
540    fn reports_the_call_line() {
541        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
542        let src = "\
543#[cfg(test)]
544mod tests {
545    fn t() {
546        let _ = crate::other::go();
547    }
548}
549";
550        let violations = violations_in(src, &[]);
551        assert_eq!(violations.len(), 1);
552        assert_eq!(violations[0].line, 4);
553    }
554
555    #[test]
556    fn effectful_std_policy() {
557        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
558        // effectful — flagged
559        assert!(is_effectful_std(&segs("std::fs::read")));
560        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
561        assert!(is_effectful_std(&segs("std::env::var")));
562        assert!(is_effectful_std(&segs("std::process::exit")));
563        assert!(is_effectful_std(&segs("std::thread::sleep")));
564        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
565        assert!(is_effectful_std(&segs("std::io::stdout")));
566        // pure — allowed
567        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
568        assert!(!is_effectful_std(&segs("std::io::Cursor")));
569        assert!(!is_effectful_std(&segs("std::time::Duration")));
570        assert!(!is_effectful_std(&segs("std::cmp::min")));
571    }
572
573    #[test]
574    fn classify_leading_segment() {
575        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
576        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
577        assert_eq!(classify(&path("super::foo"), &deps), None);
578        assert_eq!(classify(&path("self::foo"), &deps), None);
579        assert_eq!(classify(&path("Local::new"), &deps), None);
580        assert_eq!(
581            classify(&path("super::super::foo"), &deps),
582            Some("ancestor module")
583        );
584        assert_eq!(
585            classify(&path("crate::a::b"), &deps),
586            Some("first-party module")
587        );
588        assert_eq!(
589            classify(&path("rand::random"), &deps),
590            Some("external crate")
591        );
592        assert_eq!(
593            classify(&path("std::fs::read"), &deps),
594            Some("effectful std")
595        );
596        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
597    }
598
599    #[test]
600    fn recognizes_cfg_test_attribute() {
601        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
602        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
603        assert!(has_cfg_test(
604            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
605        ));
606        assert!(!has_cfg_test(
607            &module("#[cfg(feature = \"test\")] mod t {}").attrs
608        ));
609        assert!(!has_cfg_test(&module("mod t {}").attrs));
610    }
611
612    #[test]
613    fn flags_each_foreign_import() {
614        let src = "\
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use super::Thing;
619    use crate::other::*;
620    use crate::other::Named;
621    use rand::Rng;
622    use std::fs;
623    use std::collections::HashMap;
624    use std::io::Cursor;
625}
626";
627        // Flagged: the crate glob, the crate named import, the external crate, and
628        // effectful `std::fs` — not `super::*` / `super::Thing` / pure std.
629        let violations = violations_in(src, &["rand"]);
630        assert_eq!(violations.len(), 4, "got {violations:?}");
631        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
632    }
633
634    #[test]
635    fn classify_use_roots() {
636        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
637        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
638        // in-module (None)
639        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
640        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
641        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
642        assert_eq!(
643            classify_use(&segs("std::collections::HashMap"), false, &deps),
644            None
645        );
646        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
647        // out-of-module
648        assert_eq!(
649            classify_use(&segs("super::super"), true, &deps),
650            Some("ancestor module")
651        );
652        assert_eq!(
653            classify_use(&segs("crate::other"), true, &deps),
654            Some("first-party module")
655        );
656        assert_eq!(
657            classify_use(&segs("crate::other::Named"), false, &deps),
658            Some("first-party module")
659        );
660        assert_eq!(
661            classify_use(&segs("rand::Rng"), false, &deps),
662            Some("external crate")
663        );
664        assert_eq!(
665            classify_use(&segs("std::fs"), false, &deps),
666            Some("effectful std")
667        );
668        // a non-`super` glob is foreign even for pure std
669        assert_eq!(
670            classify_use(&segs("std::collections"), true, &deps),
671            Some("glob import")
672        );
673    }
674
675    #[test]
676    fn imports_outside_test_modules_are_ignored() {
677        let src = "use crate::other::*; fn run() {}";
678        assert!(violations_in(src, &[]).is_empty());
679    }
680
681    /// Run the `#[double]` detector over an integration-test snippet.
682    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
683        let ast = syn::parse_file(src).expect("snippet parses");
684        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
685        let mut visitor = DoubleVisitor {
686            file: Path::new("integration.rs"),
687            first_party: &set,
688            violations: Vec::new(),
689        };
690        visitor.visit_file(&ast);
691        visitor.violations
692    }
693
694    #[test]
695    fn flags_double_of_first_party_only() {
696        let src = "\
697use mockall_double::double;
698#[double]
699use widget::Renderer;
700#[double]
701use rand::rngs::ThreadRng;
702#[double]
703use crate::support::Helper;
704";
705        // Only the first-party `widget` double is flagged; `rand` (external) and
706        // `crate::` (the test crate itself, not the library under test) are not.
707        let violations = integration_violations_in(src, &["widget"]);
708        assert_eq!(violations.len(), 1, "got {violations:?}");
709        assert_eq!(violations[0].rule, RULE_DOUBLE);
710    }
711
712    #[test]
713    fn ignores_use_without_double() {
714        let src = "use widget::Renderer; fn t() {}";
715        assert!(integration_violations_in(src, &["widget"]).is_empty());
716    }
717
718    #[test]
719    fn recognizes_double_attribute() {
720        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
721        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
722        assert!(has_double_attr(
723            &item("#[mockall_double::double] use a::B;").attrs
724        ));
725        assert!(!has_double_attr(
726            &item("#[allow(unused_imports)] use a::B;").attrs
727        ));
728        assert!(!has_double_attr(&item("use a::B;").attrs));
729    }
730}