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/// `true` when `attrs` carry a `cfg` gate that no test build can satisfy — `#[cfg(not(test))]`
405/// and `#[cfg(all(not(test), unix))]`, but not `#[cfg(any(not(test), unix))]`, which still
406/// compiles under `cargo test`. `mutation` reads this: a mutant inside an item the test build
407/// never compiles is unkillable by construction.
408pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
409    attrs.iter().any(|attr| {
410        attr.path().is_ident("cfg")
411            && attr
412                .meta
413                .require_list()
414                .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
415                .unwrap_or(false)
416    })
417}
418
419/// A `cfg(...)` predicate's truth with `test` set and every other condition unknown. Only a
420/// definite [`CfgTruth::False`] proves the item is compiled out of a test build.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422enum CfgTruth {
423    False,
424    True,
425    Unknown,
426}
427
428/// Evaluate a whole `cfg(...)` predicate list. A `cfg` attribute holds exactly one predicate;
429/// anything else is malformed and not ours to judge.
430fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
431    match cfg_predicates(tokens).as_slice() {
432        [only] => *only,
433        _ => CfgTruth::Unknown,
434    }
435}
436
437/// Evaluate each comma-separated predicate in a `not(…)` / `all(…)` / `any(…)` group.
438fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
439    let mut out = Vec::new();
440    let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
441    for tt in tokens {
442        match &tt {
443            proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
444                if !current.is_empty() {
445                    out.push(cfg_predicate(&current));
446                    current.clear();
447                }
448            }
449            _ => current.push(tt),
450        }
451    }
452    if !current.is_empty() {
453        out.push(cfg_predicate(&current));
454    }
455    out
456}
457
458/// Evaluate one predicate with `test` set. A bare `test` is true, the three combinators recurse,
459/// and everything else — `unix`, `feature = "x"`, an unknown combinator — is
460/// [`CfgTruth::Unknown`].
461fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
462    use proc_macro2::TokenTree;
463    match tokens {
464        [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
465        [TokenTree::Ident(id), TokenTree::Group(group)] => {
466            let inner = cfg_predicates(group.stream());
467            match id.to_string().as_str() {
468                // `not` takes exactly one predicate; a malformed `not()` is undecidable, not true.
469                "not" => match inner.as_slice() {
470                    [only] => cfg_negate(*only),
471                    _ => CfgTruth::Unknown,
472                },
473                "all" => cfg_all(&inner),
474                "any" => cfg_any(&inner),
475                _ => CfgTruth::Unknown,
476            }
477        }
478        _ => CfgTruth::Unknown,
479    }
480}
481
482/// `all(…)`: false if any part is false, unknown if any part is unknown. An empty `all()` is true.
483fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
484    if parts.contains(&CfgTruth::False) {
485        CfgTruth::False
486    } else if parts.contains(&CfgTruth::Unknown) {
487        CfgTruth::Unknown
488    } else {
489        CfgTruth::True
490    }
491}
492
493/// `any(…)`: true if any part is true, unknown if any part is unknown. An empty `any()` is false.
494fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
495    if parts.contains(&CfgTruth::True) {
496        CfgTruth::True
497    } else if parts.contains(&CfgTruth::Unknown) {
498        CfgTruth::Unknown
499    } else {
500        CfgTruth::False
501    }
502}
503
504/// `not(…)`: an unknown stays unknown, so a gate we cannot decide never drops a mutant.
505fn cfg_negate(truth: CfgTruth) -> CfgTruth {
506    match truth {
507        CfgTruth::False => CfgTruth::True,
508        CfgTruth::True => CfgTruth::False,
509        CfgTruth::Unknown => CfgTruth::Unknown,
510    }
511}
512
513/// The crate's `[dependencies]` names, hyphens normalized to underscores — the external
514/// crates whose calls are out-of-module. `[dev-dependencies]` are excluded: a unit test
515/// uses its framework (`mockall`, `rstest`, …) for real.
516fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
517    let manifest = root.join("Cargo.toml");
518    if !manifest.is_file() {
519        return Ok(BTreeSet::new());
520    }
521    let text = std::fs::read_to_string(&manifest)
522        .with_context(|| format!("reading `{}`", manifest.display()))?;
523    let value: toml::Value =
524        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
525    let mut deps = BTreeSet::new();
526    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
527        for name in table.keys() {
528            deps.insert(name.replace('-', "_"));
529        }
530    }
531    Ok(deps)
532}
533
534fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
535    let entries =
536        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
537    for entry in entries {
538        let path = crate::walk::dir_entry(entry, dir)?.path();
539        if path.is_dir() {
540            collect_rust_files(&path, out)?;
541        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
542            out.push(path);
543        }
544    }
545    Ok(())
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use std::sync::atomic::{AtomicU64, Ordering};
552
553    /// Run the visitor over a source snippet with the given external-crate deps.
554    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
555        let ast = syn::parse_file(src).expect("snippet parses");
556        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
557        let mut visitor = IsolationVisitor {
558            file: Path::new("snippet.rs"),
559            deps: &dep_set,
560            test_depth: 0,
561            violations: Vec::new(),
562        };
563        visitor.visit_file(&ast);
564        visitor.violations
565    }
566
567    #[test]
568    fn flags_each_out_of_module_form() {
569        let src = "\
570#[cfg(test)]
571mod tests {
572    use super::*;
573    #[test]
574    fn t() {
575        let _ = crate::store::load();
576        let _ = std::fs::read(\"x\");
577        let _ = rand::random::<u8>();
578        let _ = super::super::util::help();
579    }
580}
581";
582        let violations = violations_in(src, &["rand"]);
583        assert_eq!(violations.len(), 4, "got {violations:?}");
584        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
585    }
586
587    #[test]
588    fn allows_in_module_calls() {
589        let src = "\
590#[cfg(test)]
591mod tests {
592    use super::*;
593    use std::io::Cursor;
594    #[test]
595    fn t() {
596        let _ = super::widget();
597        let _ = self::helper();
598        let _ = Cursor::new(b\"x\");
599        let _ = std::collections::HashMap::<u8, u8>::new();
600        assert_eq!(1, 1);
601    }
602}
603";
604        assert!(violations_in(src, &["rand"]).is_empty());
605    }
606
607    #[test]
608    fn ignores_calls_outside_test_modules() {
609        let src = "fn run() { let _ = crate::other::go(); }";
610        assert!(violations_in(src, &[]).is_empty());
611    }
612
613    #[test]
614    fn reports_the_call_line() {
615        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
616        let src = "\
617#[cfg(test)]
618mod tests {
619    fn t() {
620        let _ = crate::other::go();
621    }
622}
623";
624        let violations = violations_in(src, &[]);
625        assert_eq!(violations.len(), 1);
626        assert_eq!(violations[0].line, 4);
627    }
628
629    #[test]
630    fn effectful_std_policy() {
631        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
632        assert!(is_effectful_std(&segs("std::fs::read")));
633        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
634        assert!(is_effectful_std(&segs("std::env::var")));
635        assert!(is_effectful_std(&segs("std::process::exit")));
636        assert!(is_effectful_std(&segs("std::thread::sleep")));
637        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
638        assert!(is_effectful_std(&segs("std::io::stdout")));
639        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
640        assert!(!is_effectful_std(&segs("std::io::Cursor")));
641        assert!(!is_effectful_std(&segs("std::time::Duration")));
642        assert!(!is_effectful_std(&segs("std::cmp::min")));
643    }
644
645    #[test]
646    fn classify_leading_segment() {
647        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
648        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
649        assert_eq!(classify(&path("super::foo"), &deps), None);
650        assert_eq!(classify(&path("self::foo"), &deps), None);
651        assert_eq!(classify(&path("Local::new"), &deps), None);
652        assert_eq!(
653            classify(&path("super::super::foo"), &deps),
654            Some("ancestor module")
655        );
656        assert_eq!(
657            classify(&path("crate::a::b"), &deps),
658            Some("first-party module")
659        );
660        assert_eq!(
661            classify(&path("rand::random"), &deps),
662            Some("external crate")
663        );
664        assert_eq!(
665            classify(&path("std::fs::read"), &deps),
666            Some("effectful std")
667        );
668        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
669    }
670
671    #[test]
672    fn recognizes_cfg_test_attribute() {
673        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
674        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
675        assert!(has_cfg_test(
676            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
677        ));
678        assert!(!has_cfg_test(
679            &module("#[cfg(feature = \"test\")] mod t {}").attrs
680        ));
681        assert!(!has_cfg_test(&module("mod t {}").attrs));
682        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
683        assert!(!has_cfg_test(
684            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
685        ));
686        assert!(!has_cfg_test(
687            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
688        ));
689        assert!(has_cfg_test(
690            &module("#[cfg(not(not(test)))] mod t {}").attrs
691        ));
692    }
693
694    #[test]
695    fn flags_each_foreign_import() {
696        let src = "\
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use super::Thing;
701    use crate::other::*;
702    use crate::other::Named;
703    use rand::Rng;
704    use std::fs;
705    use std::collections::HashMap;
706    use std::io::Cursor;
707}
708";
709        // Flagged: the crate glob, the crate named import, `rand`, and `std::fs`.
710        let violations = violations_in(src, &["rand"]);
711        assert_eq!(violations.len(), 4, "got {violations:?}");
712        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
713    }
714
715    #[test]
716    fn classify_use_roots() {
717        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
718        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
719        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
720        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
721        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
722        assert_eq!(
723            classify_use(&segs("std::collections::HashMap"), false, &deps),
724            None
725        );
726        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
727        assert_eq!(
728            classify_use(&segs("super::super"), true, &deps),
729            Some("ancestor module")
730        );
731        assert_eq!(
732            classify_use(&segs("crate::other"), true, &deps),
733            Some("first-party module")
734        );
735        assert_eq!(
736            classify_use(&segs("crate::other::Named"), false, &deps),
737            Some("first-party module")
738        );
739        assert_eq!(
740            classify_use(&segs("rand::Rng"), false, &deps),
741            Some("external crate")
742        );
743        assert_eq!(
744            classify_use(&segs("std::fs"), false, &deps),
745            Some("effectful std")
746        );
747        assert_eq!(
748            classify_use(&segs("std::collections"), true, &deps),
749            Some("glob import")
750        );
751    }
752
753    #[test]
754    fn imports_outside_test_modules_are_ignored() {
755        let src = "use crate::other::*; fn run() {}";
756        assert!(violations_in(src, &[]).is_empty());
757    }
758
759    /// Run the `#[double]` detector over an integration-test snippet.
760    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
761        let ast = syn::parse_file(src).expect("snippet parses");
762        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
763        let mut visitor = DoubleVisitor {
764            file: Path::new("integration.rs"),
765            first_party: &set,
766            violations: Vec::new(),
767        };
768        visitor.visit_file(&ast);
769        visitor.violations
770    }
771
772    #[test]
773    fn flags_double_of_first_party_only() {
774        let src = "\
775use mockall_double::double;
776#[double]
777use widget::Renderer;
778#[double]
779use rand::rngs::ThreadRng;
780#[double]
781use crate::support::Helper;
782";
783        // Only `widget` is first-party: `rand` is external and `crate::` is the test crate.
784        let violations = integration_violations_in(src, &["widget"]);
785        assert_eq!(violations.len(), 1, "got {violations:?}");
786        assert_eq!(violations[0].rule, RULE_DOUBLE);
787    }
788
789    #[test]
790    fn ignores_use_without_double() {
791        let src = "use widget::Renderer; fn t() {}";
792        assert!(integration_violations_in(src, &["widget"]).is_empty());
793    }
794
795    #[test]
796    fn recognizes_double_attribute() {
797        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
798        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
799        assert!(has_double_attr(
800            &item("#[mockall_double::double] use a::B;").attrs
801        ));
802        assert!(!has_double_attr(
803            &item("#[allow(unused_imports)] use a::B;").attrs
804        ));
805        assert!(!has_double_attr(&item("use a::B;").attrs));
806    }
807
808    struct TempTree(PathBuf);
809
810    impl TempTree {
811        fn new(files: &[(&str, &str)]) -> Self {
812            static COUNTER: AtomicU64 = AtomicU64::new(0);
813            let root = std::env::temp_dir().join(format!(
814                "tc-isolation-{}-{}",
815                std::process::id(),
816                COUNTER.fetch_add(1, Ordering::Relaxed),
817            ));
818            for (rel, content) in files {
819                let path = root.join(rel);
820                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
821                std::fs::write(path, content).unwrap();
822            }
823            std::fs::create_dir_all(&root).unwrap();
824            TempTree(root)
825        }
826
827        fn path(&self) -> &Path {
828            &self.0
829        }
830    }
831
832    impl Drop for TempTree {
833        fn drop(&mut self) {
834            let _ = std::fs::remove_dir_all(&self.0);
835        }
836    }
837
838    #[test]
839    fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
840        let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
841        assert!(first_party_crates(tree.path()).unwrap().is_empty());
842        assert!(external_deps(tree.path()).unwrap().is_empty());
843    }
844
845    #[test]
846    fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
847        let tree = TempTree::new(&[(
848            "Cargo.toml",
849            "[package]\n\
850             name = \"my-crate\"\n\n\
851             [dependencies]\n\
852             sibling-lib = { path = \"../sibling-lib\" }\n\
853             rand = \"0.8\"\n\n\
854             [dev-dependencies]\n\
855             test-support = { path = \"../test-support\" }\n\
856             mockall = \"0.13\"\n",
857        )]);
858
859        let first_party = first_party_crates(tree.path()).unwrap();
860        assert_eq!(
861            first_party,
862            ["my_crate", "sibling_lib", "test_support"]
863                .iter()
864                .map(|s| (*s).to_string())
865                .collect::<BTreeSet<String>>(),
866            "the crate's own name and every path dep, hyphens normalized"
867        );
868
869        let external = external_deps(tree.path()).unwrap();
870        assert_eq!(
871            external,
872            ["rand", "sibling_lib"]
873                .iter()
874                .map(|s| (*s).to_string())
875                .collect::<BTreeSet<String>>(),
876            "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
877        );
878    }
879
880    #[test]
881    fn a_call_through_a_non_path_callee_is_left_alone() {
882        let src = "\
883#[cfg(test)]
884mod tests {
885    #[test]
886    fn t() {
887        let _ = (make())(1);
888    }
889}
890";
891        assert!(
892            violations_in(src, &["rand"]).is_empty(),
893            "a callee that is not a path carries no leading segment to classify"
894        );
895    }
896
897    #[test]
898    fn a_renamed_import_is_judged_by_its_source_path() {
899        let src = "\
900#[cfg(test)]
901mod tests {
902    use crate::other::Thing as Local;
903    use super::Widget as W;
904}
905";
906        let violations = violations_in(src, &[]);
907        assert_eq!(violations.len(), 1, "got {violations:?}");
908        let m = &violations[0].message;
909        assert!(
910            m.contains("crate::other::Thing"),
911            "the message names the source path, not the alias: {m}"
912        );
913    }
914
915    #[test]
916    fn a_grouped_import_is_flattened_leaf_by_leaf() {
917        let src = "\
918#[cfg(test)]
919mod tests {
920    use crate::other::{Named, deeper::Other};
921    use super::{Widget, helper};
922}
923";
924        let violations = violations_in(src, &[]);
925        assert_eq!(violations.len(), 2, "got {violations:?}");
926        let (first, second) = (&violations[0].message, &violations[1].message);
927        assert!(first.contains("crate::other::Named"), "{first}");
928        assert!(second.contains("crate::other::deeper::Other"), "{second}");
929    }
930
931    #[test]
932    fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
933        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
934        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
935        assert_eq!(
936            classify_use(&segs("helpers"), true, &deps),
937            Some("glob import"),
938            "a glob is foreign even when `syn` cannot resolve its root"
939        );
940        assert_eq!(
941            classify_use(&segs("helpers::Thing"), false, &deps),
942            None,
943            "a named import of an unresolvable root is the heuristic's documented limit"
944        );
945    }
946
947    #[test]
948    fn a_leading_colon_survives_into_the_message() {
949        let src = "\
950#[cfg(test)]
951mod tests {
952    #[test]
953    fn t() {
954        let _ = ::std::fs::read(\"x\");
955    }
956}
957";
958        let violations = violations_in(src, &[]);
959        assert_eq!(violations.len(), 1, "got {violations:?}");
960        let m = &violations[0].message;
961        assert!(m.contains("`::std::fs::read`"), "{m}");
962    }
963
964    #[test]
965    fn a_bare_cfg_not_is_not_a_test_module() {
966        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
967        assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
968    }
969
970    #[test]
971    fn an_unreadable_unit_source_names_the_file() {
972        let tree = TempTree::new(&[("src/widget.rs", "")]);
973        std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
974        let err = find_violations(tree.path()).unwrap_err();
975        assert!(
976            format!("{err:#}").contains("reading source file"),
977            "got: {err:#}"
978        );
979    }
980
981    #[test]
982    fn an_unparsable_unit_source_names_the_file() {
983        let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
984        let err = find_violations(tree.path()).unwrap_err();
985        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
986    }
987
988    #[test]
989    fn an_unreadable_integration_source_names_the_file() {
990        let tree = TempTree::new(&[("tests/int.rs", "")]);
991        std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
992        let err = find_integration_violations(tree.path()).unwrap_err();
993        assert!(
994            format!("{err:#}").contains("reading source file"),
995            "got: {err:#}"
996        );
997    }
998
999    #[test]
1000    fn an_unparsable_integration_source_names_the_file() {
1001        let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1002        let err = find_integration_violations(tree.path()).unwrap_err();
1003        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1004    }
1005
1006    #[test]
1007    fn integration_violations_are_sorted_by_file_and_line() {
1008        let tree = TempTree::new(&[
1009            (
1010                "Cargo.toml",
1011                "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1012            ),
1013            (
1014                "tests/int.rs",
1015                "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1016            ),
1017        ]);
1018        let violations = find_integration_violations(tree.path()).unwrap();
1019        assert_eq!(violations.len(), 2, "got {violations:?}");
1020        assert!(violations[0].line < violations[1].line);
1021    }
1022
1023    #[test]
1024    fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1025        let tree = TempTree::new(&[("Cargo.toml", "")]);
1026        std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1027        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1028        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1029        assert!(first.contains("reading"), "got: {first}");
1030        assert!(external.contains("reading"), "got: {external}");
1031    }
1032
1033    #[test]
1034    fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1035        let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1036        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1037        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1038        assert!(first.contains("parsing"), "got: {first}");
1039        assert!(external.contains("parsing"), "got: {external}");
1040    }
1041
1042    #[test]
1043    fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1044        let tree = TempTree::new(&[(
1045            "Cargo.toml",
1046            "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1047        )]);
1048        let first = first_party_crates(tree.path()).unwrap();
1049        assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1050        assert!(external_deps(tree.path()).unwrap().is_empty());
1051    }
1052
1053    #[test]
1054    fn a_registry_only_dependency_table_feeds_external_deps() {
1055        let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1056        let external = external_deps(tree.path()).unwrap();
1057        assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1058    }
1059
1060    #[test]
1061    fn a_missing_root_is_an_error_for_integration_collection() {
1062        let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1063        assert!(
1064            format!("{err:#}").contains("reading directory"),
1065            "got: {err:#}"
1066        );
1067    }
1068}