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 — 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.
263///
264/// `fs` is the deliberate carve-out. Rust privacy makes the inline `#[cfg(test)]` module the
265/// only tier that can reach a private item, so a private path-walker can be tested nowhere
266/// else — and its argument is a directory that has to exist. `env::temp_dir` rides along
267/// because it only names a writable directory; the rest of `env` reads ambient state the test
268/// never created, which is the collaborator this rule exists to catch.
269fn is_effectful_std(segs: &[String]) -> bool {
270    match segs.get(1).map(String::as_str) {
271        Some("net" | "process" | "thread" | "os") => true,
272        Some("env") => segs.get(2).map(String::as_str) != Some("temp_dir"),
273        Some("io") => matches!(
274            segs.get(2).map(String::as_str),
275            Some("stdin" | "stdout" | "stderr")
276        ),
277        Some("time") => {
278            matches!(
279                segs.get(2).map(String::as_str),
280                Some("SystemTime" | "Instant")
281            ) && segs.get(3).map(String::as_str) == Some("now")
282        }
283        _ => false,
284    }
285}
286
287/// Flatten a `use` tree into `(path, is_glob)` leaves: `use a::{b, c::*}` yields
288/// `([a, b], false)` and `([a, c], true)`. A rename is judged by its source path.
289fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
290    match tree {
291        syn::UseTree::Path(path) => {
292            prefix.push(path.ident.to_string());
293            flatten_use(&path.tree, prefix, out);
294            prefix.pop();
295        }
296        syn::UseTree::Name(name) => {
297            let mut full = prefix.clone();
298            full.push(name.ident.to_string());
299            out.push((full, false));
300        }
301        syn::UseTree::Rename(rename) => {
302            let mut full = prefix.clone();
303            full.push(rename.ident.to_string());
304            out.push((full, false));
305        }
306        syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
307        syn::UseTree::Group(group) => {
308            for item in &group.items {
309                flatten_use(item, prefix, out);
310            }
311        }
312    }
313}
314
315/// Why a `use` reaches out of the test's own module, or `None` when it stays in-module.
316/// The one legal glob is `super::*`; a named import is judged by its root like a call.
317fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
318    match segs.first().map(String::as_str)? {
319        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
320        "self" | "Self" => None,
321        "crate" => Some("first-party module"),
322        "std" if is_effectful_std(segs) => Some("effectful std"),
323        // A glob of anything but `super` is foreign, even for pure `std`.
324        "std" | "core" | "alloc" => is_glob.then_some("glob import"),
325        other => {
326            if deps.contains(other) {
327                Some("external crate")
328            } else {
329                is_glob.then_some("glob import")
330            }
331        }
332    }
333}
334
335/// Render a flattened import for the message: `a::b`, or `a::b::*` for a glob.
336fn render_use(segs: &[String], is_glob: bool) -> String {
337    let mut out = segs.join("::");
338    if is_glob {
339        if !out.is_empty() {
340            out.push_str("::");
341        }
342        out.push('*');
343    }
344    out
345}
346
347/// Render a path back to `a::b::c` for the message; generic args are dropped.
348fn render_path(path: &syn::Path) -> String {
349    let mut out = String::new();
350    if path.leading_colon.is_some() {
351        out.push_str("::");
352    }
353    for (i, seg) in path.segments.iter().enumerate() {
354        if i > 0 {
355            out.push_str("::");
356        }
357        out.push_str(&seg.ident.to_string());
358    }
359    out
360}
361
362/// `true` when `attrs` carries a `#[cfg(test)]` gate, including `cfg(all(test, …))` and
363/// `cfg(any(test, …))` — the signal for an inline unit-test module.
364pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
365    attrs.iter().any(|attr| {
366        attr.path().is_ident("cfg")
367            && attr
368                .meta
369                .require_list()
370                .map(|list| cfg_mentions_test(list.tokens.clone()))
371                .unwrap_or(false)
372    })
373}
374
375/// `true` when a `cfg(...)` predicate positively requires `test`. `#[cfg(not(test))]` gates
376/// production code for non-test builds, and a `feature = "test"` string never counts.
377fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
378    cfg_requires_test(tokens, false)
379}
380
381/// `true` when a bare `test` ident is reached under an even number of enclosing `not(...)`
382/// groups. `negated` flips inside each `not(...)`, so `not(test)` does not qualify.
383fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
384    let mut iter = tokens.into_iter().peekable();
385    while let Some(tt) = iter.next() {
386        match tt {
387            proc_macro2::TokenTree::Ident(id) if id == "not" => {
388                // `not` applies to the group immediately following it.
389                if let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() {
390                    let stream = group.stream();
391                    iter.next();
392                    if cfg_requires_test(stream, !negated) {
393                        return true;
394                    }
395                }
396            }
397            proc_macro2::TokenTree::Ident(id) => {
398                if !negated && id == "test" {
399                    return true;
400                }
401            }
402            proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
403                return true;
404            }
405            _ => {}
406        }
407    }
408    false
409}
410
411/// The 1-based lines of the Rust items a `#[cfg(not(test))]` gate keeps out of a test build.
412///
413/// The unit tier runs `--lib --bins`, which sets `cfg(test)`, so no test reaches those lines.
414/// `mutation` drops their mutants — unkillable by construction — and `coverage` drops their
415/// regions, which the binary target's test harness instruments as 0-hit. Unparseable source
416/// yields no lines, so both checks keep judging what they already judged.
417pub(crate) fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
418    let Ok(ast) = syn::parse_file(source) else {
419        return BTreeSet::new();
420    };
421    let mut hidden = HiddenItems::default();
422    hidden.visit_file(&ast);
423    hidden.lines
424}
425
426/// Collects the line ranges of gated items. A gated `mod` or `impl` covers everything inside it,
427/// so recording the whole span is enough and the walk need not track nesting.
428#[derive(Default)]
429struct HiddenItems {
430    lines: BTreeSet<u32>,
431}
432
433impl HiddenItems {
434    fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
435        if !has_cfg_not_test(attrs) {
436            return;
437        }
438        let span = node.span();
439        self.lines
440            .extend(span.start().line as u32..=span.end().line as u32);
441    }
442}
443
444impl<'ast> Visit<'ast> for HiddenItems {
445    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
446        self.gated(&node.attrs, node);
447        visit::visit_item_fn(self, node);
448    }
449
450    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
451        self.gated(&node.attrs, node);
452        visit::visit_item_mod(self, node);
453    }
454
455    fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
456        self.gated(&node.attrs, node);
457        visit::visit_item_impl(self, node);
458    }
459
460    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
461        self.gated(&node.attrs, node);
462        visit::visit_impl_item_fn(self, node);
463    }
464}
465
466/// `true` when `attrs` carry a `cfg` gate that no test build can satisfy — `#[cfg(not(test))]`
467/// and `#[cfg(all(not(test), unix))]`, but not `#[cfg(any(not(test), unix))]`, which still
468/// compiles under `cargo test`.
469pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
470    attrs.iter().any(|attr| {
471        attr.path().is_ident("cfg")
472            && attr
473                .meta
474                .require_list()
475                .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
476                .unwrap_or(false)
477    })
478}
479
480/// A `cfg(...)` predicate's truth with `test` set and every other condition unknown. Only a
481/// definite [`CfgTruth::False`] proves the item is compiled out of a test build.
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483enum CfgTruth {
484    False,
485    True,
486    Unknown,
487}
488
489/// Evaluate a whole `cfg(...)` predicate list. A `cfg` attribute holds exactly one predicate;
490/// anything else is malformed and not ours to judge.
491fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
492    match cfg_predicates(tokens).as_slice() {
493        [only] => *only,
494        _ => CfgTruth::Unknown,
495    }
496}
497
498/// Evaluate each comma-separated predicate in a `not(…)` / `all(…)` / `any(…)` group.
499fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
500    let mut out = Vec::new();
501    let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
502    for tt in tokens {
503        match &tt {
504            proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
505                if !current.is_empty() {
506                    out.push(cfg_predicate(&current));
507                    current.clear();
508                }
509            }
510            _ => current.push(tt),
511        }
512    }
513    if !current.is_empty() {
514        out.push(cfg_predicate(&current));
515    }
516    out
517}
518
519/// Evaluate one predicate with `test` set. A bare `test` is true, the three combinators recurse,
520/// and everything else — `unix`, `feature = "x"`, an unknown combinator — is
521/// [`CfgTruth::Unknown`].
522fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
523    use proc_macro2::TokenTree;
524    match tokens {
525        [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
526        [TokenTree::Ident(id), TokenTree::Group(group)] => {
527            let inner = cfg_predicates(group.stream());
528            match id.to_string().as_str() {
529                // `not` takes exactly one predicate; a malformed `not()` is undecidable, not true.
530                "not" => match inner.as_slice() {
531                    [only] => cfg_negate(*only),
532                    _ => CfgTruth::Unknown,
533                },
534                "all" => cfg_all(&inner),
535                "any" => cfg_any(&inner),
536                _ => CfgTruth::Unknown,
537            }
538        }
539        _ => CfgTruth::Unknown,
540    }
541}
542
543/// `all(…)`: false if any part is false, unknown if any part is unknown. An empty `all()` is true.
544fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
545    if parts.contains(&CfgTruth::False) {
546        CfgTruth::False
547    } else if parts.contains(&CfgTruth::Unknown) {
548        CfgTruth::Unknown
549    } else {
550        CfgTruth::True
551    }
552}
553
554/// `any(…)`: true if any part is true, unknown if any part is unknown. An empty `any()` is false.
555fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
556    if parts.contains(&CfgTruth::True) {
557        CfgTruth::True
558    } else if parts.contains(&CfgTruth::Unknown) {
559        CfgTruth::Unknown
560    } else {
561        CfgTruth::False
562    }
563}
564
565/// `not(…)`: an unknown stays unknown, so a gate we cannot decide never drops a mutant.
566fn cfg_negate(truth: CfgTruth) -> CfgTruth {
567    match truth {
568        CfgTruth::False => CfgTruth::True,
569        CfgTruth::True => CfgTruth::False,
570        CfgTruth::Unknown => CfgTruth::Unknown,
571    }
572}
573
574/// The crate's `[dependencies]` names, hyphens normalized to underscores — the external
575/// crates whose calls are out-of-module. `[dev-dependencies]` are excluded: a unit test
576/// uses its framework (`mockall`, `rstest`, …) for real.
577fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
578    let manifest = root.join("Cargo.toml");
579    if !manifest.is_file() {
580        return Ok(BTreeSet::new());
581    }
582    let text = std::fs::read_to_string(&manifest)
583        .with_context(|| format!("reading `{}`", manifest.display()))?;
584    let value: toml::Value =
585        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
586    let mut deps = BTreeSet::new();
587    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
588        for name in table.keys() {
589            deps.insert(name.replace('-', "_"));
590        }
591    }
592    Ok(deps)
593}
594
595fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
596    let entries =
597        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
598    for entry in entries {
599        let path = crate::walk::dir_entry(entry, dir)?.path();
600        if path.is_dir() {
601            collect_rust_files(&path, out)?;
602        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
603            out.push(path);
604        }
605    }
606    Ok(())
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use std::sync::atomic::{AtomicU64, Ordering};
613
614    /// Run the visitor over a source snippet with the given external-crate deps.
615    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
616        let ast = syn::parse_file(src).expect("snippet parses");
617        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
618        let mut visitor = IsolationVisitor {
619            file: Path::new("snippet.rs"),
620            deps: &dep_set,
621            test_depth: 0,
622            violations: Vec::new(),
623        };
624        visitor.visit_file(&ast);
625        visitor.violations
626    }
627
628    #[test]
629    fn flags_each_out_of_module_form() {
630        let src = "\
631#[cfg(test)]
632mod tests {
633    use super::*;
634    #[test]
635    fn t() {
636        let _ = crate::store::load();
637        let _ = std::net::TcpStream::connect(\"x\");
638        let _ = rand::random::<u8>();
639        let _ = super::super::util::help();
640    }
641}
642";
643        let violations = violations_in(src, &["rand"]);
644        assert_eq!(violations.len(), 4, "got {violations:?}");
645        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
646    }
647
648    #[test]
649    fn allows_in_module_calls() {
650        let src = "\
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use std::io::Cursor;
655    #[test]
656    fn t() {
657        let _ = super::widget();
658        let _ = self::helper();
659        let _ = Cursor::new(b\"x\");
660        let _ = std::collections::HashMap::<u8, u8>::new();
661        assert_eq!(1, 1);
662    }
663}
664";
665        assert!(violations_in(src, &["rand"]).is_empty());
666    }
667
668    #[test]
669    fn ignores_calls_outside_test_modules() {
670        let src = "fn run() { let _ = crate::other::go(); }";
671        assert!(violations_in(src, &[]).is_empty());
672    }
673
674    #[test]
675    fn reports_the_call_line() {
676        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
677        let src = "\
678#[cfg(test)]
679mod tests {
680    fn t() {
681        let _ = crate::other::go();
682    }
683}
684";
685        let violations = violations_in(src, &[]);
686        assert_eq!(violations.len(), 1);
687        assert_eq!(violations[0].line, 4);
688    }
689
690    #[test]
691    fn effectful_std_policy() {
692        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
693        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
694        assert!(is_effectful_std(&segs("std::env::var")));
695        assert!(is_effectful_std(&segs("std::env")));
696        assert!(is_effectful_std(&segs("std::process::exit")));
697        assert!(is_effectful_std(&segs("std::thread::sleep")));
698        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
699        assert!(is_effectful_std(&segs("std::io::stdout")));
700        assert!(!is_effectful_std(&segs("std::fs::read")));
701        assert!(!is_effectful_std(&segs("std::fs")));
702        assert!(!is_effectful_std(&segs("std::env::temp_dir")));
703        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
704        assert!(!is_effectful_std(&segs("std::io::Cursor")));
705        assert!(!is_effectful_std(&segs("std::time::Duration")));
706        assert!(!is_effectful_std(&segs("std::cmp::min")));
707    }
708
709    #[test]
710    fn classify_leading_segment() {
711        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
712        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
713        assert_eq!(classify(&path("super::foo"), &deps), None);
714        assert_eq!(classify(&path("self::foo"), &deps), None);
715        assert_eq!(classify(&path("Local::new"), &deps), None);
716        assert_eq!(
717            classify(&path("super::super::foo"), &deps),
718            Some("ancestor module")
719        );
720        assert_eq!(
721            classify(&path("crate::a::b"), &deps),
722            Some("first-party module")
723        );
724        assert_eq!(
725            classify(&path("rand::random"), &deps),
726            Some("external crate")
727        );
728        assert_eq!(
729            classify(&path("std::net::TcpStream::connect"), &deps),
730            Some("effectful std")
731        );
732        assert_eq!(classify(&path("std::fs::read"), &deps), None);
733        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
734    }
735
736    #[test]
737    fn recognizes_cfg_test_attribute() {
738        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
739        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
740        assert!(has_cfg_test(
741            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
742        ));
743        assert!(!has_cfg_test(
744            &module("#[cfg(feature = \"test\")] mod t {}").attrs
745        ));
746        assert!(!has_cfg_test(&module("mod t {}").attrs));
747        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
748        assert!(!has_cfg_test(
749            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
750        ));
751        assert!(!has_cfg_test(
752            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
753        ));
754        assert!(has_cfg_test(
755            &module("#[cfg(not(not(test)))] mod t {}").attrs
756        ));
757    }
758
759    #[test]
760    fn flags_each_foreign_import() {
761        let src = "\
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use super::Thing;
766    use crate::other::*;
767    use crate::other::Named;
768    use rand::Rng;
769    use std::net;
770    use std::fs;
771    use std::collections::HashMap;
772    use std::io::Cursor;
773}
774";
775        // Flagged: the crate glob, the crate named import, `rand`, and `std::net`. `std::fs`
776        // is not — a unit test may build the tree its unit walks.
777        let violations = violations_in(src, &["rand"]);
778        assert_eq!(violations.len(), 4, "got {violations:?}");
779        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
780    }
781
782    #[test]
783    fn classify_use_roots() {
784        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
785        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
786        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
787        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
788        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
789        assert_eq!(
790            classify_use(&segs("std::collections::HashMap"), false, &deps),
791            None
792        );
793        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
794        assert_eq!(
795            classify_use(&segs("super::super"), true, &deps),
796            Some("ancestor module")
797        );
798        assert_eq!(
799            classify_use(&segs("crate::other"), true, &deps),
800            Some("first-party module")
801        );
802        assert_eq!(
803            classify_use(&segs("crate::other::Named"), false, &deps),
804            Some("first-party module")
805        );
806        assert_eq!(
807            classify_use(&segs("rand::Rng"), false, &deps),
808            Some("external crate")
809        );
810        assert_eq!(
811            classify_use(&segs("std::net"), false, &deps),
812            Some("effectful std")
813        );
814        assert_eq!(classify_use(&segs("std::fs"), false, &deps), None);
815        assert_eq!(
816            classify_use(&segs("std::collections"), true, &deps),
817            Some("glob import")
818        );
819    }
820
821    #[test]
822    fn imports_outside_test_modules_are_ignored() {
823        let src = "use crate::other::*; fn run() {}";
824        assert!(violations_in(src, &[]).is_empty());
825    }
826
827    /// Run the `#[double]` detector over an integration-test snippet.
828    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
829        let ast = syn::parse_file(src).expect("snippet parses");
830        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
831        let mut visitor = DoubleVisitor {
832            file: Path::new("integration.rs"),
833            first_party: &set,
834            violations: Vec::new(),
835        };
836        visitor.visit_file(&ast);
837        visitor.violations
838    }
839
840    #[test]
841    fn flags_double_of_first_party_only() {
842        let src = "\
843use mockall_double::double;
844#[double]
845use widget::Renderer;
846#[double]
847use rand::rngs::ThreadRng;
848#[double]
849use crate::support::Helper;
850";
851        // Only `widget` is first-party: `rand` is external and `crate::` is the test crate.
852        let violations = integration_violations_in(src, &["widget"]);
853        assert_eq!(violations.len(), 1, "got {violations:?}");
854        assert_eq!(violations[0].rule, RULE_DOUBLE);
855    }
856
857    #[test]
858    fn ignores_use_without_double() {
859        let src = "use widget::Renderer; fn t() {}";
860        assert!(integration_violations_in(src, &["widget"]).is_empty());
861    }
862
863    #[test]
864    fn recognizes_double_attribute() {
865        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
866        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
867        assert!(has_double_attr(
868            &item("#[mockall_double::double] use a::B;").attrs
869        ));
870        assert!(!has_double_attr(
871            &item("#[allow(unused_imports)] use a::B;").attrs
872        ));
873        assert!(!has_double_attr(&item("use a::B;").attrs));
874    }
875
876    struct TempTree(PathBuf);
877
878    impl TempTree {
879        fn new(files: &[(&str, &str)]) -> Self {
880            static COUNTER: AtomicU64 = AtomicU64::new(0);
881            let root = std::env::temp_dir().join(format!(
882                "tc-isolation-{}-{}",
883                std::process::id(),
884                COUNTER.fetch_add(1, Ordering::Relaxed),
885            ));
886            for (rel, content) in files {
887                let path = root.join(rel);
888                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
889                std::fs::write(path, content).unwrap();
890            }
891            std::fs::create_dir_all(&root).unwrap();
892            TempTree(root)
893        }
894
895        fn path(&self) -> &Path {
896            &self.0
897        }
898    }
899
900    impl Drop for TempTree {
901        fn drop(&mut self) {
902            let _ = std::fs::remove_dir_all(&self.0);
903        }
904    }
905
906    #[test]
907    fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
908        let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
909        assert!(first_party_crates(tree.path()).unwrap().is_empty());
910        assert!(external_deps(tree.path()).unwrap().is_empty());
911    }
912
913    #[test]
914    fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
915        let tree = TempTree::new(&[(
916            "Cargo.toml",
917            "[package]\n\
918             name = \"my-crate\"\n\n\
919             [dependencies]\n\
920             sibling-lib = { path = \"../sibling-lib\" }\n\
921             rand = \"0.8\"\n\n\
922             [dev-dependencies]\n\
923             test-support = { path = \"../test-support\" }\n\
924             mockall = \"0.13\"\n",
925        )]);
926
927        let first_party = first_party_crates(tree.path()).unwrap();
928        assert_eq!(
929            first_party,
930            ["my_crate", "sibling_lib", "test_support"]
931                .iter()
932                .map(|s| (*s).to_string())
933                .collect::<BTreeSet<String>>(),
934            "the crate's own name and every path dep, hyphens normalized"
935        );
936
937        let external = external_deps(tree.path()).unwrap();
938        assert_eq!(
939            external,
940            ["rand", "sibling_lib"]
941                .iter()
942                .map(|s| (*s).to_string())
943                .collect::<BTreeSet<String>>(),
944            "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
945        );
946    }
947
948    #[test]
949    fn a_call_through_a_non_path_callee_is_left_alone() {
950        let src = "\
951#[cfg(test)]
952mod tests {
953    #[test]
954    fn t() {
955        let _ = (make())(1);
956    }
957}
958";
959        assert!(
960            violations_in(src, &["rand"]).is_empty(),
961            "a callee that is not a path carries no leading segment to classify"
962        );
963    }
964
965    #[test]
966    fn a_renamed_import_is_judged_by_its_source_path() {
967        let src = "\
968#[cfg(test)]
969mod tests {
970    use crate::other::Thing as Local;
971    use super::Widget as W;
972}
973";
974        let violations = violations_in(src, &[]);
975        assert_eq!(violations.len(), 1, "got {violations:?}");
976        let m = &violations[0].message;
977        assert!(
978            m.contains("crate::other::Thing"),
979            "the message names the source path, not the alias: {m}"
980        );
981    }
982
983    #[test]
984    fn a_grouped_import_is_flattened_leaf_by_leaf() {
985        let src = "\
986#[cfg(test)]
987mod tests {
988    use crate::other::{Named, deeper::Other};
989    use super::{Widget, helper};
990}
991";
992        let violations = violations_in(src, &[]);
993        assert_eq!(violations.len(), 2, "got {violations:?}");
994        let (first, second) = (&violations[0].message, &violations[1].message);
995        assert!(first.contains("crate::other::Named"), "{first}");
996        assert!(second.contains("crate::other::deeper::Other"), "{second}");
997    }
998
999    #[test]
1000    fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
1001        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
1002        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
1003        assert_eq!(
1004            classify_use(&segs("helpers"), true, &deps),
1005            Some("glob import"),
1006            "a glob is foreign even when `syn` cannot resolve its root"
1007        );
1008        assert_eq!(
1009            classify_use(&segs("helpers::Thing"), false, &deps),
1010            None,
1011            "a named import of an unresolvable root is the heuristic's documented limit"
1012        );
1013    }
1014
1015    #[test]
1016    fn a_leading_colon_survives_into_the_message() {
1017        let src = "\
1018#[cfg(test)]
1019mod tests {
1020    #[test]
1021    fn t() {
1022        let _ = ::std::net::TcpStream::connect(\"x\");
1023    }
1024}
1025";
1026        let violations = violations_in(src, &[]);
1027        assert_eq!(violations.len(), 1, "got {violations:?}");
1028        let m = &violations[0].message;
1029        assert!(m.contains("`::std::net::TcpStream::connect`"), "{m}");
1030    }
1031
1032    #[test]
1033    fn a_bare_cfg_not_is_not_a_test_module() {
1034        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
1035        assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
1036    }
1037
1038    #[test]
1039    fn an_unreadable_unit_source_names_the_file() {
1040        let tree = TempTree::new(&[("src/widget.rs", "")]);
1041        std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
1042        let err = find_violations(tree.path()).unwrap_err();
1043        assert!(
1044            format!("{err:#}").contains("reading source file"),
1045            "got: {err:#}"
1046        );
1047    }
1048
1049    #[test]
1050    fn an_unparsable_unit_source_names_the_file() {
1051        let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
1052        let err = find_violations(tree.path()).unwrap_err();
1053        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1054    }
1055
1056    #[test]
1057    fn an_unreadable_integration_source_names_the_file() {
1058        let tree = TempTree::new(&[("tests/int.rs", "")]);
1059        std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
1060        let err = find_integration_violations(tree.path()).unwrap_err();
1061        assert!(
1062            format!("{err:#}").contains("reading source file"),
1063            "got: {err:#}"
1064        );
1065    }
1066
1067    #[test]
1068    fn an_unparsable_integration_source_names_the_file() {
1069        let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1070        let err = find_integration_violations(tree.path()).unwrap_err();
1071        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1072    }
1073
1074    #[test]
1075    fn integration_violations_are_sorted_by_file_and_line() {
1076        let tree = TempTree::new(&[
1077            (
1078                "Cargo.toml",
1079                "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1080            ),
1081            (
1082                "tests/int.rs",
1083                "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1084            ),
1085        ]);
1086        let violations = find_integration_violations(tree.path()).unwrap();
1087        assert_eq!(violations.len(), 2, "got {violations:?}");
1088        assert!(violations[0].line < violations[1].line);
1089    }
1090
1091    #[test]
1092    fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1093        let tree = TempTree::new(&[("Cargo.toml", "")]);
1094        std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1095        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1096        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1097        assert!(first.contains("reading"), "got: {first}");
1098        assert!(external.contains("reading"), "got: {external}");
1099    }
1100
1101    #[test]
1102    fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1103        let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1104        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1105        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1106        assert!(first.contains("parsing"), "got: {first}");
1107        assert!(external.contains("parsing"), "got: {external}");
1108    }
1109
1110    #[test]
1111    fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1112        let tree = TempTree::new(&[(
1113            "Cargo.toml",
1114            "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1115        )]);
1116        let first = first_party_crates(tree.path()).unwrap();
1117        assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1118        assert!(external_deps(tree.path()).unwrap().is_empty());
1119    }
1120
1121    #[test]
1122    fn a_registry_only_dependency_table_feeds_external_deps() {
1123        let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1124        let external = external_deps(tree.path()).unwrap();
1125        assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1126    }
1127
1128    #[test]
1129    fn a_missing_root_is_an_error_for_integration_collection() {
1130        let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1131        assert!(
1132            format!("{err:#}").contains("reading directory"),
1133            "got: {err:#}"
1134        );
1135    }
1136
1137    #[test]
1138    fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1139        let source = "\
1140#[cfg(not(test))]
1141pub fn main() -> u8 {
1142    run()
1143}
1144
1145fn run() -> u8 {
1146    1
1147}
1148";
1149        assert_eq!(
1150            lines_hidden_from_tests(source),
1151            BTreeSet::from([1, 2, 3, 4])
1152        );
1153    }
1154
1155    #[test]
1156    fn a_gated_module_hides_everything_inside_it() {
1157        let source = "\
1158#[cfg(not(test))]
1159mod real {
1160    pub fn go() -> u8 {
1161        1
1162    }
1163}
1164";
1165        assert_eq!(
1166            lines_hidden_from_tests(source),
1167            BTreeSet::from([1, 2, 3, 4, 5, 6])
1168        );
1169    }
1170
1171    #[test]
1172    fn a_gated_method_hides_only_that_method() {
1173        let source = "\
1174impl Runner {
1175    #[cfg(not(test))]
1176    fn go(&self) -> u8 {
1177        1
1178    }
1179
1180    fn stay(&self) -> u8 {
1181        2
1182    }
1183}
1184";
1185        assert_eq!(
1186            lines_hidden_from_tests(source),
1187            BTreeSet::from([2, 3, 4, 5])
1188        );
1189    }
1190
1191    #[test]
1192    fn an_ungated_file_hides_nothing() {
1193        let source = "#[cfg(test)]\nmod tests {\n    fn t() {}\n}\n\nfn go() -> u8 {\n    1\n}\n";
1194
1195        assert!(lines_hidden_from_tests(source).is_empty());
1196    }
1197
1198    #[test]
1199    fn unparseable_source_hides_nothing() {
1200        assert!(lines_hidden_from_tests("fn go( {").is_empty());
1201    }
1202
1203    /// Whether `attr` on a plain function hides it from the test build.
1204    fn hides_under(attr: &str) -> bool {
1205        !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n    1\n}}\n")).is_empty()
1206    }
1207
1208    #[test]
1209    fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1210        assert!(hides_under("#[cfg(not(test))]"));
1211        assert!(hides_under("#[cfg(all(not(test), unix))]"));
1212        assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1213        assert!(hides_under("#[cfg(any())]"));
1214        assert!(hides_under("#[cfg(not(all()))]"));
1215    }
1216
1217    #[test]
1218    fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1219        assert!(!hides_under("#[cfg(test)]"));
1220        assert!(!hides_under("#[cfg(unix)]"));
1221        assert!(!hides_under("#[cfg(feature = \"x\")]"));
1222        assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1223        assert!(!hides_under("#[cfg(not(not(test)))]"));
1224        assert!(!hides_under("#[cfg(all())]"));
1225        assert!(!hides_under("#[inline]"));
1226    }
1227
1228    #[test]
1229    fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1230        assert!(!hides_under("#[cfg(not(unix))]"));
1231        assert!(!hides_under("#[cfg(all(test, unix))]"));
1232    }
1233
1234    #[test]
1235    fn a_malformed_gate_hides_nothing() {
1236        assert!(!hides_under("#[cfg(not())]"));
1237        assert!(!hides_under("#[cfg(nope(test))]"));
1238        assert!(!hides_under("#[cfg(not(test), unix)]"));
1239    }
1240}