Skip to main content

testing_conventions/
isolation.rs

1//! Rust unit-isolation lint for inline `#[cfg(test)]` modules. The AST walk is a
2//! deterministic `syn` heuristic; its precision limits live in `internals/rust/isolation.md`.
3
4use std::collections::BTreeSet;
5use std::iter::Peekable;
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 `scan_root`, sorted by `(file, line)`.
35/// `crate_root`'s `Cargo.toml` names the external crates, so a scan pointed at `src/` still
36/// sees the dependency set. `tests/`, `benches/`, `examples/`, and `target/` are not unit
37/// source, so a local build changes no result.
38pub fn find_violations(
39    scan_root: impl AsRef<Path>,
40    crate_root: impl AsRef<Path>,
41) -> Result<Vec<Violation>> {
42    let root = scan_root.as_ref();
43    let deps = external_deps(crate_root.as_ref())?;
44
45    let mut files = Vec::new();
46    crate::colocated_test::collect_rust_source_files(root, &mut files)?;
47    files.sort();
48
49    let mut violations = Vec::new();
50    for file in &files {
51        let source = std::fs::read_to_string(file)
52            .with_context(|| format!("reading source file `{}`", file.display()))?;
53        let ast = syn::parse_file(&source)
54            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
55        let mut visitor = IsolationVisitor {
56            file,
57            deps: &deps,
58            test_depth: 0,
59            violations: Vec::new(),
60        };
61        visitor.visit_file(&ast);
62        violations.append(&mut visitor.violations);
63    }
64
65    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
66    Ok(violations)
67}
68
69/// Every `no-first-party-double` violation in the `tests/` crates under crate root `root`.
70/// An integration test runs first-party code for real, so doubling it is the error;
71/// doubling an external crate is fine.
72pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
73    let root = root.as_ref();
74    let first_party = first_party_crates(root)?;
75
76    let mut files = Vec::new();
77    collect_rust_files(root, &mut files)?;
78    files.retain(|file| is_integration_test(root, file));
79    files.sort();
80
81    let mut violations = Vec::new();
82    for file in &files {
83        let source = std::fs::read_to_string(file)
84            .with_context(|| format!("reading source file `{}`", file.display()))?;
85        let ast = syn::parse_file(&source)
86            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
87        violations.extend(double_violations(file, &ast, &first_party));
88    }
89
90    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
91    Ok(violations)
92}
93
94/// Every `no-first-party-double` violation the parsed integration test `ast` carries, in source
95/// order.
96fn double_violations(
97    file: &Path,
98    ast: &syn::File,
99    first_party: &BTreeSet<String>,
100) -> Vec<Violation> {
101    let mut visitor = DoubleVisitor {
102        file,
103        first_party,
104        violations: Vec::new(),
105    };
106    visitor.visit_file(ast);
107    visitor.violations
108}
109
110/// Walks one integration-test file, flagging a `#[double]` of a first-party crate.
111struct DoubleVisitor<'a> {
112    file: &'a Path,
113    first_party: &'a BTreeSet<String>,
114    violations: Vec<Violation>,
115}
116
117impl<'ast> Visit<'ast> for DoubleVisitor<'_> {
118    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
119        if has_double_attr(&node.attrs) {
120            let mut imports = Vec::new();
121            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
122            if let Some((segs, is_glob)) = imports.iter().find(|(segs, _)| {
123                segs.first()
124                    .is_some_and(|root| self.first_party.contains(root))
125            }) {
126                self.violations.push(Violation {
127                    file: self.file.to_path_buf(),
128                    line: node.span().start().line,
129                    rule: RULE_DOUBLE,
130                    message: format!(
131                        "integration test doubles first-party `{}` with `#[double]`; \
132                         run first-party code for real — only external crates may be doubled",
133                        render_use(segs, *is_glob),
134                    ),
135                });
136            }
137        }
138        visit::visit_item_use(self, node);
139    }
140
141    fn visit_macro(&mut self, node: &'ast syn::Macro) {
142        if let MacroBody::Items(items) = macro_body(node) {
143            let mut inner = DoubleVisitor {
144                file: self.file,
145                first_party: self.first_party,
146                violations: Vec::new(),
147            };
148            for item in &items {
149                inner.visit_item(item);
150            }
151            self.violations.append(&mut inner.violations);
152        }
153        visit::visit_macro(self, node);
154    }
155}
156
157/// `true` for a `#[double]` / `#[mockall_double::double]` attribute.
158fn has_double_attr(attrs: &[syn::Attribute]) -> bool {
159    attrs.iter().any(|attr| {
160        attr.path()
161            .segments
162            .last()
163            .is_some_and(|seg| seg.ident == "double")
164    })
165}
166
167/// The crate's own `[package].name` plus every `path` dependency, hyphens normalized to
168/// underscores. A `tests/` crate names the library under test by crate name rather than
169/// `crate::`, so the name is what a `#[double]` import is matched against.
170fn first_party_crates(root: &Path) -> Result<BTreeSet<String>> {
171    let manifest = root.join("Cargo.toml");
172    if !manifest.is_file() {
173        return Ok(BTreeSet::new());
174    }
175    let text = std::fs::read_to_string(&manifest)
176        .with_context(|| format!("reading `{}`", manifest.display()))?;
177    let value: toml::Value =
178        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
179    let mut set = path_dependency_names(&value);
180    set.extend(package_crate_name(&value));
181    Ok(set)
182}
183
184/// The manifest's own `[package].name`, hyphens normalized to underscores.
185fn package_crate_name(value: &toml::Value) -> Option<String> {
186    Some(
187        value
188            .get("package")?
189            .get("name")?
190            .as_str()?
191            .replace('-', "_"),
192    )
193}
194
195/// Every `path` dependency the manifest declares, hyphens normalized to underscores. A registry
196/// dependency is external code, so only a `path` entry is first-party.
197fn path_dependency_names(value: &toml::Value) -> BTreeSet<String> {
198    let mut set = BTreeSet::new();
199    for table_name in ["dependencies", "dev-dependencies"] {
200        let Some(table) = value.get(table_name).and_then(toml::Value::as_table) else {
201            continue;
202        };
203        for (name, spec) in table {
204            if spec.as_table().is_some_and(|t| t.contains_key("path")) {
205                set.insert(name.replace('-', "_"));
206            }
207        }
208    }
209    set
210}
211
212/// `true` when `file` (under `root`) is a Rust integration test — a `*.rs` file with a
213/// `tests` component. An inline `#[cfg(test)]` unit test doubles its collaborators by
214/// design; only a `tests/` crate runs first-party code for real.
215fn is_integration_test(root: &Path, file: &Path) -> bool {
216    file.strip_prefix(root)
217        .unwrap_or(file)
218        .components()
219        .any(|component| component.as_os_str() == "tests")
220}
221
222/// Walks one parsed file, flagging out-of-module calls inside `#[cfg(test)]` modules.
223struct IsolationVisitor<'a> {
224    file: &'a Path,
225    deps: &'a BTreeSet<String>,
226    test_depth: usize,
227    violations: Vec<Violation>,
228}
229
230impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
231    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
232        let is_test = has_cfg_test(&node.attrs);
233        if is_test {
234            self.test_depth += 1;
235        }
236        visit::visit_item_mod(self, node);
237        if is_test {
238            self.test_depth -= 1;
239        }
240    }
241
242    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
243        if self.test_depth > 0 {
244            if let syn::Expr::Path(path_expr) = node.func.as_ref() {
245                if let Some(kind) = classify(&path_expr.path, self.deps) {
246                    self.violations.push(Violation {
247                        file: self.file.to_path_buf(),
248                        line: node.span().start().line,
249                        rule: RULE_CALL,
250                        message: format!(
251                            "unit test calls `{}` out of its own module ({kind}); \
252                             inject a trait double for effectful collaborators",
253                            render_path(&path_expr.path),
254                        ),
255                    });
256                }
257            }
258        }
259        visit::visit_expr_call(self, node);
260    }
261
262    fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
263        if self.test_depth > 0 {
264            let mut imports = Vec::new();
265            flatten_use(&node.tree, &mut Vec::new(), &mut imports);
266            for (segs, is_glob) in &imports {
267                if let Some(kind) = classify_use(segs, *is_glob, self.deps) {
268                    self.violations.push(Violation {
269                        file: self.file.to_path_buf(),
270                        line: node.span().start().line,
271                        rule: RULE_IMPORT,
272                        message: format!(
273                            "unit test imports `{}` out of its own module ({kind}); \
274                             import the unit or a named pure value instead",
275                            render_use(segs, *is_glob),
276                        ),
277                    });
278                }
279            }
280        }
281        visit::visit_item_use(self, node);
282    }
283
284    fn visit_macro(&mut self, node: &'ast syn::Macro) {
285        if self.test_depth > 0 {
286            let mut inner = IsolationVisitor {
287                file: self.file,
288                deps: self.deps,
289                test_depth: self.test_depth,
290                violations: Vec::new(),
291            };
292            match macro_body(node) {
293                MacroBody::Items(items) => {
294                    for item in &items {
295                        inner.visit_item(item);
296                    }
297                }
298                MacroBody::Exprs(exprs) => {
299                    for expr in &exprs {
300                        inner.visit_expr(expr);
301                    }
302                }
303                MacroBody::Opaque => {}
304            }
305            self.violations.append(&mut inner.violations);
306        }
307        visit::visit_macro(self, node);
308    }
309}
310
311/// What a macro invocation's token body could be re-parsed as. `Opaque` covers both a body
312/// that is no valid Rust and one this check refuses to read — see [`macro_body`].
313enum MacroBody {
314    Items(Vec<syn::Item>),
315    Exprs(Vec<syn::Expr>),
316    Opaque,
317}
318
319/// Re-parse a macro invocation's tokens so the reaches inside them are visible. A macro node
320/// carries an unparsed `TokenStream`, so `assert!(crate::load())` never reaches `visit_expr_call`
321/// without this.
322///
323/// This reads the tokens the invocation was *written* with, not what the macro expands to:
324/// `assert!`, `vec!`, `write!` and their kin pass their arguments through, so what is written is
325/// what runs. A quoting macro is the opposite — its body is a template for code emitted
326/// elsewhere — so `quote!`/`quote_spanned!` and a `macro_rules!` definition are left opaque
327/// rather than read as if they executed here.
328fn macro_body(mac: &syn::Macro) -> MacroBody {
329    use syn::parse::Parser;
330
331    let name = mac.path.segments.last().map(|seg| seg.ident.to_string());
332    if matches!(
333        name.as_deref(),
334        Some("quote" | "quote_spanned" | "macro_rules")
335    ) {
336        return MacroBody::Opaque;
337    }
338    let tokens = mac.tokens.clone();
339    if let Ok(file) = syn::parse2::<syn::File>(tokens.clone()) {
340        return MacroBody::Items(file.items);
341    }
342    let args = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
343    match args.parse2(tokens) {
344        Ok(exprs) => MacroBody::Exprs(exprs.into_iter().collect()),
345        Err(_) => MacroBody::Opaque,
346    }
347}
348
349/// Why a call's leading path is out-of-module, or `None` when it stays in-module or is
350/// unresolvable — an unresolvable path is not flagged, the `syn` heuristic's known limit.
351fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
352    let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
353    if is_pure_call_path(&segs) {
354        return None;
355    }
356    match segs.first().map(String::as_str)? {
357        "self" | "Self" => None,
358        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
359        "crate" => Some("first-party module"),
360        "std" => is_effectful_std(&segs).then_some("effectful std"),
361        // `core`/`alloc` carry no effectful APIs.
362        "core" | "alloc" => None,
363        // A local type or fn, including one imported by `super::*`, is in-module.
364        other => deps.contains(other).then_some("external crate"),
365    }
366}
367
368fn is_pure_call_path(segs: &[String]) -> bool {
369    const PATHS: &[&str] = &[
370        "clap::Command::new",
371        "clap::Arg::new",
372        "clap::Error::new",
373        "syn::parse_str",
374        "syn::parse_file",
375        "toml::from_str",
376        "zip::ZipWriter::new",
377        "zip::write::SimpleFileOptions::default",
378        "flate2::write::GzEncoder::new",
379        "flate2::Compression::default",
380        "tar::Builder::new",
381        "tar::Header::new_gnu",
382        "std::process::ExitStatus::from_raw",
383    ];
384    PATHS.contains(&segs.join("::").as_str())
385}
386
387fn is_pure_import_path(segs: &[String]) -> bool {
388    const PATHS: &[&str] = &[
389        "clap::error::ErrorKind",
390        "std::os::unix::process::ExitStatusExt",
391    ];
392    PATHS.contains(&segs.join("::").as_str())
393}
394
395/// `true` for an effectful `std` path — net, process, env, threads, OS, the clock, or
396/// real-handle I/O. Pure `std` stays in-module: `internals/rust/testing.md` makes
397/// `io::Cursor` the idiomatic in-memory unit-test tool.
398///
399/// `fs` is the deliberate carve-out. Rust privacy makes the inline `#[cfg(test)]` module the
400/// only tier that can reach a private item, so a private path-walker can be tested nowhere
401/// else — and its argument is a directory that has to exist. `env::temp_dir` rides along
402/// because it only names a writable directory; the rest of `env` reads ambient state the test
403/// never created, which is the collaborator this rule exists to catch. `process::id` is the
404/// other half of that same naming idiom — the runner's own PID, with nothing to double — while
405/// the rest of `process` spawns, controls, or terminates.
406fn is_effectful_std(segs: &[String]) -> bool {
407    match segs.get(1).map(String::as_str) {
408        Some("net" | "thread" | "os") => true,
409        Some("process") => segs.get(2).map(String::as_str) != Some("id"),
410        Some("env") => segs.get(2).map(String::as_str) != Some("temp_dir"),
411        Some("io") => matches!(
412            segs.get(2).map(String::as_str),
413            Some("stdin" | "stdout" | "stderr")
414        ),
415        Some("time") => {
416            matches!(
417                segs.get(2).map(String::as_str),
418                Some("SystemTime" | "Instant")
419            ) && segs.get(3).map(String::as_str) == Some("now")
420        }
421        _ => false,
422    }
423}
424
425/// Flatten a `use` tree into `(path, is_glob)` leaves: `use a::{b, c::*}` yields
426/// `([a, b], false)` and `([a, c], true)`. A rename is judged by its source path.
427fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
428    match tree {
429        syn::UseTree::Path(path) => {
430            prefix.push(path.ident.to_string());
431            flatten_use(&path.tree, prefix, out);
432            prefix.pop();
433        }
434        syn::UseTree::Name(name) => out.push(use_leaf(prefix, &name.ident)),
435        syn::UseTree::Rename(rename) => out.push(use_leaf(prefix, &rename.ident)),
436        syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
437        syn::UseTree::Group(group) => {
438            for item in &group.items {
439                flatten_use(item, prefix, out);
440            }
441        }
442    }
443}
444
445/// The non-glob leaf a named or renamed import contributes: `prefix` extended by `ident`.
446fn use_leaf(prefix: &[String], ident: &syn::Ident) -> (Vec<String>, bool) {
447    let mut full = prefix.to_vec();
448    full.push(ident.to_string());
449    (full, false)
450}
451
452/// Why a `use` reaches out of the test's own module, or `None` when it stays in-module.
453/// The one legal glob is `super::*`; a named import is judged by its root like a call.
454fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
455    if !is_glob && is_pure_import_path(segs) {
456        return None;
457    }
458    match segs.first().map(String::as_str)? {
459        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
460        "self" | "Self" => None,
461        "crate" => Some("first-party module"),
462        "std" if is_effectful_std(segs) => Some("effectful std"),
463        // A glob of anything but `super` is foreign, even for pure `std`.
464        "std" | "core" | "alloc" => is_glob.then_some("glob import"),
465        other => {
466            if deps.contains(other) {
467                Some("external crate")
468            } else {
469                is_glob.then_some("glob import")
470            }
471        }
472    }
473}
474
475/// Render a flattened import for the message: `a::b`, or `a::b::*` for a glob.
476fn render_use(segs: &[String], is_glob: bool) -> String {
477    let mut out = segs.join("::");
478    if is_glob {
479        if !out.is_empty() {
480            out.push_str("::");
481        }
482        out.push('*');
483    }
484    out
485}
486
487/// Render a path back to `a::b::c` for the message; generic args are dropped.
488fn render_path(path: &syn::Path) -> String {
489    let mut out = String::new();
490    if path.leading_colon.is_some() {
491        out.push_str("::");
492    }
493    for (i, seg) in path.segments.iter().enumerate() {
494        if i > 0 {
495            out.push_str("::");
496        }
497        out.push_str(&seg.ident.to_string());
498    }
499    out
500}
501
502/// `true` when `attrs` carries a `#[cfg(test)]` gate, including `cfg(all(test, …))` and
503/// `cfg(any(test, …))` — the signal for an inline unit-test module.
504pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
505    attrs.iter().any(|attr| {
506        attr.path().is_ident("cfg")
507            && attr
508                .meta
509                .require_list()
510                .map(|list| cfg_mentions_test(list.tokens.clone()))
511                .unwrap_or(false)
512    })
513}
514
515/// `true` when a `cfg(...)` predicate positively requires `test`. `#[cfg(not(test))]` gates
516/// production code for non-test builds, and a `feature = "test"` string never counts.
517fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
518    cfg_requires_test(tokens, false)
519}
520
521/// `true` when a bare `test` ident is reached under an even number of enclosing `not(...)`
522/// groups. `negated` flips inside each `not(...)`, so `not(test)` does not qualify.
523fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
524    let mut iter = tokens.into_iter().peekable();
525    while let Some(tt) = iter.next() {
526        match tt {
527            proc_macro2::TokenTree::Ident(id) => {
528                if cfg_ident_requires_test(&id, &mut iter, negated) {
529                    return true;
530                }
531            }
532            proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
533                return true;
534            }
535            _ => {}
536        }
537    }
538    false
539}
540
541/// `true` when an ident in a `cfg(...)` predicate requires `test`: a bare `test` outside any
542/// `not(...)`, or a `not(...)` whose group requires it under the flipped negation.
543fn cfg_ident_requires_test(
544    id: &proc_macro2::Ident,
545    iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
546    negated: bool,
547) -> bool {
548    if id == "not" {
549        return cfg_not_requires_test(iter, negated);
550    }
551    !negated && id == "test"
552}
553
554/// `true` when the group a `not` applies to — the token tree immediately following it, which
555/// this consumes — requires `test` under the flipped negation.
556fn cfg_not_requires_test(
557    iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
558    negated: bool,
559) -> bool {
560    let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() else {
561        return false;
562    };
563    let stream = group.stream();
564    iter.next();
565    cfg_requires_test(stream, !negated)
566}
567
568/// The 1-based lines of the Rust items a `#[cfg(not(test))]` gate keeps out of a test build.
569///
570/// The unit tier runs `--lib --bins`, which sets `cfg(test)`, so no test reaches those lines.
571/// `mutation` drops their mutants — unkillable by construction — and `coverage` drops their
572/// regions, which the binary target's test harness instruments as 0-hit. Unparseable source
573/// yields no lines, so both checks keep judging what they already judged.
574pub(crate) fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
575    let Ok(ast) = syn::parse_file(source) else {
576        return BTreeSet::new();
577    };
578    let mut hidden = HiddenItems::default();
579    hidden.visit_file(&ast);
580    hidden.lines
581}
582
583/// Collects the line ranges of gated items. A gated `mod` or `impl` covers everything inside it,
584/// so recording the whole span is enough and the walk need not track nesting.
585#[derive(Default)]
586struct HiddenItems {
587    lines: BTreeSet<u32>,
588}
589
590impl HiddenItems {
591    fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
592        if !has_cfg_not_test(attrs) {
593            return;
594        }
595        let span = node.span();
596        self.lines
597            .extend(span.start().line as u32..=span.end().line as u32);
598    }
599}
600
601impl<'ast> Visit<'ast> for HiddenItems {
602    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
603        self.gated(&node.attrs, node);
604        visit::visit_item_fn(self, node);
605    }
606
607    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
608        self.gated(&node.attrs, node);
609        visit::visit_item_mod(self, node);
610    }
611
612    fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
613        self.gated(&node.attrs, node);
614        visit::visit_item_impl(self, node);
615    }
616
617    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
618        self.gated(&node.attrs, node);
619        visit::visit_impl_item_fn(self, node);
620    }
621}
622
623/// `true` when `attrs` carry a `cfg` gate that no test build can satisfy — `#[cfg(not(test))]`
624/// and `#[cfg(all(not(test), unix))]`, but not `#[cfg(any(not(test), unix))]`, which still
625/// compiles under `cargo test`.
626pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
627    attrs.iter().any(|attr| {
628        attr.path().is_ident("cfg")
629            && attr
630                .meta
631                .require_list()
632                .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
633                .unwrap_or(false)
634    })
635}
636
637/// A `cfg(...)` predicate's truth with `test` set and every other condition unknown. Only a
638/// definite [`CfgTruth::False`] proves the item is compiled out of a test build.
639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640enum CfgTruth {
641    False,
642    True,
643    Unknown,
644}
645
646/// Evaluate a whole `cfg(...)` predicate list. A `cfg` attribute holds exactly one predicate;
647/// anything else is malformed and not ours to judge.
648fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
649    match cfg_predicates(tokens).as_slice() {
650        [only] => *only,
651        _ => CfgTruth::Unknown,
652    }
653}
654
655/// Evaluate each comma-separated predicate in a `not(…)` / `all(…)` / `any(…)` group.
656fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
657    let mut out = Vec::new();
658    let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
659    for tt in tokens {
660        match &tt {
661            proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
662                if !current.is_empty() {
663                    out.push(cfg_predicate(&current));
664                    current.clear();
665                }
666            }
667            _ => current.push(tt),
668        }
669    }
670    if !current.is_empty() {
671        out.push(cfg_predicate(&current));
672    }
673    out
674}
675
676/// Evaluate one predicate with `test` set. A bare `test` is true, the three combinators recurse,
677/// and everything else — `unix`, `feature = "x"`, an unknown combinator — is
678/// [`CfgTruth::Unknown`].
679fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
680    use proc_macro2::TokenTree;
681    match tokens {
682        [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
683        [TokenTree::Ident(id), TokenTree::Group(group)] => {
684            let inner = cfg_predicates(group.stream());
685            match id.to_string().as_str() {
686                // `not` takes exactly one predicate; a malformed `not()` is undecidable, not true.
687                "not" => match inner.as_slice() {
688                    [only] => cfg_negate(*only),
689                    _ => CfgTruth::Unknown,
690                },
691                "all" => cfg_all(&inner),
692                "any" => cfg_any(&inner),
693                _ => CfgTruth::Unknown,
694            }
695        }
696        _ => CfgTruth::Unknown,
697    }
698}
699
700/// `all(…)`: false if any part is false, unknown if any part is unknown. An empty `all()` is true.
701fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
702    if parts.contains(&CfgTruth::False) {
703        CfgTruth::False
704    } else if parts.contains(&CfgTruth::Unknown) {
705        CfgTruth::Unknown
706    } else {
707        CfgTruth::True
708    }
709}
710
711/// `any(…)`: true if any part is true, unknown if any part is unknown. An empty `any()` is false.
712fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
713    if parts.contains(&CfgTruth::True) {
714        CfgTruth::True
715    } else if parts.contains(&CfgTruth::Unknown) {
716        CfgTruth::Unknown
717    } else {
718        CfgTruth::False
719    }
720}
721
722/// `not(…)`: an unknown stays unknown, so a gate we cannot decide never drops a mutant.
723fn cfg_negate(truth: CfgTruth) -> CfgTruth {
724    match truth {
725        CfgTruth::False => CfgTruth::True,
726        CfgTruth::True => CfgTruth::False,
727        CfgTruth::Unknown => CfgTruth::Unknown,
728    }
729}
730
731/// The crate's `[dependencies]` names, hyphens normalized to underscores — the external
732/// crates whose calls are out-of-module. `[dev-dependencies]` are excluded: a unit test
733/// uses its framework (`mockall`, `rstest`, …) for real.
734fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
735    let manifest = root.join("Cargo.toml");
736    if !manifest.is_file() {
737        return Ok(BTreeSet::new());
738    }
739    let text = std::fs::read_to_string(&manifest)
740        .with_context(|| format!("reading `{}`", manifest.display()))?;
741    let value: toml::Value =
742        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
743    let mut deps = BTreeSet::new();
744    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
745        for name in table.keys() {
746            deps.insert(name.replace('-', "_"));
747        }
748    }
749    Ok(deps)
750}
751
752fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
753    let entries =
754        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
755    for entry in entries {
756        let path = crate::walk::dir_entry(entry, dir)?.path();
757        if path.is_dir() {
758            collect_rust_files(&path, out)?;
759        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
760            out.push(path);
761        }
762    }
763    Ok(())
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use std::sync::atomic::{AtomicU64, Ordering};
770
771    /// Run the visitor over a source snippet with the given external-crate deps.
772    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
773        let ast = syn::parse_file(src).expect("snippet parses");
774        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
775        let mut visitor = IsolationVisitor {
776            file: Path::new("snippet.rs"),
777            deps: &dep_set,
778            test_depth: 0,
779            violations: Vec::new(),
780        };
781        visitor.visit_file(&ast);
782        visitor.violations
783    }
784
785    #[test]
786    fn flags_each_out_of_module_form() {
787        let src = "\
788#[cfg(test)]
789mod tests {
790    use super::*;
791    #[test]
792    fn t() {
793        let _ = crate::store::load();
794        let _ = std::net::TcpStream::connect(\"x\");
795        let _ = rand::random::<u8>();
796        let _ = super::super::util::help();
797    }
798}
799";
800        let violations = violations_in(src, &["rand"]);
801        assert_eq!(violations.len(), 4, "got {violations:?}");
802        assert!(violations.iter().all(|v| v.rule == RULE_CALL));
803    }
804
805    #[test]
806    fn allows_in_module_calls() {
807        let src = "\
808#[cfg(test)]
809mod tests {
810    use super::*;
811    use std::io::Cursor;
812    #[test]
813    fn t() {
814        let _ = super::widget();
815        let _ = self::helper();
816        let _ = Cursor::new(b\"x\");
817        let _ = std::collections::HashMap::<u8, u8>::new();
818        assert_eq!(1, 1);
819    }
820}
821";
822        assert!(violations_in(src, &["rand"]).is_empty());
823    }
824
825    #[test]
826    fn ignores_calls_outside_test_modules() {
827        let src = "fn run() { let _ = crate::other::go(); }";
828        assert!(violations_in(src, &[]).is_empty());
829    }
830
831    #[test]
832    fn reports_the_call_line() {
833        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
834        let src = "\
835#[cfg(test)]
836mod tests {
837    fn t() {
838        let _ = crate::other::go();
839    }
840}
841";
842        let violations = violations_in(src, &[]);
843        assert_eq!(violations.len(), 1);
844        assert_eq!(violations[0].line, 4);
845    }
846
847    #[test]
848    fn effectful_std_policy() {
849        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
850        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
851        assert!(is_effectful_std(&segs("std::env::var")));
852        assert!(is_effectful_std(&segs("std::env")));
853        assert!(is_effectful_std(&segs("std::process::exit")));
854        assert!(is_effectful_std(&segs("std::process::Command::new")));
855        assert!(is_effectful_std(&segs("std::process")));
856        assert!(is_effectful_std(&segs("std::thread::sleep")));
857        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
858        assert!(is_effectful_std(&segs("std::io::stdout")));
859        assert!(!is_effectful_std(&segs("std::fs::read")));
860        assert!(!is_effectful_std(&segs("std::fs")));
861        assert!(!is_effectful_std(&segs("std::env::temp_dir")));
862        assert!(!is_effectful_std(&segs("std::process::id")));
863        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
864        assert!(!is_effectful_std(&segs("std::io::Cursor")));
865        assert!(!is_effectful_std(&segs("std::time::Duration")));
866        assert!(!is_effectful_std(&segs("std::cmp::min")));
867    }
868
869    #[test]
870    fn classify_leading_segment() {
871        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
872        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
873        assert_eq!(classify(&path("super::foo"), &deps), None);
874        assert_eq!(classify(&path("self::foo"), &deps), None);
875        assert_eq!(classify(&path("Local::new"), &deps), None);
876        assert_eq!(
877            classify(&path("super::super::foo"), &deps),
878            Some("ancestor module")
879        );
880        assert_eq!(
881            classify(&path("crate::a::b"), &deps),
882            Some("first-party module")
883        );
884        assert_eq!(
885            classify(&path("rand::random"), &deps),
886            Some("external crate")
887        );
888        assert_eq!(
889            classify(&path("std::net::TcpStream::connect"), &deps),
890            Some("effectful std")
891        );
892        assert_eq!(classify(&path("std::fs::read"), &deps), None);
893        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
894    }
895
896    #[test]
897    fn recognizes_cfg_test_attribute() {
898        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
899        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
900        assert!(has_cfg_test(
901            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
902        ));
903        assert!(!has_cfg_test(
904            &module("#[cfg(feature = \"test\")] mod t {}").attrs
905        ));
906        assert!(!has_cfg_test(&module("mod t {}").attrs));
907        assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
908        assert!(!has_cfg_test(
909            &module("#[cfg(all(not(test), unix))] mod t {}").attrs
910        ));
911        assert!(!has_cfg_test(
912            &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
913        ));
914        assert!(has_cfg_test(
915            &module("#[cfg(not(not(test)))] mod t {}").attrs
916        ));
917    }
918
919    #[test]
920    fn flags_each_foreign_import() {
921        let src = "\
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use super::Thing;
926    use crate::other::*;
927    use crate::other::Named;
928    use rand::Rng;
929    use std::net;
930    use std::fs;
931    use std::collections::HashMap;
932    use std::io::Cursor;
933}
934";
935        // Flagged: the crate glob, the crate named import, `rand`, and `std::net`. `std::fs`
936        // is not — a unit test may build the tree its unit walks.
937        let violations = violations_in(src, &["rand"]);
938        assert_eq!(violations.len(), 4, "got {violations:?}");
939        assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
940    }
941
942    #[test]
943    fn classify_use_roots() {
944        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
945        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
946        assert_eq!(classify_use(&segs("super"), true, &deps), None); // `use super::*`
947        assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
948        assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
949        assert_eq!(
950            classify_use(&segs("std::collections::HashMap"), false, &deps),
951            None
952        );
953        assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
954        assert_eq!(
955            classify_use(&segs("super::super"), true, &deps),
956            Some("ancestor module")
957        );
958        assert_eq!(
959            classify_use(&segs("crate::other"), true, &deps),
960            Some("first-party module")
961        );
962        assert_eq!(
963            classify_use(&segs("crate::other::Named"), false, &deps),
964            Some("first-party module")
965        );
966        assert_eq!(
967            classify_use(&segs("rand::Rng"), false, &deps),
968            Some("external crate")
969        );
970        assert_eq!(
971            classify_use(&segs("std::net"), false, &deps),
972            Some("effectful std")
973        );
974        assert_eq!(classify_use(&segs("std::fs"), false, &deps), None);
975        assert_eq!(
976            classify_use(&segs("std::collections"), true, &deps),
977            Some("glob import")
978        );
979    }
980
981    #[test]
982    fn imports_outside_test_modules_are_ignored() {
983        let src = "use crate::other::*; fn run() {}";
984        assert!(violations_in(src, &[]).is_empty());
985    }
986
987    /// Run the `#[double]` detector over an integration-test snippet.
988    fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
989        let ast = syn::parse_file(src).expect("snippet parses");
990        let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
991        let mut visitor = DoubleVisitor {
992            file: Path::new("integration.rs"),
993            first_party: &set,
994            violations: Vec::new(),
995        };
996        visitor.visit_file(&ast);
997        visitor.violations
998    }
999
1000    #[test]
1001    fn flags_double_of_first_party_only() {
1002        let src = "\
1003use mockall_double::double;
1004#[double]
1005use widget::Renderer;
1006#[double]
1007use rand::rngs::ThreadRng;
1008#[double]
1009use crate::support::Helper;
1010";
1011        // Only `widget` is first-party: `rand` is external and `crate::` is the test crate.
1012        let violations = integration_violations_in(src, &["widget"]);
1013        assert_eq!(violations.len(), 1, "got {violations:?}");
1014        assert_eq!(violations[0].rule, RULE_DOUBLE);
1015    }
1016
1017    #[test]
1018    fn ignores_use_without_double() {
1019        let src = "use widget::Renderer; fn t() {}";
1020        assert!(integration_violations_in(src, &["widget"]).is_empty());
1021    }
1022
1023    #[test]
1024    fn recognizes_double_attribute() {
1025        let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
1026        assert!(has_double_attr(&item("#[double] use a::B;").attrs));
1027        assert!(has_double_attr(
1028            &item("#[mockall_double::double] use a::B;").attrs
1029        ));
1030        assert!(!has_double_attr(
1031            &item("#[allow(unused_imports)] use a::B;").attrs
1032        ));
1033        assert!(!has_double_attr(&item("use a::B;").attrs));
1034    }
1035
1036    struct TempTree(PathBuf);
1037
1038    impl TempTree {
1039        fn new(files: &[(&str, &str)]) -> Self {
1040            static COUNTER: AtomicU64 = AtomicU64::new(0);
1041            let root = std::env::temp_dir().join(format!(
1042                "tc-isolation-{}-{}",
1043                std::process::id(),
1044                COUNTER.fetch_add(1, Ordering::Relaxed),
1045            ));
1046            for (rel, content) in files {
1047                let path = root.join(rel);
1048                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1049                std::fs::write(path, content).unwrap();
1050            }
1051            std::fs::create_dir_all(&root).unwrap();
1052            TempTree(root)
1053        }
1054
1055        fn path(&self) -> &Path {
1056            &self.0
1057        }
1058    }
1059
1060    impl Drop for TempTree {
1061        fn drop(&mut self) {
1062            let _ = std::fs::remove_dir_all(&self.0);
1063        }
1064    }
1065
1066    #[test]
1067    fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
1068        let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
1069        assert!(first_party_crates(tree.path()).unwrap().is_empty());
1070        assert!(external_deps(tree.path()).unwrap().is_empty());
1071    }
1072
1073    #[test]
1074    fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
1075        let tree = TempTree::new(&[(
1076            "Cargo.toml",
1077            "[package]\n\
1078             name = \"my-crate\"\n\n\
1079             [dependencies]\n\
1080             sibling-lib = { path = \"../sibling-lib\" }\n\
1081             rand = \"0.8\"\n\n\
1082             [dev-dependencies]\n\
1083             test-support = { path = \"../test-support\" }\n\
1084             mockall = \"0.13\"\n",
1085        )]);
1086
1087        let first_party = first_party_crates(tree.path()).unwrap();
1088        assert_eq!(
1089            first_party,
1090            ["my_crate", "sibling_lib", "test_support"]
1091                .iter()
1092                .map(|s| (*s).to_string())
1093                .collect::<BTreeSet<String>>(),
1094            "the crate's own name and every path dep, hyphens normalized"
1095        );
1096
1097        let external = external_deps(tree.path()).unwrap();
1098        assert_eq!(
1099            external,
1100            ["rand", "sibling_lib"]
1101                .iter()
1102                .map(|s| (*s).to_string())
1103                .collect::<BTreeSet<String>>(),
1104            "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
1105        );
1106    }
1107
1108    #[test]
1109    fn a_call_through_a_non_path_callee_is_left_alone() {
1110        let src = "\
1111#[cfg(test)]
1112mod tests {
1113    #[test]
1114    fn t() {
1115        let _ = (make())(1);
1116    }
1117}
1118";
1119        assert!(
1120            violations_in(src, &["rand"]).is_empty(),
1121            "a callee that is not a path carries no leading segment to classify"
1122        );
1123    }
1124
1125    #[test]
1126    fn a_renamed_import_is_judged_by_its_source_path() {
1127        let src = "\
1128#[cfg(test)]
1129mod tests {
1130    use crate::other::Thing as Local;
1131    use super::Widget as W;
1132}
1133";
1134        let violations = violations_in(src, &[]);
1135        assert_eq!(violations.len(), 1, "got {violations:?}");
1136        let m = &violations[0].message;
1137        assert!(
1138            m.contains("crate::other::Thing"),
1139            "the message names the source path, not the alias: {m}"
1140        );
1141    }
1142
1143    #[test]
1144    fn a_grouped_import_is_flattened_leaf_by_leaf() {
1145        let src = "\
1146#[cfg(test)]
1147mod tests {
1148    use crate::other::{Named, deeper::Other};
1149    use super::{Widget, helper};
1150}
1151";
1152        let violations = violations_in(src, &[]);
1153        assert_eq!(violations.len(), 2, "got {violations:?}");
1154        let (first, second) = (&violations[0].message, &violations[1].message);
1155        assert!(first.contains("crate::other::Named"), "{first}");
1156        assert!(second.contains("crate::other::deeper::Other"), "{second}");
1157    }
1158
1159    #[test]
1160    fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
1161        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
1162        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
1163        assert_eq!(
1164            classify_use(&segs("helpers"), true, &deps),
1165            Some("glob import"),
1166            "a glob is foreign even when `syn` cannot resolve its root"
1167        );
1168        assert_eq!(
1169            classify_use(&segs("helpers::Thing"), false, &deps),
1170            None,
1171            "a named import of an unresolvable root is the heuristic's documented limit"
1172        );
1173    }
1174
1175    #[test]
1176    fn a_leading_colon_survives_into_the_message() {
1177        let src = "\
1178#[cfg(test)]
1179mod tests {
1180    #[test]
1181    fn t() {
1182        let _ = ::std::net::TcpStream::connect(\"x\");
1183    }
1184}
1185";
1186        let violations = violations_in(src, &[]);
1187        assert_eq!(violations.len(), 1, "got {violations:?}");
1188        let m = &violations[0].message;
1189        assert!(m.contains("`::std::net::TcpStream::connect`"), "{m}");
1190    }
1191
1192    #[test]
1193    fn a_bare_cfg_not_is_not_a_test_module() {
1194        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
1195        assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
1196    }
1197
1198    #[test]
1199    fn an_unreadable_unit_source_names_the_file() {
1200        let tree = TempTree::new(&[("src/widget.rs", "")]);
1201        std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
1202        let err = find_violations(tree.path(), tree.path()).unwrap_err();
1203        assert!(
1204            format!("{err:#}").contains("reading source file"),
1205            "got: {err:#}"
1206        );
1207    }
1208
1209    #[test]
1210    fn an_unparsable_unit_source_names_the_file() {
1211        let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
1212        let err = find_violations(tree.path(), tree.path()).unwrap_err();
1213        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1214    }
1215
1216    #[test]
1217    fn an_unreadable_integration_source_names_the_file() {
1218        let tree = TempTree::new(&[("tests/int.rs", "")]);
1219        std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
1220        let err = find_integration_violations(tree.path()).unwrap_err();
1221        assert!(
1222            format!("{err:#}").contains("reading source file"),
1223            "got: {err:#}"
1224        );
1225    }
1226
1227    #[test]
1228    fn an_unparsable_integration_source_names_the_file() {
1229        let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1230        let err = find_integration_violations(tree.path()).unwrap_err();
1231        assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1232    }
1233
1234    #[test]
1235    fn integration_violations_are_sorted_by_file_and_line() {
1236        let tree = TempTree::new(&[
1237            (
1238                "Cargo.toml",
1239                "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1240            ),
1241            (
1242                "tests/int.rs",
1243                "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1244            ),
1245        ]);
1246        let violations = find_integration_violations(tree.path()).unwrap();
1247        assert_eq!(violations.len(), 2, "got {violations:?}");
1248        assert!(violations[0].line < violations[1].line);
1249    }
1250
1251    #[test]
1252    fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1253        let tree = TempTree::new(&[("Cargo.toml", "")]);
1254        std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1255        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1256        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1257        assert!(first.contains("reading"), "got: {first}");
1258        assert!(external.contains("reading"), "got: {external}");
1259    }
1260
1261    #[test]
1262    fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1263        let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1264        let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1265        let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1266        assert!(first.contains("parsing"), "got: {first}");
1267        assert!(external.contains("parsing"), "got: {external}");
1268    }
1269
1270    #[test]
1271    fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1272        let tree = TempTree::new(&[(
1273            "Cargo.toml",
1274            "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1275        )]);
1276        let first = first_party_crates(tree.path()).unwrap();
1277        assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1278        assert!(external_deps(tree.path()).unwrap().is_empty());
1279    }
1280
1281    #[test]
1282    fn a_registry_only_dependency_table_feeds_external_deps() {
1283        let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1284        let external = external_deps(tree.path()).unwrap();
1285        assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1286    }
1287
1288    #[test]
1289    fn a_missing_root_is_an_error_for_integration_collection() {
1290        let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1291        assert!(
1292            format!("{err:#}").contains("reading directory"),
1293            "got: {err:#}"
1294        );
1295    }
1296
1297    #[test]
1298    fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1299        let source = "\
1300#[cfg(not(test))]
1301pub fn main() -> u8 {
1302    run()
1303}
1304
1305fn run() -> u8 {
1306    1
1307}
1308";
1309        assert_eq!(
1310            lines_hidden_from_tests(source),
1311            BTreeSet::from([1, 2, 3, 4])
1312        );
1313    }
1314
1315    #[test]
1316    fn a_gated_module_hides_everything_inside_it() {
1317        let source = "\
1318#[cfg(not(test))]
1319mod real {
1320    pub fn go() -> u8 {
1321        1
1322    }
1323}
1324";
1325        assert_eq!(
1326            lines_hidden_from_tests(source),
1327            BTreeSet::from([1, 2, 3, 4, 5, 6])
1328        );
1329    }
1330
1331    #[test]
1332    fn a_gated_method_hides_only_that_method() {
1333        let source = "\
1334impl Runner {
1335    #[cfg(not(test))]
1336    fn go(&self) -> u8 {
1337        1
1338    }
1339
1340    fn stay(&self) -> u8 {
1341        2
1342    }
1343}
1344";
1345        assert_eq!(
1346            lines_hidden_from_tests(source),
1347            BTreeSet::from([2, 3, 4, 5])
1348        );
1349    }
1350
1351    #[test]
1352    fn an_ungated_file_hides_nothing() {
1353        let source = "#[cfg(test)]\nmod tests {\n    fn t() {}\n}\n\nfn go() -> u8 {\n    1\n}\n";
1354
1355        assert!(lines_hidden_from_tests(source).is_empty());
1356    }
1357
1358    #[test]
1359    fn unparseable_source_hides_nothing() {
1360        assert!(lines_hidden_from_tests("fn go( {").is_empty());
1361    }
1362
1363    /// Whether `attr` on a plain function hides it from the test build.
1364    fn hides_under(attr: &str) -> bool {
1365        !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n    1\n}}\n")).is_empty()
1366    }
1367
1368    #[test]
1369    fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1370        assert!(hides_under("#[cfg(not(test))]"));
1371        assert!(hides_under("#[cfg(all(not(test), unix))]"));
1372        assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1373        assert!(hides_under("#[cfg(any())]"));
1374        assert!(hides_under("#[cfg(not(all()))]"));
1375    }
1376
1377    #[test]
1378    fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1379        assert!(!hides_under("#[cfg(test)]"));
1380        assert!(!hides_under("#[cfg(unix)]"));
1381        assert!(!hides_under("#[cfg(feature = \"x\")]"));
1382        assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1383        assert!(!hides_under("#[cfg(not(not(test)))]"));
1384        assert!(!hides_under("#[cfg(all())]"));
1385        assert!(!hides_under("#[inline]"));
1386    }
1387
1388    #[test]
1389    fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1390        assert!(!hides_under("#[cfg(not(unix))]"));
1391        assert!(!hides_under("#[cfg(all(test, unix))]"));
1392    }
1393
1394    #[test]
1395    fn a_malformed_gate_hides_nothing() {
1396        assert!(!hides_under("#[cfg(not())]"));
1397        assert!(!hides_under("#[cfg(nope(test))]"));
1398        assert!(!hides_under("#[cfg(not(test), unix)]"));
1399    }
1400}