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