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 = crate::walk::dir_entry(entry, dir)?.path();
430        if path.is_dir() {
431            collect_rust_files(&path, out)?;
432        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
433            out.push(path);
434        }
435    }
436    Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::sync::atomic::{AtomicU64, Ordering};
443
444    /// Run the visitor over a source snippet with the given external-crate deps.
445    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
446        let ast = syn::parse_file(src).expect("snippet parses");
447        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
448        let mut visitor = IsolationVisitor {
449            file: Path::new("snippet.rs"),
450            deps: &dep_set,
451            test_depth: 0,
452            violations: Vec::new(),
453        };
454        visitor.visit_file(&ast);
455        visitor.violations
456    }
457
458    #[test]
459    fn flags_each_out_of_module_form() {
460        let src = "\
461#[cfg(test)]
462mod tests {
463    use super::*;
464    #[test]
465    fn t() {
466        let _ = crate::store::load();
467        let _ = std::fs::read(\"x\");
468        let _ = rand::random::<u8>();
469        let _ = super::super::util::help();
470    }
471}
472";
473        let violations = violations_in(src, &["rand"]);
474        assert_eq!(violations.len(), 4, "got {violations:?}");
475        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
476    }
477
478    #[test]
479    fn allows_in_module_calls() {
480        let src = "\
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use std::io::Cursor;
485    #[test]
486    fn t() {
487        let _ = super::widget();
488        let _ = self::helper();
489        let _ = Cursor::new(b\"x\");
490        let _ = std::collections::HashMap::<u8, u8>::new();
491        assert_eq!(1, 1);
492    }
493}
494";
495        assert!(violations_in(src, &["rand"]).is_empty());
496    }
497
498    #[test]
499    fn ignores_calls_outside_test_modules() {
500        let src = "fn run() { let _ = crate::other::go(); }";
501        assert!(violations_in(src, &[]).is_empty());
502    }
503
504    #[test]
505    fn reports_the_call_line() {
506        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
507        let src = "\
508#[cfg(test)]
509mod tests {
510    fn t() {
511        let _ = crate::other::go();
512    }
513}
514";
515        let violations = violations_in(src, &[]);
516        assert_eq!(violations.len(), 1);
517        assert_eq!(violations[0].line, 4);
518    }
519
520    #[test]
521    fn effectful_std_policy() {
522        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
523        assert!(is_effectful_std(&segs("std::fs::read")));
524        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
525        assert!(is_effectful_std(&segs("std::env::var")));
526        assert!(is_effectful_std(&segs("std::process::exit")));
527        assert!(is_effectful_std(&segs("std::thread::sleep")));
528        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
529        assert!(is_effectful_std(&segs("std::io::stdout")));
530        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
531        assert!(!is_effectful_std(&segs("std::io::Cursor")));
532        assert!(!is_effectful_std(&segs("std::time::Duration")));
533        assert!(!is_effectful_std(&segs("std::cmp::min")));
534    }
535
536    #[test]
537    fn classify_leading_segment() {
538        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
539        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
540        assert_eq!(classify(&path("super::foo"), &deps), None);
541        assert_eq!(classify(&path("self::foo"), &deps), None);
542        assert_eq!(classify(&path("Local::new"), &deps), None);
543        assert_eq!(
544            classify(&path("super::super::foo"), &deps),
545            Some("ancestor module")
546        );
547        assert_eq!(
548            classify(&path("crate::a::b"), &deps),
549            Some("first-party module")
550        );
551        assert_eq!(
552            classify(&path("rand::random"), &deps),
553            Some("external crate")
554        );
555        assert_eq!(
556            classify(&path("std::fs::read"), &deps),
557            Some("effectful std")
558        );
559        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
560    }
561
562    #[test]
563    fn recognizes_cfg_test_attribute() {
564        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
565        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
566        assert!(has_cfg_test(
567            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
568        ));
569        assert!(!has_cfg_test(
570            &module("#[cfg(feature = \"test\")] mod t {}").attrs
571        ));
572        assert!(!has_cfg_test(&module("mod t {}").attrs));
573        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
574        assert!(!has_cfg_test(
575            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
576        ));
577        assert!(!has_cfg_test(
578            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
579        ));
580        assert!(has_cfg_test(
581            &module("#[cfg(not(not(test)))] mod t {}").attrs
582        ));
583    }
584
585    #[test]
586    fn flags_each_foreign_import() {
587        let src = "\
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use super::Thing;
592    use crate::other::*;
593    use crate::other::Named;
594    use rand::Rng;
595    use std::fs;
596    use std::collections::HashMap;
597    use std::io::Cursor;
598}
599";
600        // Flagged: the crate glob, the crate named import, `rand`, and `std::fs`.
601        let violations = violations_in(src, &["rand"]);
602        assert_eq!(violations.len(), 4, "got {violations:?}");
603        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
604    }
605
606    #[test]
607    fn classify_use_roots() {
608        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
609        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
610        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
611        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
612        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
613        assert_eq!(
614            classify_use(&segs("std::collections::HashMap"), false, &deps),
615            None
616        );
617        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
618        assert_eq!(
619            classify_use(&segs("super::super"), true, &deps),
620            Some("ancestor module")
621        );
622        assert_eq!(
623            classify_use(&segs("crate::other"), true, &deps),
624            Some("first-party module")
625        );
626        assert_eq!(
627            classify_use(&segs("crate::other::Named"), false, &deps),
628            Some("first-party module")
629        );
630        assert_eq!(
631            classify_use(&segs("rand::Rng"), false, &deps),
632            Some("external crate")
633        );
634        assert_eq!(
635            classify_use(&segs("std::fs"), false, &deps),
636            Some("effectful std")
637        );
638        assert_eq!(
639            classify_use(&segs("std::collections"), true, &deps),
640            Some("glob import")
641        );
642    }
643
644    #[test]
645    fn imports_outside_test_modules_are_ignored() {
646        let src = "use crate::other::*; fn run() {}";
647        assert!(violations_in(src, &[]).is_empty());
648    }
649
650    /// Run the `#[double]` detector over an integration-test snippet.
651    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
652        let ast = syn::parse_file(src).expect("snippet parses");
653        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
654        let mut visitor = DoubleVisitor {
655            file: Path::new("integration.rs"),
656            first_party: &set,
657            violations: Vec::new(),
658        };
659        visitor.visit_file(&ast);
660        visitor.violations
661    }
662
663    #[test]
664    fn flags_double_of_first_party_only() {
665        let src = "\
666use mockall_double::double;
667#[double]
668use widget::Renderer;
669#[double]
670use rand::rngs::ThreadRng;
671#[double]
672use crate::support::Helper;
673";
674        // Only `widget` is first-party: `rand` is external and `crate::` is the test crate.
675        let violations = integration_violations_in(src, &["widget"]);
676        assert_eq!(violations.len(), 1, "got {violations:?}");
677        assert_eq!(violations[0].rule, RULE_DOUBLE);
678    }
679
680    #[test]
681    fn ignores_use_without_double() {
682        let src = "use widget::Renderer; fn t() {}";
683        assert!(integration_violations_in(src, &["widget"]).is_empty());
684    }
685
686    #[test]
687    fn recognizes_double_attribute() {
688        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
689        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
690        assert!(has_double_attr(
691            &item("#[mockall_double::double] use a::B;").attrs
692        ));
693        assert!(!has_double_attr(
694            &item("#[allow(unused_imports)] use a::B;").attrs
695        ));
696        assert!(!has_double_attr(&item("use a::B;").attrs));
697    }
698
699    struct TempTree(PathBuf);
700
701    impl TempTree {
702        fn new(files: &[(&str, &str)]) -> Self {
703            static COUNTER: AtomicU64 = AtomicU64::new(0);
704            let root = std::env::temp_dir().join(format!(
705                "tc-isolation-{}-{}",
706                std::process::id(),
707                COUNTER.fetch_add(1, Ordering::Relaxed),
708            ));
709            for (rel, content) in files {
710                let path = root.join(rel);
711                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
712                std::fs::write(path, content).unwrap();
713            }
714            std::fs::create_dir_all(&root).unwrap();
715            TempTree(root)
716        }
717
718        fn path(&self) -> &Path {
719            &self.0
720        }
721    }
722
723    impl Drop for TempTree {
724        fn drop(&mut self) {
725            let _ = std::fs::remove_dir_all(&self.0);
726        }
727    }
728
729    #[test]
730    fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
731        let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
732        assert!(first_party_crates(tree.path()).unwrap().is_empty());
733        assert!(external_deps(tree.path()).unwrap().is_empty());
734    }
735
736    #[test]
737    fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
738        let tree = TempTree::new(&[(
739            "Cargo.toml",
740            "[package]\n\
741             name = \"my-crate\"\n\n\
742             [dependencies]\n\
743             sibling-lib = { path = \"../sibling-lib\" }\n\
744             rand = \"0.8\"\n\n\
745             [dev-dependencies]\n\
746             test-support = { path = \"../test-support\" }\n\
747             mockall = \"0.13\"\n",
748        )]);
749
750        let first_party = first_party_crates(tree.path()).unwrap();
751        assert_eq!(
752            first_party,
753            ["my_crate", "sibling_lib", "test_support"]
754                .iter()
755                .map(|s| (*s).to_string())
756                .collect::<BTreeSet<String>>(),
757            "the crate's own name and every path dep, hyphens normalized"
758        );
759
760        let external = external_deps(tree.path()).unwrap();
761        assert_eq!(
762            external,
763            ["rand", "sibling_lib"]
764                .iter()
765                .map(|s| (*s).to_string())
766                .collect::<BTreeSet<String>>(),
767            "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
768        );
769    }
770
771    #[test]
772    fn a_call_through_a_non_path_callee_is_left_alone() {
773        let src = "\
774#[cfg(test)]
775mod tests {
776    #[test]
777    fn t() {
778        let _ = (make())(1);
779    }
780}
781";
782        assert!(
783            violations_in(src, &["rand"]).is_empty(),
784            "a callee that is not a path carries no leading segment to classify"
785        );
786    }
787
788    #[test]
789    fn a_renamed_import_is_judged_by_its_source_path() {
790        let src = "\
791#[cfg(test)]
792mod tests {
793    use crate::other::Thing as Local;
794    use super::Widget as W;
795}
796";
797        let violations = violations_in(src, &[]);
798        assert_eq!(violations.len(), 1, "got {violations:?}");
799        let m = &violations[0].message;
800        assert!(
801            m.contains("crate::other::Thing"),
802            "the message names the source path, not the alias: {m}"
803        );
804    }
805
806    #[test]
807    fn a_grouped_import_is_flattened_leaf_by_leaf() {
808        let src = "\
809#[cfg(test)]
810mod tests {
811    use crate::other::{Named, deeper::Other};
812    use super::{Widget, helper};
813}
814";
815        let violations = violations_in(src, &[]);
816        assert_eq!(violations.len(), 2, "got {violations:?}");
817        let (first, second) = (&violations[0].message, &violations[1].message);
818        assert!(first.contains("crate::other::Named"), "{first}");
819        assert!(second.contains("crate::other::deeper::Other"), "{second}");
820    }
821
822    #[test]
823    fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
824        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
825        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
826        assert_eq!(
827            classify_use(&segs("helpers"), true, &deps),
828            Some("glob import"),
829            "a glob is foreign even when `syn` cannot resolve its root"
830        );
831        assert_eq!(
832            classify_use(&segs("helpers::Thing"), false, &deps),
833            None,
834            "a named import of an unresolvable root is the heuristic's documented limit"
835        );
836    }
837
838    #[test]
839    fn a_leading_colon_survives_into_the_message() {
840        let src = "\
841#[cfg(test)]
842mod tests {
843    #[test]
844    fn t() {
845        let _ = ::std::fs::read(\"x\");
846    }
847}
848";
849        let violations = violations_in(src, &[]);
850        assert_eq!(violations.len(), 1, "got {violations:?}");
851        let m = &violations[0].message;
852        assert!(m.contains("`::std::fs::read`"), "{m}");
853    }
854
855    #[test]
856    fn a_bare_cfg_not_is_not_a_test_module() {
857        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
858        assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
859    }
860
861    #[test]
862    fn an_unreadable_unit_source_names_the_file() {
863        let tree = TempTree::new(&[("src/widget.rs", "")]);
864        std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
865        let err = find_violations(tree.path()).unwrap_err();
866        assert!(
867            format!("{err:#}").contains("reading source file"),
868            "got: {err:#}"
869        );
870    }
871
872    #[test]
873    fn an_unparsable_unit_source_names_the_file() {
874        let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
875        let err = find_violations(tree.path()).unwrap_err();
876        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
877    }
878
879    #[test]
880    fn an_unreadable_integration_source_names_the_file() {
881        let tree = TempTree::new(&[("tests/int.rs", "")]);
882        std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
883        let err = find_integration_violations(tree.path()).unwrap_err();
884        assert!(
885            format!("{err:#}").contains("reading source file"),
886            "got: {err:#}"
887        );
888    }
889
890    #[test]
891    fn an_unparsable_integration_source_names_the_file() {
892        let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
893        let err = find_integration_violations(tree.path()).unwrap_err();
894        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
895    }
896
897    #[test]
898    fn integration_violations_are_sorted_by_file_and_line() {
899        let tree = TempTree::new(&[
900            (
901                "Cargo.toml",
902                "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
903            ),
904            (
905                "tests/int.rs",
906                "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
907            ),
908        ]);
909        let violations = find_integration_violations(tree.path()).unwrap();
910        assert_eq!(violations.len(), 2, "got {violations:?}");
911        assert!(violations[0].line < violations[1].line);
912    }
913
914    #[test]
915    fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
916        let tree = TempTree::new(&[("Cargo.toml", "")]);
917        std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
918        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
919        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
920        assert!(first.contains("reading"), "got: {first}");
921        assert!(external.contains("reading"), "got: {external}");
922    }
923
924    #[test]
925    fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
926        let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
927        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
928        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
929        assert!(first.contains("parsing"), "got: {first}");
930        assert!(external.contains("parsing"), "got: {external}");
931    }
932
933    #[test]
934    fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
935        let tree = TempTree::new(&[(
936            "Cargo.toml",
937            "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
938        )]);
939        let first = first_party_crates(tree.path()).unwrap();
940        assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
941        assert!(external_deps(tree.path()).unwrap().is_empty());
942    }
943
944    #[test]
945    fn a_registry_only_dependency_table_feeds_external_deps() {
946        let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
947        let external = external_deps(tree.path()).unwrap();
948        assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
949    }
950
951    #[test]
952    fn a_missing_root_is_an_error_for_integration_collection() {
953        let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
954        assert!(
955            format!("{err:#}").contains("reading directory"),
956            "got: {err:#}"
957        );
958    }
959}