1use std::collections::BTreeSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{anyhow, Context, Result};
9use syn::spanned::Spanned;
10use syn::visit::{self, Visit};
11
12pub use crate::violation::Violation;
13
14const RULE_CALL: &str = "no-out-of-module-call";
15const RULE_IMPORT: &str = "no-out-of-module-import";
16const RULE_DOUBLE: &str = "no-first-party-double";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
20pub enum Language {
21 #[value(name = "rust")]
23 Rust,
24 #[value(name = "typescript")]
27 TypeScript,
28 #[value(name = "python")]
31 Python,
32}
33
34pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
38 let root = root.as_ref();
39 let deps = external_deps(root)?;
40
41 let mut files = Vec::new();
42 crate::colocated_test::collect_rust_source_files(root, &mut files)?;
43 files.sort();
44
45 let mut violations = Vec::new();
46 for file in &files {
47 let source = std::fs::read_to_string(file)
48 .with_context(|| format!("reading source file `{}`", file.display()))?;
49 let ast = syn::parse_file(&source)
50 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
51 let mut visitor = IsolationVisitor {
52 file,
53 deps: &deps,
54 test_depth: 0,
55 violations: Vec::new(),
56 };
57 visitor.visit_file(&ast);
58 violations.append(&mut visitor.violations);
59 }
60
61 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
62 Ok(violations)
63}
64
65pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
69 let root = root.as_ref();
70 let first_party = first_party_crates(root)?;
71
72 let mut files = Vec::new();
73 collect_rust_files(root, &mut files)?;
74 files.retain(|file| is_integration_test(root, file));
75 files.sort();
76
77 let mut violations = Vec::new();
78 for file in &files {
79 let source = std::fs::read_to_string(file)
80 .with_context(|| format!("reading source file `{}`", file.display()))?;
81 let ast = syn::parse_file(&source)
82 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
83 let mut visitor = DoubleVisitor {
84 file,
85 first_party: &first_party,
86 violations: Vec::new(),
87 };
88 visitor.visit_file(&ast);
89 violations.append(&mut visitor.violations);
90 }
91
92 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
93 Ok(violations)
94}
95
96struct DoubleVisitor<'a> {
98 file: &'a Path,
99 first_party: &'a BTreeSet<String>,
100 violations: Vec<Violation>,
101}
102
103impl<'ast> Visit<'ast> for DoubleVisitor<'_> {
104 fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
105 if has_double_attr(&node.attrs) {
106 let mut imports = Vec::new();
107 flatten_use(&node.tree, &mut Vec::new(), &mut imports);
108 if let Some((segs, is_glob)) = imports.iter().find(|(segs, _)| {
109 segs.first()
110 .is_some_and(|root| self.first_party.contains(root))
111 }) {
112 self.violations.push(Violation {
113 file: self.file.to_path_buf(),
114 line: node.span().start().line,
115 rule: RULE_DOUBLE,
116 message: format!(
117 "integration test doubles first-party `{}` with `#[double]`; \
118 run first-party code for real — only external crates may be doubled",
119 render_use(segs, *is_glob),
120 ),
121 });
122 }
123 }
124 visit::visit_item_use(self, node);
125 }
126}
127
128fn has_double_attr(attrs: &[syn::Attribute]) -> bool {
130 attrs.iter().any(|attr| {
131 attr.path()
132 .segments
133 .last()
134 .is_some_and(|seg| seg.ident == "double")
135 })
136}
137
138fn first_party_crates(root: &Path) -> Result<BTreeSet<String>> {
142 let manifest = root.join("Cargo.toml");
143 let mut set = BTreeSet::new();
144 if !manifest.is_file() {
145 return Ok(set);
146 }
147 let text = std::fs::read_to_string(&manifest)
148 .with_context(|| format!("reading `{}`", manifest.display()))?;
149 let value: toml::Value =
150 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
151
152 if let Some(name) = value
153 .get("package")
154 .and_then(|package| package.get("name"))
155 .and_then(toml::Value::as_str)
156 {
157 set.insert(name.replace('-', "_"));
158 }
159 for table_name in ["dependencies", "dev-dependencies"] {
160 if let Some(table) = value.get(table_name).and_then(toml::Value::as_table) {
161 for (name, spec) in table {
162 if spec.as_table().is_some_and(|t| t.contains_key("path")) {
163 set.insert(name.replace('-', "_"));
164 }
165 }
166 }
167 }
168 Ok(set)
169}
170
171fn is_integration_test(root: &Path, file: &Path) -> bool {
175 file.strip_prefix(root)
176 .unwrap_or(file)
177 .components()
178 .any(|component| component.as_os_str() == "tests")
179}
180
181struct IsolationVisitor<'a> {
183 file: &'a Path,
184 deps: &'a BTreeSet<String>,
185 test_depth: usize,
186 violations: Vec<Violation>,
187}
188
189impl<'ast> Visit<'ast> for IsolationVisitor<'_> {
190 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
191 let is_test = has_cfg_test(&node.attrs);
192 if is_test {
193 self.test_depth += 1;
194 }
195 visit::visit_item_mod(self, node);
196 if is_test {
197 self.test_depth -= 1;
198 }
199 }
200
201 fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
202 if self.test_depth > 0 {
203 if let syn::Expr::Path(path_expr) = node.func.as_ref() {
204 if let Some(kind) = classify(&path_expr.path, self.deps) {
205 self.violations.push(Violation {
206 file: self.file.to_path_buf(),
207 line: node.span().start().line,
208 rule: RULE_CALL,
209 message: format!(
210 "unit test calls `{}` out of its own module ({kind}); \
211 inject a trait double — only `super::` is in-module",
212 render_path(&path_expr.path),
213 ),
214 });
215 }
216 }
217 }
218 visit::visit_expr_call(self, node);
219 }
220
221 fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
222 if self.test_depth > 0 {
223 let mut imports = Vec::new();
224 flatten_use(&node.tree, &mut Vec::new(), &mut imports);
225 for (segs, is_glob) in &imports {
226 if let Some(kind) = classify_use(segs, *is_glob, self.deps) {
227 self.violations.push(Violation {
228 file: self.file.to_path_buf(),
229 line: node.span().start().line,
230 rule: RULE_IMPORT,
231 message: format!(
232 "unit test imports `{}` out of its own module ({kind}); \
233 only `super::` (the unit) and pure `std` belong in a unit test",
234 render_use(segs, *is_glob),
235 ),
236 });
237 }
238 }
239 }
240 visit::visit_item_use(self, node);
241 }
242}
243
244fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
247 let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
248 match segs.first().map(String::as_str)? {
249 "self" | "Self" => None,
250 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
251 "crate" => Some("first-party module"),
252 "std" => is_effectful_std(&segs).then_some("effectful std"),
253 "core" | "alloc" => None,
255 other => deps.contains(other).then_some("external crate"),
257 }
258}
259
260fn is_effectful_std(segs: &[String]) -> bool {
264 match segs.get(1).map(String::as_str) {
265 Some("fs" | "net" | "process" | "env" | "thread" | "os") => true,
266 Some("io") => matches!(
267 segs.get(2).map(String::as_str),
268 Some("stdin" | "stdout" | "stderr")
269 ),
270 Some("time") => {
271 matches!(
272 segs.get(2).map(String::as_str),
273 Some("SystemTime" | "Instant")
274 ) && segs.get(3).map(String::as_str) == Some("now")
275 }
276 _ => false,
277 }
278}
279
280fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
283 match tree {
284 syn::UseTree::Path(path) => {
285 prefix.push(path.ident.to_string());
286 flatten_use(&path.tree, prefix, out);
287 prefix.pop();
288 }
289 syn::UseTree::Name(name) => {
290 let mut full = prefix.clone();
291 full.push(name.ident.to_string());
292 out.push((full, false));
293 }
294 syn::UseTree::Rename(rename) => {
295 let mut full = prefix.clone();
296 full.push(rename.ident.to_string());
297 out.push((full, false));
298 }
299 syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
300 syn::UseTree::Group(group) => {
301 for item in &group.items {
302 flatten_use(item, prefix, out);
303 }
304 }
305 }
306}
307
308fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
311 match segs.first().map(String::as_str)? {
312 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
313 "self" | "Self" => None,
314 "crate" => Some("first-party module"),
315 "std" if is_effectful_std(segs) => Some("effectful std"),
316 "std" | "core" | "alloc" => is_glob.then_some("glob import"),
318 other => {
319 if deps.contains(other) {
320 Some("external crate")
321 } else {
322 is_glob.then_some("glob import")
323 }
324 }
325 }
326}
327
328fn render_use(segs: &[String], is_glob: bool) -> String {
330 let mut out = segs.join("::");
331 if is_glob {
332 if !out.is_empty() {
333 out.push_str("::");
334 }
335 out.push('*');
336 }
337 out
338}
339
340fn render_path(path: &syn::Path) -> String {
342 let mut out = String::new();
343 if path.leading_colon.is_some() {
344 out.push_str("::");
345 }
346 for (i, seg) in path.segments.iter().enumerate() {
347 if i > 0 {
348 out.push_str("::");
349 }
350 out.push_str(&seg.ident.to_string());
351 }
352 out
353}
354
355pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
358 attrs.iter().any(|attr| {
359 attr.path().is_ident("cfg")
360 && attr
361 .meta
362 .require_list()
363 .map(|list| cfg_mentions_test(list.tokens.clone()))
364 .unwrap_or(false)
365 })
366}
367
368fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
371 cfg_requires_test(tokens, false)
372}
373
374fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
377 let mut iter = tokens.into_iter().peekable();
378 while let Some(tt) = iter.next() {
379 match tt {
380 proc_macro2::TokenTree::Ident(id) if id == "not" => {
381 if let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() {
383 let stream = group.stream();
384 iter.next();
385 if cfg_requires_test(stream, !negated) {
386 return true;
387 }
388 }
389 }
390 proc_macro2::TokenTree::Ident(id) => {
391 if !negated && id == "test" {
392 return true;
393 }
394 }
395 proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
396 return true;
397 }
398 _ => {}
399 }
400 }
401 false
402}
403
404fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
408 let manifest = root.join("Cargo.toml");
409 if !manifest.is_file() {
410 return Ok(BTreeSet::new());
411 }
412 let text = std::fs::read_to_string(&manifest)
413 .with_context(|| format!("reading `{}`", manifest.display()))?;
414 let value: toml::Value =
415 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
416 let mut deps = BTreeSet::new();
417 if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
418 for name in table.keys() {
419 deps.insert(name.replace('-', "_"));
420 }
421 }
422 Ok(deps)
423}
424
425fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
426 let entries =
427 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
428 for entry in entries {
429 let path = entry
430 .with_context(|| format!("reading an entry under `{}`", dir.display()))?
431 .path();
432 if path.is_dir() {
433 collect_rust_files(&path, out)?;
434 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
435 out.push(path);
436 }
437 }
438 Ok(())
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
447 let ast = syn::parse_file(src).expect("snippet parses");
448 let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
449 let mut visitor = IsolationVisitor {
450 file: Path::new("snippet.rs"),
451 deps: &dep_set,
452 test_depth: 0,
453 violations: Vec::new(),
454 };
455 visitor.visit_file(&ast);
456 visitor.violations
457 }
458
459 #[test]
460 fn flags_each_out_of_module_form() {
461 let src = "\
462#[cfg(test)]
463mod tests {
464 use super::*;
465 #[test]
466 fn t() {
467 let _ = crate::store::load();
468 let _ = std::fs::read(\"x\");
469 let _ = rand::random::<u8>();
470 let _ = super::super::util::help();
471 }
472}
473";
474 let violations = violations_in(src, &["rand"]);
475 assert_eq!(violations.len(), 4, "got {violations:?}");
476 assert!(violations.iter().all(|v| v.rule == RULE_CALL));
477 }
478
479 #[test]
480 fn allows_in_module_calls() {
481 let src = "\
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use std::io::Cursor;
486 #[test]
487 fn t() {
488 let _ = super::widget();
489 let _ = self::helper();
490 let _ = Cursor::new(b\"x\");
491 let _ = std::collections::HashMap::<u8, u8>::new();
492 assert_eq!(1, 1);
493 }
494}
495";
496 assert!(violations_in(src, &["rand"]).is_empty());
497 }
498
499 #[test]
500 fn ignores_calls_outside_test_modules() {
501 let src = "fn run() { let _ = crate::other::go(); }";
502 assert!(violations_in(src, &[]).is_empty());
503 }
504
505 #[test]
506 fn reports_the_call_line() {
507 let src = "\
509#[cfg(test)]
510mod tests {
511 fn t() {
512 let _ = crate::other::go();
513 }
514}
515";
516 let violations = violations_in(src, &[]);
517 assert_eq!(violations.len(), 1);
518 assert_eq!(violations[0].line, 4);
519 }
520
521 #[test]
522 fn effectful_std_policy() {
523 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
524 assert!(is_effectful_std(&segs("std::fs::read")));
525 assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
526 assert!(is_effectful_std(&segs("std::env::var")));
527 assert!(is_effectful_std(&segs("std::process::exit")));
528 assert!(is_effectful_std(&segs("std::thread::sleep")));
529 assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
530 assert!(is_effectful_std(&segs("std::io::stdout")));
531 assert!(!is_effectful_std(&segs("std::collections::HashMap")));
532 assert!(!is_effectful_std(&segs("std::io::Cursor")));
533 assert!(!is_effectful_std(&segs("std::time::Duration")));
534 assert!(!is_effectful_std(&segs("std::cmp::min")));
535 }
536
537 #[test]
538 fn classify_leading_segment() {
539 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
540 let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
541 assert_eq!(classify(&path("super::foo"), &deps), None);
542 assert_eq!(classify(&path("self::foo"), &deps), None);
543 assert_eq!(classify(&path("Local::new"), &deps), None);
544 assert_eq!(
545 classify(&path("super::super::foo"), &deps),
546 Some("ancestor module")
547 );
548 assert_eq!(
549 classify(&path("crate::a::b"), &deps),
550 Some("first-party module")
551 );
552 assert_eq!(
553 classify(&path("rand::random"), &deps),
554 Some("external crate")
555 );
556 assert_eq!(
557 classify(&path("std::fs::read"), &deps),
558 Some("effectful std")
559 );
560 assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
561 }
562
563 #[test]
564 fn recognizes_cfg_test_attribute() {
565 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
566 assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
567 assert!(has_cfg_test(
568 &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
569 ));
570 assert!(!has_cfg_test(
571 &module("#[cfg(feature = \"test\")] mod t {}").attrs
572 ));
573 assert!(!has_cfg_test(&module("mod t {}").attrs));
574 assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
575 assert!(!has_cfg_test(
576 &module("#[cfg(all(not(test), unix))] mod t {}").attrs
577 ));
578 assert!(!has_cfg_test(
579 &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
580 ));
581 assert!(has_cfg_test(
582 &module("#[cfg(not(not(test)))] mod t {}").attrs
583 ));
584 }
585
586 #[test]
587 fn flags_each_foreign_import() {
588 let src = "\
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use super::Thing;
593 use crate::other::*;
594 use crate::other::Named;
595 use rand::Rng;
596 use std::fs;
597 use std::collections::HashMap;
598 use std::io::Cursor;
599}
600";
601 let violations = violations_in(src, &["rand"]);
603 assert_eq!(violations.len(), 4, "got {violations:?}");
604 assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
605 }
606
607 #[test]
608 fn classify_use_roots() {
609 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
610 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
611 assert_eq!(classify_use(&segs("super"), true, &deps), None); assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
613 assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
614 assert_eq!(
615 classify_use(&segs("std::collections::HashMap"), false, &deps),
616 None
617 );
618 assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
619 assert_eq!(
620 classify_use(&segs("super::super"), true, &deps),
621 Some("ancestor module")
622 );
623 assert_eq!(
624 classify_use(&segs("crate::other"), true, &deps),
625 Some("first-party module")
626 );
627 assert_eq!(
628 classify_use(&segs("crate::other::Named"), false, &deps),
629 Some("first-party module")
630 );
631 assert_eq!(
632 classify_use(&segs("rand::Rng"), false, &deps),
633 Some("external crate")
634 );
635 assert_eq!(
636 classify_use(&segs("std::fs"), false, &deps),
637 Some("effectful std")
638 );
639 assert_eq!(
640 classify_use(&segs("std::collections"), true, &deps),
641 Some("glob import")
642 );
643 }
644
645 #[test]
646 fn imports_outside_test_modules_are_ignored() {
647 let src = "use crate::other::*; fn run() {}";
648 assert!(violations_in(src, &[]).is_empty());
649 }
650
651 fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
653 let ast = syn::parse_file(src).expect("snippet parses");
654 let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
655 let mut visitor = DoubleVisitor {
656 file: Path::new("integration.rs"),
657 first_party: &set,
658 violations: Vec::new(),
659 };
660 visitor.visit_file(&ast);
661 visitor.violations
662 }
663
664 #[test]
665 fn flags_double_of_first_party_only() {
666 let src = "\
667use mockall_double::double;
668#[double]
669use widget::Renderer;
670#[double]
671use rand::rngs::ThreadRng;
672#[double]
673use crate::support::Helper;
674";
675 let violations = integration_violations_in(src, &["widget"]);
677 assert_eq!(violations.len(), 1, "got {violations:?}");
678 assert_eq!(violations[0].rule, RULE_DOUBLE);
679 }
680
681 #[test]
682 fn ignores_use_without_double() {
683 let src = "use widget::Renderer; fn t() {}";
684 assert!(integration_violations_in(src, &["widget"]).is_empty());
685 }
686
687 #[test]
688 fn recognizes_double_attribute() {
689 let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
690 assert!(has_double_attr(&item("#[double] use a::B;").attrs));
691 assert!(has_double_attr(
692 &item("#[mockall_double::double] use a::B;").attrs
693 ));
694 assert!(!has_double_attr(
695 &item("#[allow(unused_imports)] use a::B;").attrs
696 ));
697 assert!(!has_double_attr(&item("use a::B;").attrs));
698 }
699}