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