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