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