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