Skip to main content

testing_conventions/
isolation.rs

1//! Rust unit-isolation lint (#44): an inline `#[cfg(test)] mod` may call only into
2//! the unit under test — its parent module, reached via `super::`. A call *out of
3//! the test's own module* — into another first-party module (`crate::…`), an
4//! external crate, or effectful `std` — is a violation. Inject a trait double
5//! (hand-rolled or `mockall`) instead; the compiler checks the double.
6//!
7//! Detection is AST-based: each `*.rs` file under the crate root is parsed with
8//! `syn` and its `#[cfg(test)]` modules are walked with a [`Visit`]or. This is the
9//! deterministic `syn` heuristic; full name-resolution precision is a future
10//! `dylint` pass. The design and its precision limits live in
11//! `internals/rust/isolation.md`.
12//!
13//! Implemented detector:
14//! - **`no-out-of-module-call`** (D1): a call expression `A::…::f(…)` inside a
15//!   `#[cfg(test)]` module whose leading segment `A` reaches out of the module —
16//!   `crate::` (first-party, another module), `super::super::…` (an ancestor),
17//!   an external crate from `Cargo.toml`, or effectful `std`. A single `super::`,
18//!   `self`/`Self`, a bare/unqualified call, and pure `std` (incl. `io::Cursor`)
19//!   stay in-module and are not flagged.
20
21use std::collections::BTreeSet;
22use std::path::{Path, PathBuf};
23
24use anyhow::{anyhow, Context, Result};
25use syn::spanned::Spanned;
26use syn::visit::{self, Visit};
27
28pub use crate::violation::Violation;
29
30/// Rule id reported for an out-of-module call.
31const RULE: &str = "no-out-of-module-call";
32
33/// A language whose unit-isolation convention can be checked. Rust only for now
34/// (Python #42 / TypeScript #43 are separate detectors).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
36pub enum Language {
37    /// Inline `#[cfg(test)]` modules in `*.rs` files.
38    #[value(name = "rust")]
39    Rust,
40}
41
42/// Scan the Rust source files under `root` and return every isolation violation,
43/// sorted by `(file, line)` for deterministic output.
44///
45/// `root` is the crate root: its `Cargo.toml` names the external crates whose
46/// calls are out-of-module. Every `*.rs` file under it is parsed; a file that
47/// cannot be read or parsed is an error.
48pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
49    let root = root.as_ref();
50    let deps = external_deps(root)?;
51
52    let mut files = Vec::new();
53    collect_rust_files(root, &mut files)?;
54    files.sort();
55
56    let mut violations = Vec::new();
57    for file in &files {
58        let source = std::fs::read_to_string(file)
59            .with_context(|| format!("reading source file `{}`", file.display()))?;
60        let ast = syn::parse_file(&source)
61            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
62        let mut visitor = IsolationVisitor {
63            file,
64            deps: &deps,
65            test_depth: 0,
66            violations: Vec::new(),
67        };
68        visitor.visit_file(&ast);
69        violations.append(&mut visitor.violations);
70    }
71
72    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
73    Ok(violations)
74}
75
76/// Walks one parsed file, flagging out-of-module calls inside `#[cfg(test)]`
77/// modules. `test_depth` counts how deep we are inside such modules, so a call in
78/// non-test code is ignored.
79struct IsolationVisitor<'a> {
80    file: &'a Path,
81    deps: &'a BTreeSet<String>,
82    test_depth: usize,
83    violations: Vec<Violation>,
84}
85
86impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
87    fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
88        let is_test = has_cfg_test(&node.attrs);
89        if is_test {
90            self.test_depth += 1;
91        }
92        visit::visit_item_mod(self, node);
93        if is_test {
94            self.test_depth -= 1;
95        }
96    }
97
98    fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
99        if self.test_depth > 0 {
100            if let syn::Expr::Path(path_expr) = node.func.as_ref() {
101                if let Some(kind) = classify(&path_expr.path, self.deps) {
102                    self.violations.push(Violation {
103                        file: self.file.to_path_buf(),
104                        line: node.span().start().line,
105                        rule: RULE,
106                        message: format!(
107                            "unit test calls `{}` out of its own module ({kind}); \
108                             inject a trait double — only `super::` is in-module",
109                            render_path(&path_expr.path),
110                        ),
111                    });
112                }
113            }
114        }
115        visit::visit_expr_call(self, node);
116    }
117}
118
119/// Why a call's leading path is out-of-module, or `None` when the call stays
120/// in-module (or is unresolvable, and so deliberately not flagged — the `syn`
121/// heuristic's documented limit).
122fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
123    let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
124    match segs.first().map(String::as_str)? {
125        // `self` / `Self` are local; a single `super::` is the unit under test.
126        "self" | "Self" => None,
127        "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
128        "crate" => Some("first-party module"),
129        "std" => is_effectful_std(&segs).then_some("effectful std"),
130        // `core`/`alloc` carry no effectful APIs.
131        "core" | "alloc" => None,
132        // Any other leading segment is in-module unless it names an external
133        // crate; a local type/fn (incl. `super::*`-imported) is not flagged.
134        other => deps.contains(other).then_some("external crate"),
135    }
136}
137
138/// `true` for an effectful `std` path — filesystem, network, process, env,
139/// threads, OS, the clock (`SystemTime::now` / `Instant::now`), or real-handle
140/// I/O (`stdin`/`stdout`/`stderr`). Pure std is allowed: `std::io::Cursor` and the
141/// I/O traits, `time::Duration`, `collections`, `fmt`, … — `internals/rust/`
142/// `testing.md` makes `Cursor` the idiomatic in-memory unit-test tool.
143fn is_effectful_std(segs: &[String]) -> bool {
144    match segs.get(1).map(String::as_str) {
145        Some("fs" | "net" | "process" | "env" | "thread" | "os") => true,
146        Some("io") => matches!(
147            segs.get(2).map(String::as_str),
148            Some("stdin" | "stdout" | "stderr")
149        ),
150        Some("time") => {
151            matches!(
152                segs.get(2).map(String::as_str),
153                Some("SystemTime" | "Instant")
154            ) && segs.get(3).map(String::as_str) == Some("now")
155        }
156        _ => false,
157    }
158}
159
160/// Render a path back to `a::b::c` for the message (idents only; generic args
161/// dropped).
162fn render_path(path: &syn::Path) -> String {
163    let mut out = String::new();
164    if path.leading_colon.is_some() {
165        out.push_str("::");
166    }
167    for (i, seg) in path.segments.iter().enumerate() {
168        if i > 0 {
169            out.push_str("::");
170        }
171        out.push_str(&seg.ident.to_string());
172    }
173    out
174}
175
176/// `true` when `attrs` carries a `#[cfg(test)]` gate (including `cfg(all(test, …))`
177/// / `cfg(any(test, …))`) — the signal for an inline unit-test module.
178fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
179    attrs.iter().any(|attr| {
180        attr.path().is_ident("cfg")
181            && attr
182                .meta
183                .require_list()
184                .map(|list| cfg_mentions_test(list.tokens.clone()))
185                .unwrap_or(false)
186    })
187}
188
189/// `true` when a `cfg(...)` token stream contains a bare `test` ident (recursing
190/// into `all(...)` / `any(...)` groups). A `feature = "test"` string literal does
191/// not count.
192fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
193    tokens.into_iter().any(|tt| match tt {
194        proc_macro2::TokenTree::Ident(id) => id == "test",
195        proc_macro2::TokenTree::Group(group) => cfg_mentions_test(group.stream()),
196        _ => false,
197    })
198}
199
200/// The crate's normal `[dependencies]` names (hyphens normalized to underscores,
201/// the form used in paths) — the external crates whose calls are out-of-module.
202/// `[dev-dependencies]` are test tooling (`mockall`, `rstest`, …) and are
203/// deliberately excluded: a unit test uses its framework for real. Returns an
204/// empty set when there is no `Cargo.toml` at `root`.
205fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
206    let manifest = root.join("Cargo.toml");
207    if !manifest.is_file() {
208        return Ok(BTreeSet::new());
209    }
210    let text = std::fs::read_to_string(&manifest)
211        .with_context(|| format!("reading `{}`", manifest.display()))?;
212    let value: toml::Value =
213        toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
214    let mut deps = BTreeSet::new();
215    if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
216        for name in table.keys() {
217            deps.insert(name.replace('-', "_"));
218        }
219    }
220    Ok(deps)
221}
222
223/// Recursively collect every `*.rs` file under `dir` into `out`.
224fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
225    let entries =
226        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
227    for entry in entries {
228        let path = entry
229            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
230            .path();
231        if path.is_dir() {
232            collect_rust_files(&path, out)?;
233        } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
234            out.push(path);
235        }
236    }
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// Run the visitor over a source snippet with the given external-crate deps.
245    fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
246        let ast = syn::parse_file(src).expect("snippet parses");
247        let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
248        let mut visitor = IsolationVisitor {
249            file: Path::new("snippet.rs"),
250            deps: &dep_set,
251            test_depth: 0,
252            violations: Vec::new(),
253        };
254        visitor.visit_file(&ast);
255        visitor.violations
256    }
257
258    #[test]
259    fn flags_each_out_of_module_form() {
260        let src = "\
261#[cfg(test)]
262mod tests {
263    use super::*;
264    #[test]
265    fn t() {
266        let _ = crate::store::load();
267        let _ = std::fs::read(\"x\");
268        let _ = rand::random::<u8>();
269        let _ = super::super::util::help();
270    }
271}
272";
273        let violations = violations_in(src, &["rand"]);
274        assert_eq!(violations.len(), 4, "got {violations:?}");
275        assert!(violations.iter().all(|v| v.rule == RULE));
276    }
277
278    #[test]
279    fn allows_in_module_calls() {
280        let src = "\
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::io::Cursor;
285    #[test]
286    fn t() {
287        let _ = super::widget();
288        let _ = self::helper();
289        let _ = Cursor::new(b\"x\");
290        let _ = std::collections::HashMap::<u8, u8>::new();
291        assert_eq!(1, 1);
292    }
293}
294";
295        assert!(violations_in(src, &["rand"]).is_empty());
296    }
297
298    #[test]
299    fn ignores_calls_outside_test_modules() {
300        let src = "fn run() { let _ = crate::other::go(); }";
301        assert!(violations_in(src, &[]).is_empty());
302    }
303
304    #[test]
305    fn reports_the_call_line() {
306        // Line 1 is `#[cfg(test)]`; the flagged call sits on line 4.
307        let src = "\
308#[cfg(test)]
309mod tests {
310    fn t() {
311        let _ = crate::other::go();
312    }
313}
314";
315        let violations = violations_in(src, &[]);
316        assert_eq!(violations.len(), 1);
317        assert_eq!(violations[0].line, 4);
318    }
319
320    #[test]
321    fn effectful_std_policy() {
322        let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
323        // effectful — flagged
324        assert!(is_effectful_std(&segs("std::fs::read")));
325        assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
326        assert!(is_effectful_std(&segs("std::env::var")));
327        assert!(is_effectful_std(&segs("std::process::exit")));
328        assert!(is_effectful_std(&segs("std::thread::sleep")));
329        assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
330        assert!(is_effectful_std(&segs("std::io::stdout")));
331        // pure — allowed
332        assert!(!is_effectful_std(&segs("std::collections::HashMap")));
333        assert!(!is_effectful_std(&segs("std::io::Cursor")));
334        assert!(!is_effectful_std(&segs("std::time::Duration")));
335        assert!(!is_effectful_std(&segs("std::cmp::min")));
336    }
337
338    #[test]
339    fn classify_leading_segment() {
340        let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
341        let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
342        assert_eq!(classify(&path("super::foo"), &deps), None);
343        assert_eq!(classify(&path("self::foo"), &deps), None);
344        assert_eq!(classify(&path("Local::new"), &deps), None);
345        assert_eq!(
346            classify(&path("super::super::foo"), &deps),
347            Some("ancestor module")
348        );
349        assert_eq!(
350            classify(&path("crate::a::b"), &deps),
351            Some("first-party module")
352        );
353        assert_eq!(
354            classify(&path("rand::random"), &deps),
355            Some("external crate")
356        );
357        assert_eq!(
358            classify(&path("std::fs::read"), &deps),
359            Some("effectful std")
360        );
361        assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
362    }
363
364    #[test]
365    fn recognizes_cfg_test_attribute() {
366        let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
367        assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
368        assert!(has_cfg_test(
369            &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
370        ));
371        assert!(!has_cfg_test(
372            &module("#[cfg(feature = \"test\")] mod t {}").attrs
373        ));
374        assert!(!has_cfg_test(&module("mod t {}").attrs));
375    }
376}