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 {
270 match segs.get(1).map(String::as_str) {
271 Some("net" | "process" | "thread" | "os") => true,
272 Some("env") => segs.get(2).map(String::as_str) != Some("temp_dir"),
273 Some("io") => matches!(
274 segs.get(2).map(String::as_str),
275 Some("stdin" | "stdout" | "stderr")
276 ),
277 Some("time") => {
278 matches!(
279 segs.get(2).map(String::as_str),
280 Some("SystemTime" | "Instant")
281 ) && segs.get(3).map(String::as_str) == Some("now")
282 }
283 _ => false,
284 }
285}
286
287fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
290 match tree {
291 syn::UseTree::Path(path) => {
292 prefix.push(path.ident.to_string());
293 flatten_use(&path.tree, prefix, out);
294 prefix.pop();
295 }
296 syn::UseTree::Name(name) => {
297 let mut full = prefix.clone();
298 full.push(name.ident.to_string());
299 out.push((full, false));
300 }
301 syn::UseTree::Rename(rename) => {
302 let mut full = prefix.clone();
303 full.push(rename.ident.to_string());
304 out.push((full, false));
305 }
306 syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
307 syn::UseTree::Group(group) => {
308 for item in &group.items {
309 flatten_use(item, prefix, out);
310 }
311 }
312 }
313}
314
315fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
318 match segs.first().map(String::as_str)? {
319 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
320 "self" | "Self" => None,
321 "crate" => Some("first-party module"),
322 "std" if is_effectful_std(segs) => Some("effectful std"),
323 "std" | "core" | "alloc" => is_glob.then_some("glob import"),
325 other => {
326 if deps.contains(other) {
327 Some("external crate")
328 } else {
329 is_glob.then_some("glob import")
330 }
331 }
332 }
333}
334
335fn render_use(segs: &[String], is_glob: bool) -> String {
337 let mut out = segs.join("::");
338 if is_glob {
339 if !out.is_empty() {
340 out.push_str("::");
341 }
342 out.push('*');
343 }
344 out
345}
346
347fn render_path(path: &syn::Path) -> String {
349 let mut out = String::new();
350 if path.leading_colon.is_some() {
351 out.push_str("::");
352 }
353 for (i, seg) in path.segments.iter().enumerate() {
354 if i > 0 {
355 out.push_str("::");
356 }
357 out.push_str(&seg.ident.to_string());
358 }
359 out
360}
361
362pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
365 attrs.iter().any(|attr| {
366 attr.path().is_ident("cfg")
367 && attr
368 .meta
369 .require_list()
370 .map(|list| cfg_mentions_test(list.tokens.clone()))
371 .unwrap_or(false)
372 })
373}
374
375fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
378 cfg_requires_test(tokens, false)
379}
380
381fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
384 let mut iter = tokens.into_iter().peekable();
385 while let Some(tt) = iter.next() {
386 match tt {
387 proc_macro2::TokenTree::Ident(id) if id == "not" => {
388 if let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() {
390 let stream = group.stream();
391 iter.next();
392 if cfg_requires_test(stream, !negated) {
393 return true;
394 }
395 }
396 }
397 proc_macro2::TokenTree::Ident(id) => {
398 if !negated && id == "test" {
399 return true;
400 }
401 }
402 proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
403 return true;
404 }
405 _ => {}
406 }
407 }
408 false
409}
410
411pub(crate) fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
418 let Ok(ast) = syn::parse_file(source) else {
419 return BTreeSet::new();
420 };
421 let mut hidden = HiddenItems::default();
422 hidden.visit_file(&ast);
423 hidden.lines
424}
425
426#[derive(Default)]
429struct HiddenItems {
430 lines: BTreeSet<u32>,
431}
432
433impl HiddenItems {
434 fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
435 if !has_cfg_not_test(attrs) {
436 return;
437 }
438 let span = node.span();
439 self.lines
440 .extend(span.start().line as u32..=span.end().line as u32);
441 }
442}
443
444impl<'ast> Visit<'ast> for HiddenItems {
445 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
446 self.gated(&node.attrs, node);
447 visit::visit_item_fn(self, node);
448 }
449
450 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
451 self.gated(&node.attrs, node);
452 visit::visit_item_mod(self, node);
453 }
454
455 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
456 self.gated(&node.attrs, node);
457 visit::visit_item_impl(self, node);
458 }
459
460 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
461 self.gated(&node.attrs, node);
462 visit::visit_impl_item_fn(self, node);
463 }
464}
465
466pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
470 attrs.iter().any(|attr| {
471 attr.path().is_ident("cfg")
472 && attr
473 .meta
474 .require_list()
475 .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
476 .unwrap_or(false)
477 })
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483enum CfgTruth {
484 False,
485 True,
486 Unknown,
487}
488
489fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
492 match cfg_predicates(tokens).as_slice() {
493 [only] => *only,
494 _ => CfgTruth::Unknown,
495 }
496}
497
498fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
500 let mut out = Vec::new();
501 let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
502 for tt in tokens {
503 match &tt {
504 proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
505 if !current.is_empty() {
506 out.push(cfg_predicate(¤t));
507 current.clear();
508 }
509 }
510 _ => current.push(tt),
511 }
512 }
513 if !current.is_empty() {
514 out.push(cfg_predicate(¤t));
515 }
516 out
517}
518
519fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
523 use proc_macro2::TokenTree;
524 match tokens {
525 [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
526 [TokenTree::Ident(id), TokenTree::Group(group)] => {
527 let inner = cfg_predicates(group.stream());
528 match id.to_string().as_str() {
529 "not" => match inner.as_slice() {
531 [only] => cfg_negate(*only),
532 _ => CfgTruth::Unknown,
533 },
534 "all" => cfg_all(&inner),
535 "any" => cfg_any(&inner),
536 _ => CfgTruth::Unknown,
537 }
538 }
539 _ => CfgTruth::Unknown,
540 }
541}
542
543fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
545 if parts.contains(&CfgTruth::False) {
546 CfgTruth::False
547 } else if parts.contains(&CfgTruth::Unknown) {
548 CfgTruth::Unknown
549 } else {
550 CfgTruth::True
551 }
552}
553
554fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
556 if parts.contains(&CfgTruth::True) {
557 CfgTruth::True
558 } else if parts.contains(&CfgTruth::Unknown) {
559 CfgTruth::Unknown
560 } else {
561 CfgTruth::False
562 }
563}
564
565fn cfg_negate(truth: CfgTruth) -> CfgTruth {
567 match truth {
568 CfgTruth::False => CfgTruth::True,
569 CfgTruth::True => CfgTruth::False,
570 CfgTruth::Unknown => CfgTruth::Unknown,
571 }
572}
573
574fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
578 let manifest = root.join("Cargo.toml");
579 if !manifest.is_file() {
580 return Ok(BTreeSet::new());
581 }
582 let text = std::fs::read_to_string(&manifest)
583 .with_context(|| format!("reading `{}`", manifest.display()))?;
584 let value: toml::Value =
585 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
586 let mut deps = BTreeSet::new();
587 if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
588 for name in table.keys() {
589 deps.insert(name.replace('-', "_"));
590 }
591 }
592 Ok(deps)
593}
594
595fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
596 let entries =
597 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
598 for entry in entries {
599 let path = crate::walk::dir_entry(entry, dir)?.path();
600 if path.is_dir() {
601 collect_rust_files(&path, out)?;
602 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
603 out.push(path);
604 }
605 }
606 Ok(())
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use std::sync::atomic::{AtomicU64, Ordering};
613
614 fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
616 let ast = syn::parse_file(src).expect("snippet parses");
617 let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
618 let mut visitor = IsolationVisitor {
619 file: Path::new("snippet.rs"),
620 deps: &dep_set,
621 test_depth: 0,
622 violations: Vec::new(),
623 };
624 visitor.visit_file(&ast);
625 visitor.violations
626 }
627
628 #[test]
629 fn flags_each_out_of_module_form() {
630 let src = "\
631#[cfg(test)]
632mod tests {
633 use super::*;
634 #[test]
635 fn t() {
636 let _ = crate::store::load();
637 let _ = std::net::TcpStream::connect(\"x\");
638 let _ = rand::random::<u8>();
639 let _ = super::super::util::help();
640 }
641}
642";
643 let violations = violations_in(src, &["rand"]);
644 assert_eq!(violations.len(), 4, "got {violations:?}");
645 assert!(violations.iter().all(|v| v.rule == RULE_CALL));
646 }
647
648 #[test]
649 fn allows_in_module_calls() {
650 let src = "\
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use std::io::Cursor;
655 #[test]
656 fn t() {
657 let _ = super::widget();
658 let _ = self::helper();
659 let _ = Cursor::new(b\"x\");
660 let _ = std::collections::HashMap::<u8, u8>::new();
661 assert_eq!(1, 1);
662 }
663}
664";
665 assert!(violations_in(src, &["rand"]).is_empty());
666 }
667
668 #[test]
669 fn ignores_calls_outside_test_modules() {
670 let src = "fn run() { let _ = crate::other::go(); }";
671 assert!(violations_in(src, &[]).is_empty());
672 }
673
674 #[test]
675 fn reports_the_call_line() {
676 let src = "\
678#[cfg(test)]
679mod tests {
680 fn t() {
681 let _ = crate::other::go();
682 }
683}
684";
685 let violations = violations_in(src, &[]);
686 assert_eq!(violations.len(), 1);
687 assert_eq!(violations[0].line, 4);
688 }
689
690 #[test]
691 fn effectful_std_policy() {
692 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
693 assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
694 assert!(is_effectful_std(&segs("std::env::var")));
695 assert!(is_effectful_std(&segs("std::env")));
696 assert!(is_effectful_std(&segs("std::process::exit")));
697 assert!(is_effectful_std(&segs("std::thread::sleep")));
698 assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
699 assert!(is_effectful_std(&segs("std::io::stdout")));
700 assert!(!is_effectful_std(&segs("std::fs::read")));
701 assert!(!is_effectful_std(&segs("std::fs")));
702 assert!(!is_effectful_std(&segs("std::env::temp_dir")));
703 assert!(!is_effectful_std(&segs("std::collections::HashMap")));
704 assert!(!is_effectful_std(&segs("std::io::Cursor")));
705 assert!(!is_effectful_std(&segs("std::time::Duration")));
706 assert!(!is_effectful_std(&segs("std::cmp::min")));
707 }
708
709 #[test]
710 fn classify_leading_segment() {
711 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
712 let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
713 assert_eq!(classify(&path("super::foo"), &deps), None);
714 assert_eq!(classify(&path("self::foo"), &deps), None);
715 assert_eq!(classify(&path("Local::new"), &deps), None);
716 assert_eq!(
717 classify(&path("super::super::foo"), &deps),
718 Some("ancestor module")
719 );
720 assert_eq!(
721 classify(&path("crate::a::b"), &deps),
722 Some("first-party module")
723 );
724 assert_eq!(
725 classify(&path("rand::random"), &deps),
726 Some("external crate")
727 );
728 assert_eq!(
729 classify(&path("std::net::TcpStream::connect"), &deps),
730 Some("effectful std")
731 );
732 assert_eq!(classify(&path("std::fs::read"), &deps), None);
733 assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
734 }
735
736 #[test]
737 fn recognizes_cfg_test_attribute() {
738 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
739 assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
740 assert!(has_cfg_test(
741 &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
742 ));
743 assert!(!has_cfg_test(
744 &module("#[cfg(feature = \"test\")] mod t {}").attrs
745 ));
746 assert!(!has_cfg_test(&module("mod t {}").attrs));
747 assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
748 assert!(!has_cfg_test(
749 &module("#[cfg(all(not(test), unix))] mod t {}").attrs
750 ));
751 assert!(!has_cfg_test(
752 &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
753 ));
754 assert!(has_cfg_test(
755 &module("#[cfg(not(not(test)))] mod t {}").attrs
756 ));
757 }
758
759 #[test]
760 fn flags_each_foreign_import() {
761 let src = "\
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use super::Thing;
766 use crate::other::*;
767 use crate::other::Named;
768 use rand::Rng;
769 use std::net;
770 use std::fs;
771 use std::collections::HashMap;
772 use std::io::Cursor;
773}
774";
775 let violations = violations_in(src, &["rand"]);
778 assert_eq!(violations.len(), 4, "got {violations:?}");
779 assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
780 }
781
782 #[test]
783 fn classify_use_roots() {
784 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
785 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
786 assert_eq!(classify_use(&segs("super"), true, &deps), None); assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
788 assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
789 assert_eq!(
790 classify_use(&segs("std::collections::HashMap"), false, &deps),
791 None
792 );
793 assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
794 assert_eq!(
795 classify_use(&segs("super::super"), true, &deps),
796 Some("ancestor module")
797 );
798 assert_eq!(
799 classify_use(&segs("crate::other"), true, &deps),
800 Some("first-party module")
801 );
802 assert_eq!(
803 classify_use(&segs("crate::other::Named"), false, &deps),
804 Some("first-party module")
805 );
806 assert_eq!(
807 classify_use(&segs("rand::Rng"), false, &deps),
808 Some("external crate")
809 );
810 assert_eq!(
811 classify_use(&segs("std::net"), false, &deps),
812 Some("effectful std")
813 );
814 assert_eq!(classify_use(&segs("std::fs"), false, &deps), None);
815 assert_eq!(
816 classify_use(&segs("std::collections"), true, &deps),
817 Some("glob import")
818 );
819 }
820
821 #[test]
822 fn imports_outside_test_modules_are_ignored() {
823 let src = "use crate::other::*; fn run() {}";
824 assert!(violations_in(src, &[]).is_empty());
825 }
826
827 fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
829 let ast = syn::parse_file(src).expect("snippet parses");
830 let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
831 let mut visitor = DoubleVisitor {
832 file: Path::new("integration.rs"),
833 first_party: &set,
834 violations: Vec::new(),
835 };
836 visitor.visit_file(&ast);
837 visitor.violations
838 }
839
840 #[test]
841 fn flags_double_of_first_party_only() {
842 let src = "\
843use mockall_double::double;
844#[double]
845use widget::Renderer;
846#[double]
847use rand::rngs::ThreadRng;
848#[double]
849use crate::support::Helper;
850";
851 let violations = integration_violations_in(src, &["widget"]);
853 assert_eq!(violations.len(), 1, "got {violations:?}");
854 assert_eq!(violations[0].rule, RULE_DOUBLE);
855 }
856
857 #[test]
858 fn ignores_use_without_double() {
859 let src = "use widget::Renderer; fn t() {}";
860 assert!(integration_violations_in(src, &["widget"]).is_empty());
861 }
862
863 #[test]
864 fn recognizes_double_attribute() {
865 let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
866 assert!(has_double_attr(&item("#[double] use a::B;").attrs));
867 assert!(has_double_attr(
868 &item("#[mockall_double::double] use a::B;").attrs
869 ));
870 assert!(!has_double_attr(
871 &item("#[allow(unused_imports)] use a::B;").attrs
872 ));
873 assert!(!has_double_attr(&item("use a::B;").attrs));
874 }
875
876 struct TempTree(PathBuf);
877
878 impl TempTree {
879 fn new(files: &[(&str, &str)]) -> Self {
880 static COUNTER: AtomicU64 = AtomicU64::new(0);
881 let root = std::env::temp_dir().join(format!(
882 "tc-isolation-{}-{}",
883 std::process::id(),
884 COUNTER.fetch_add(1, Ordering::Relaxed),
885 ));
886 for (rel, content) in files {
887 let path = root.join(rel);
888 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
889 std::fs::write(path, content).unwrap();
890 }
891 std::fs::create_dir_all(&root).unwrap();
892 TempTree(root)
893 }
894
895 fn path(&self) -> &Path {
896 &self.0
897 }
898 }
899
900 impl Drop for TempTree {
901 fn drop(&mut self) {
902 let _ = std::fs::remove_dir_all(&self.0);
903 }
904 }
905
906 #[test]
907 fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
908 let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
909 assert!(first_party_crates(tree.path()).unwrap().is_empty());
910 assert!(external_deps(tree.path()).unwrap().is_empty());
911 }
912
913 #[test]
914 fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
915 let tree = TempTree::new(&[(
916 "Cargo.toml",
917 "[package]\n\
918 name = \"my-crate\"\n\n\
919 [dependencies]\n\
920 sibling-lib = { path = \"../sibling-lib\" }\n\
921 rand = \"0.8\"\n\n\
922 [dev-dependencies]\n\
923 test-support = { path = \"../test-support\" }\n\
924 mockall = \"0.13\"\n",
925 )]);
926
927 let first_party = first_party_crates(tree.path()).unwrap();
928 assert_eq!(
929 first_party,
930 ["my_crate", "sibling_lib", "test_support"]
931 .iter()
932 .map(|s| (*s).to_string())
933 .collect::<BTreeSet<String>>(),
934 "the crate's own name and every path dep, hyphens normalized"
935 );
936
937 let external = external_deps(tree.path()).unwrap();
938 assert_eq!(
939 external,
940 ["rand", "sibling_lib"]
941 .iter()
942 .map(|s| (*s).to_string())
943 .collect::<BTreeSet<String>>(),
944 "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
945 );
946 }
947
948 #[test]
949 fn a_call_through_a_non_path_callee_is_left_alone() {
950 let src = "\
951#[cfg(test)]
952mod tests {
953 #[test]
954 fn t() {
955 let _ = (make())(1);
956 }
957}
958";
959 assert!(
960 violations_in(src, &["rand"]).is_empty(),
961 "a callee that is not a path carries no leading segment to classify"
962 );
963 }
964
965 #[test]
966 fn a_renamed_import_is_judged_by_its_source_path() {
967 let src = "\
968#[cfg(test)]
969mod tests {
970 use crate::other::Thing as Local;
971 use super::Widget as W;
972}
973";
974 let violations = violations_in(src, &[]);
975 assert_eq!(violations.len(), 1, "got {violations:?}");
976 let m = &violations[0].message;
977 assert!(
978 m.contains("crate::other::Thing"),
979 "the message names the source path, not the alias: {m}"
980 );
981 }
982
983 #[test]
984 fn a_grouped_import_is_flattened_leaf_by_leaf() {
985 let src = "\
986#[cfg(test)]
987mod tests {
988 use crate::other::{Named, deeper::Other};
989 use super::{Widget, helper};
990}
991";
992 let violations = violations_in(src, &[]);
993 assert_eq!(violations.len(), 2, "got {violations:?}");
994 let (first, second) = (&violations[0].message, &violations[1].message);
995 assert!(first.contains("crate::other::Named"), "{first}");
996 assert!(second.contains("crate::other::deeper::Other"), "{second}");
997 }
998
999 #[test]
1000 fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
1001 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
1002 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
1003 assert_eq!(
1004 classify_use(&segs("helpers"), true, &deps),
1005 Some("glob import"),
1006 "a glob is foreign even when `syn` cannot resolve its root"
1007 );
1008 assert_eq!(
1009 classify_use(&segs("helpers::Thing"), false, &deps),
1010 None,
1011 "a named import of an unresolvable root is the heuristic's documented limit"
1012 );
1013 }
1014
1015 #[test]
1016 fn a_leading_colon_survives_into_the_message() {
1017 let src = "\
1018#[cfg(test)]
1019mod tests {
1020 #[test]
1021 fn t() {
1022 let _ = ::std::net::TcpStream::connect(\"x\");
1023 }
1024}
1025";
1026 let violations = violations_in(src, &[]);
1027 assert_eq!(violations.len(), 1, "got {violations:?}");
1028 let m = &violations[0].message;
1029 assert!(m.contains("`::std::net::TcpStream::connect`"), "{m}");
1030 }
1031
1032 #[test]
1033 fn a_bare_cfg_not_is_not_a_test_module() {
1034 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
1035 assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
1036 }
1037
1038 #[test]
1039 fn an_unreadable_unit_source_names_the_file() {
1040 let tree = TempTree::new(&[("src/widget.rs", "")]);
1041 std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
1042 let err = find_violations(tree.path()).unwrap_err();
1043 assert!(
1044 format!("{err:#}").contains("reading source file"),
1045 "got: {err:#}"
1046 );
1047 }
1048
1049 #[test]
1050 fn an_unparsable_unit_source_names_the_file() {
1051 let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
1052 let err = find_violations(tree.path()).unwrap_err();
1053 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1054 }
1055
1056 #[test]
1057 fn an_unreadable_integration_source_names_the_file() {
1058 let tree = TempTree::new(&[("tests/int.rs", "")]);
1059 std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
1060 let err = find_integration_violations(tree.path()).unwrap_err();
1061 assert!(
1062 format!("{err:#}").contains("reading source file"),
1063 "got: {err:#}"
1064 );
1065 }
1066
1067 #[test]
1068 fn an_unparsable_integration_source_names_the_file() {
1069 let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1070 let err = find_integration_violations(tree.path()).unwrap_err();
1071 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1072 }
1073
1074 #[test]
1075 fn integration_violations_are_sorted_by_file_and_line() {
1076 let tree = TempTree::new(&[
1077 (
1078 "Cargo.toml",
1079 "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1080 ),
1081 (
1082 "tests/int.rs",
1083 "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1084 ),
1085 ]);
1086 let violations = find_integration_violations(tree.path()).unwrap();
1087 assert_eq!(violations.len(), 2, "got {violations:?}");
1088 assert!(violations[0].line < violations[1].line);
1089 }
1090
1091 #[test]
1092 fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1093 let tree = TempTree::new(&[("Cargo.toml", "")]);
1094 std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1095 let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1096 let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1097 assert!(first.contains("reading"), "got: {first}");
1098 assert!(external.contains("reading"), "got: {external}");
1099 }
1100
1101 #[test]
1102 fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1103 let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1104 let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1105 let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1106 assert!(first.contains("parsing"), "got: {first}");
1107 assert!(external.contains("parsing"), "got: {external}");
1108 }
1109
1110 #[test]
1111 fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1112 let tree = TempTree::new(&[(
1113 "Cargo.toml",
1114 "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1115 )]);
1116 let first = first_party_crates(tree.path()).unwrap();
1117 assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1118 assert!(external_deps(tree.path()).unwrap().is_empty());
1119 }
1120
1121 #[test]
1122 fn a_registry_only_dependency_table_feeds_external_deps() {
1123 let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1124 let external = external_deps(tree.path()).unwrap();
1125 assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1126 }
1127
1128 #[test]
1129 fn a_missing_root_is_an_error_for_integration_collection() {
1130 let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1131 assert!(
1132 format!("{err:#}").contains("reading directory"),
1133 "got: {err:#}"
1134 );
1135 }
1136
1137 #[test]
1138 fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1139 let source = "\
1140#[cfg(not(test))]
1141pub fn main() -> u8 {
1142 run()
1143}
1144
1145fn run() -> u8 {
1146 1
1147}
1148";
1149 assert_eq!(
1150 lines_hidden_from_tests(source),
1151 BTreeSet::from([1, 2, 3, 4])
1152 );
1153 }
1154
1155 #[test]
1156 fn a_gated_module_hides_everything_inside_it() {
1157 let source = "\
1158#[cfg(not(test))]
1159mod real {
1160 pub fn go() -> u8 {
1161 1
1162 }
1163}
1164";
1165 assert_eq!(
1166 lines_hidden_from_tests(source),
1167 BTreeSet::from([1, 2, 3, 4, 5, 6])
1168 );
1169 }
1170
1171 #[test]
1172 fn a_gated_method_hides_only_that_method() {
1173 let source = "\
1174impl Runner {
1175 #[cfg(not(test))]
1176 fn go(&self) -> u8 {
1177 1
1178 }
1179
1180 fn stay(&self) -> u8 {
1181 2
1182 }
1183}
1184";
1185 assert_eq!(
1186 lines_hidden_from_tests(source),
1187 BTreeSet::from([2, 3, 4, 5])
1188 );
1189 }
1190
1191 #[test]
1192 fn an_ungated_file_hides_nothing() {
1193 let source = "#[cfg(test)]\nmod tests {\n fn t() {}\n}\n\nfn go() -> u8 {\n 1\n}\n";
1194
1195 assert!(lines_hidden_from_tests(source).is_empty());
1196 }
1197
1198 #[test]
1199 fn unparseable_source_hides_nothing() {
1200 assert!(lines_hidden_from_tests("fn go( {").is_empty());
1201 }
1202
1203 fn hides_under(attr: &str) -> bool {
1205 !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n 1\n}}\n")).is_empty()
1206 }
1207
1208 #[test]
1209 fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1210 assert!(hides_under("#[cfg(not(test))]"));
1211 assert!(hides_under("#[cfg(all(not(test), unix))]"));
1212 assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1213 assert!(hides_under("#[cfg(any())]"));
1214 assert!(hides_under("#[cfg(not(all()))]"));
1215 }
1216
1217 #[test]
1218 fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1219 assert!(!hides_under("#[cfg(test)]"));
1220 assert!(!hides_under("#[cfg(unix)]"));
1221 assert!(!hides_under("#[cfg(feature = \"x\")]"));
1222 assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1223 assert!(!hides_under("#[cfg(not(not(test)))]"));
1224 assert!(!hides_under("#[cfg(all())]"));
1225 assert!(!hides_under("#[inline]"));
1226 }
1227
1228 #[test]
1229 fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1230 assert!(!hides_under("#[cfg(not(unix))]"));
1231 assert!(!hides_under("#[cfg(all(test, unix))]"));
1232 }
1233
1234 #[test]
1235 fn a_malformed_gate_hides_nothing() {
1236 assert!(!hides_under("#[cfg(not())]"));
1237 assert!(!hides_under("#[cfg(nope(test))]"));
1238 assert!(!hides_under("#[cfg(not(test), unix)]"));
1239 }
1240}