Skip to main content

testing_conventions/
isolation.rs

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