1use std::collections::BTreeSet;
5use std::iter::Peekable;
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(
39 scan_root: impl AsRef<Path>,
40 crate_root: impl AsRef<Path>,
41) -> Result<Vec<Violation>> {
42 let root = scan_root.as_ref();
43 let deps = external_deps(crate_root.as_ref())?;
44
45 let mut files = Vec::new();
46 crate::colocated_test::collect_rust_source_files(root, &mut files)?;
47 files.sort();
48
49 let mut violations = Vec::new();
50 for file in &files {
51 let source = std::fs::read_to_string(file)
52 .with_context(|| format!("reading source file `{}`", file.display()))?;
53 let ast = syn::parse_file(&source)
54 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
55 let mut visitor = IsolationVisitor {
56 file,
57 deps: &deps,
58 test_depth: 0,
59 violations: Vec::new(),
60 };
61 visitor.visit_file(&ast);
62 violations.append(&mut visitor.violations);
63 }
64
65 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
66 Ok(violations)
67}
68
69pub fn find_integration_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
73 let root = root.as_ref();
74 let first_party = first_party_crates(root)?;
75
76 let mut files = Vec::new();
77 collect_rust_files(root, &mut files)?;
78 files.retain(|file| is_integration_test(root, file));
79 files.sort();
80
81 let mut violations = Vec::new();
82 for file in &files {
83 let source = std::fs::read_to_string(file)
84 .with_context(|| format!("reading source file `{}`", file.display()))?;
85 let ast = syn::parse_file(&source)
86 .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
87 violations.extend(double_violations(file, &ast, &first_party));
88 }
89
90 violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
91 Ok(violations)
92}
93
94fn double_violations(
97 file: &Path,
98 ast: &syn::File,
99 first_party: &BTreeSet<String>,
100) -> Vec<Violation> {
101 let mut visitor = DoubleVisitor {
102 file,
103 first_party,
104 violations: Vec::new(),
105 };
106 visitor.visit_file(ast);
107 visitor.violations
108}
109
110struct DoubleVisitor<'a> {
112 file: &'a Path,
113 first_party: &'a BTreeSet<String>,
114 violations: Vec<Violation>,
115}
116
117impl<'ast> Visit<'ast> for DoubleVisitor<'_> {
118 fn visit_item_use(&mut self, node: &'ast syn::ItemUse) {
119 if has_double_attr(&node.attrs) {
120 let mut imports = Vec::new();
121 flatten_use(&node.tree, &mut Vec::new(), &mut imports);
122 if let Some((segs, is_glob)) = imports.iter().find(|(segs, _)| {
123 segs.first()
124 .is_some_and(|root| self.first_party.contains(root))
125 }) {
126 self.violations.push(Violation {
127 file: self.file.to_path_buf(),
128 line: node.span().start().line,
129 rule: RULE_DOUBLE,
130 message: format!(
131 "integration test doubles first-party `{}` with `#[double]`; \
132 run first-party code for real — only external crates may be doubled",
133 render_use(segs, *is_glob),
134 ),
135 });
136 }
137 }
138 visit::visit_item_use(self, node);
139 }
140
141 fn visit_macro(&mut self, node: &'ast syn::Macro) {
142 if let MacroBody::Items(items) = macro_body(node) {
143 let mut inner = DoubleVisitor {
144 file: self.file,
145 first_party: self.first_party,
146 violations: Vec::new(),
147 };
148 for item in &items {
149 inner.visit_item(item);
150 }
151 self.violations.append(&mut inner.violations);
152 }
153 visit::visit_macro(self, node);
154 }
155}
156
157fn has_double_attr(attrs: &[syn::Attribute]) -> bool {
159 attrs.iter().any(|attr| {
160 attr.path()
161 .segments
162 .last()
163 .is_some_and(|seg| seg.ident == "double")
164 })
165}
166
167fn first_party_crates(root: &Path) -> Result<BTreeSet<String>> {
171 let manifest = root.join("Cargo.toml");
172 if !manifest.is_file() {
173 return Ok(BTreeSet::new());
174 }
175 let text = std::fs::read_to_string(&manifest)
176 .with_context(|| format!("reading `{}`", manifest.display()))?;
177 let value: toml::Value =
178 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
179 let mut set = path_dependency_names(&value);
180 set.extend(package_crate_name(&value));
181 Ok(set)
182}
183
184fn package_crate_name(value: &toml::Value) -> Option<String> {
186 Some(
187 value
188 .get("package")?
189 .get("name")?
190 .as_str()?
191 .replace('-', "_"),
192 )
193}
194
195fn path_dependency_names(value: &toml::Value) -> BTreeSet<String> {
198 let mut set = BTreeSet::new();
199 for table_name in ["dependencies", "dev-dependencies"] {
200 let Some(table) = value.get(table_name).and_then(toml::Value::as_table) else {
201 continue;
202 };
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 set
210}
211
212fn is_integration_test(root: &Path, file: &Path) -> bool {
216 file.strip_prefix(root)
217 .unwrap_or(file)
218 .components()
219 .any(|component| component.as_os_str() == "tests")
220}
221
222struct 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 for effectful collaborators",
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 import the unit or a named pure value instead",
275 render_use(segs, *is_glob),
276 ),
277 });
278 }
279 }
280 }
281 visit::visit_item_use(self, node);
282 }
283
284 fn visit_macro(&mut self, node: &'ast syn::Macro) {
285 if self.test_depth > 0 {
286 let mut inner = IsolationVisitor {
287 file: self.file,
288 deps: self.deps,
289 test_depth: self.test_depth,
290 violations: Vec::new(),
291 };
292 match macro_body(node) {
293 MacroBody::Items(items) => {
294 for item in &items {
295 inner.visit_item(item);
296 }
297 }
298 MacroBody::Exprs(exprs) => {
299 for expr in &exprs {
300 inner.visit_expr(expr);
301 }
302 }
303 MacroBody::Opaque => {}
304 }
305 self.violations.append(&mut inner.violations);
306 }
307 visit::visit_macro(self, node);
308 }
309}
310
311enum MacroBody {
314 Items(Vec<syn::Item>),
315 Exprs(Vec<syn::Expr>),
316 Opaque,
317}
318
319fn macro_body(mac: &syn::Macro) -> MacroBody {
329 use syn::parse::Parser;
330
331 let name = mac.path.segments.last().map(|seg| seg.ident.to_string());
332 if matches!(
333 name.as_deref(),
334 Some("quote" | "quote_spanned" | "macro_rules")
335 ) {
336 return MacroBody::Opaque;
337 }
338 let tokens = mac.tokens.clone();
339 if let Ok(file) = syn::parse2::<syn::File>(tokens.clone()) {
340 return MacroBody::Items(file.items);
341 }
342 let args = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
343 match args.parse2(tokens) {
344 Ok(exprs) => MacroBody::Exprs(exprs.into_iter().collect()),
345 Err(_) => MacroBody::Opaque,
346 }
347}
348
349fn classify(path: &syn::Path, deps: &BTreeSet<String>) -> Option<&'static str> {
352 let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
353 if is_pure_call_path(&segs) {
354 return None;
355 }
356 match segs.first().map(String::as_str)? {
357 "self" | "Self" => None,
358 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
359 "crate" => Some("first-party module"),
360 "std" => is_effectful_std(&segs).then_some("effectful std"),
361 "core" | "alloc" => None,
363 other => deps.contains(other).then_some("external crate"),
365 }
366}
367
368fn is_pure_call_path(segs: &[String]) -> bool {
369 const PATHS: &[&str] = &[
370 "clap::Command::new",
371 "clap::Arg::new",
372 "clap::Error::new",
373 "syn::parse_str",
374 "syn::parse_file",
375 "toml::from_str",
376 "zip::ZipWriter::new",
377 "zip::write::SimpleFileOptions::default",
378 "flate2::write::GzEncoder::new",
379 "flate2::Compression::default",
380 "tar::Builder::new",
381 "tar::Header::new_gnu",
382 "std::process::ExitStatus::from_raw",
383 ];
384 PATHS.contains(&segs.join("::").as_str())
385}
386
387fn is_pure_import_path(segs: &[String]) -> bool {
388 const PATHS: &[&str] = &[
389 "clap::error::ErrorKind",
390 "std::os::unix::process::ExitStatusExt",
391 ];
392 PATHS.contains(&segs.join("::").as_str())
393}
394
395fn is_effectful_std(segs: &[String]) -> bool {
407 match segs.get(1).map(String::as_str) {
408 Some("net" | "thread" | "os") => true,
409 Some("process") => segs.get(2).map(String::as_str) != Some("id"),
410 Some("env") => segs.get(2).map(String::as_str) != Some("temp_dir"),
411 Some("io") => matches!(
412 segs.get(2).map(String::as_str),
413 Some("stdin" | "stdout" | "stderr")
414 ),
415 Some("time") => {
416 matches!(
417 segs.get(2).map(String::as_str),
418 Some("SystemTime" | "Instant")
419 ) && segs.get(3).map(String::as_str) == Some("now")
420 }
421 _ => false,
422 }
423}
424
425fn flatten_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Vec<(Vec<String>, bool)>) {
428 match tree {
429 syn::UseTree::Path(path) => {
430 prefix.push(path.ident.to_string());
431 flatten_use(&path.tree, prefix, out);
432 prefix.pop();
433 }
434 syn::UseTree::Name(name) => out.push(use_leaf(prefix, &name.ident)),
435 syn::UseTree::Rename(rename) => out.push(use_leaf(prefix, &rename.ident)),
436 syn::UseTree::Glob(_) => out.push((prefix.clone(), true)),
437 syn::UseTree::Group(group) => {
438 for item in &group.items {
439 flatten_use(item, prefix, out);
440 }
441 }
442 }
443}
444
445fn use_leaf(prefix: &[String], ident: &syn::Ident) -> (Vec<String>, bool) {
447 let mut full = prefix.to_vec();
448 full.push(ident.to_string());
449 (full, false)
450}
451
452fn classify_use(segs: &[String], is_glob: bool, deps: &BTreeSet<String>) -> Option<&'static str> {
455 if !is_glob && is_pure_import_path(segs) {
456 return None;
457 }
458 match segs.first().map(String::as_str)? {
459 "super" => (segs.get(1).map(String::as_str) == Some("super")).then_some("ancestor module"),
460 "self" | "Self" => None,
461 "crate" => Some("first-party module"),
462 "std" if is_effectful_std(segs) => Some("effectful std"),
463 "std" | "core" | "alloc" => is_glob.then_some("glob import"),
465 other => {
466 if deps.contains(other) {
467 Some("external crate")
468 } else {
469 is_glob.then_some("glob import")
470 }
471 }
472 }
473}
474
475fn render_use(segs: &[String], is_glob: bool) -> String {
477 let mut out = segs.join("::");
478 if is_glob {
479 if !out.is_empty() {
480 out.push_str("::");
481 }
482 out.push('*');
483 }
484 out
485}
486
487fn render_path(path: &syn::Path) -> String {
489 let mut out = String::new();
490 if path.leading_colon.is_some() {
491 out.push_str("::");
492 }
493 for (i, seg) in path.segments.iter().enumerate() {
494 if i > 0 {
495 out.push_str("::");
496 }
497 out.push_str(&seg.ident.to_string());
498 }
499 out
500}
501
502pub(crate) fn has_cfg_test(attrs: &[syn::Attribute]) -> bool {
505 attrs.iter().any(|attr| {
506 attr.path().is_ident("cfg")
507 && attr
508 .meta
509 .require_list()
510 .map(|list| cfg_mentions_test(list.tokens.clone()))
511 .unwrap_or(false)
512 })
513}
514
515fn cfg_mentions_test(tokens: proc_macro2::TokenStream) -> bool {
518 cfg_requires_test(tokens, false)
519}
520
521fn cfg_requires_test(tokens: proc_macro2::TokenStream, negated: bool) -> bool {
524 let mut iter = tokens.into_iter().peekable();
525 while let Some(tt) = iter.next() {
526 match tt {
527 proc_macro2::TokenTree::Ident(id) => {
528 if cfg_ident_requires_test(&id, &mut iter, negated) {
529 return true;
530 }
531 }
532 proc_macro2::TokenTree::Group(group) if cfg_requires_test(group.stream(), negated) => {
533 return true;
534 }
535 _ => {}
536 }
537 }
538 false
539}
540
541fn cfg_ident_requires_test(
544 id: &proc_macro2::Ident,
545 iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
546 negated: bool,
547) -> bool {
548 if id == "not" {
549 return cfg_not_requires_test(iter, negated);
550 }
551 !negated && id == "test"
552}
553
554fn cfg_not_requires_test(
557 iter: &mut Peekable<proc_macro2::token_stream::IntoIter>,
558 negated: bool,
559) -> bool {
560 let Some(proc_macro2::TokenTree::Group(group)) = iter.peek() else {
561 return false;
562 };
563 let stream = group.stream();
564 iter.next();
565 cfg_requires_test(stream, !negated)
566}
567
568pub(crate) fn lines_hidden_from_tests(source: &str) -> BTreeSet<u32> {
575 let Ok(ast) = syn::parse_file(source) else {
576 return BTreeSet::new();
577 };
578 let mut hidden = HiddenItems::default();
579 hidden.visit_file(&ast);
580 hidden.lines
581}
582
583#[derive(Default)]
586struct HiddenItems {
587 lines: BTreeSet<u32>,
588}
589
590impl HiddenItems {
591 fn gated(&mut self, attrs: &[syn::Attribute], node: &dyn Spanned) {
592 if !has_cfg_not_test(attrs) {
593 return;
594 }
595 let span = node.span();
596 self.lines
597 .extend(span.start().line as u32..=span.end().line as u32);
598 }
599}
600
601impl<'ast> Visit<'ast> for HiddenItems {
602 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
603 self.gated(&node.attrs, node);
604 visit::visit_item_fn(self, node);
605 }
606
607 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
608 self.gated(&node.attrs, node);
609 visit::visit_item_mod(self, node);
610 }
611
612 fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) {
613 self.gated(&node.attrs, node);
614 visit::visit_item_impl(self, node);
615 }
616
617 fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
618 self.gated(&node.attrs, node);
619 visit::visit_impl_item_fn(self, node);
620 }
621}
622
623pub(crate) fn has_cfg_not_test(attrs: &[syn::Attribute]) -> bool {
627 attrs.iter().any(|attr| {
628 attr.path().is_ident("cfg")
629 && attr
630 .meta
631 .require_list()
632 .map(|list| cfg_under_test(list.tokens.clone()) == CfgTruth::False)
633 .unwrap_or(false)
634 })
635}
636
637#[derive(Debug, Clone, Copy, PartialEq, Eq)]
640enum CfgTruth {
641 False,
642 True,
643 Unknown,
644}
645
646fn cfg_under_test(tokens: proc_macro2::TokenStream) -> CfgTruth {
649 match cfg_predicates(tokens).as_slice() {
650 [only] => *only,
651 _ => CfgTruth::Unknown,
652 }
653}
654
655fn cfg_predicates(tokens: proc_macro2::TokenStream) -> Vec<CfgTruth> {
657 let mut out = Vec::new();
658 let mut current: Vec<proc_macro2::TokenTree> = Vec::new();
659 for tt in tokens {
660 match &tt {
661 proc_macro2::TokenTree::Punct(punct) if punct.as_char() == ',' => {
662 if !current.is_empty() {
663 out.push(cfg_predicate(¤t));
664 current.clear();
665 }
666 }
667 _ => current.push(tt),
668 }
669 }
670 if !current.is_empty() {
671 out.push(cfg_predicate(¤t));
672 }
673 out
674}
675
676fn cfg_predicate(tokens: &[proc_macro2::TokenTree]) -> CfgTruth {
680 use proc_macro2::TokenTree;
681 match tokens {
682 [TokenTree::Ident(id)] if id == "test" => CfgTruth::True,
683 [TokenTree::Ident(id), TokenTree::Group(group)] => {
684 let inner = cfg_predicates(group.stream());
685 match id.to_string().as_str() {
686 "not" => match inner.as_slice() {
688 [only] => cfg_negate(*only),
689 _ => CfgTruth::Unknown,
690 },
691 "all" => cfg_all(&inner),
692 "any" => cfg_any(&inner),
693 _ => CfgTruth::Unknown,
694 }
695 }
696 _ => CfgTruth::Unknown,
697 }
698}
699
700fn cfg_all(parts: &[CfgTruth]) -> CfgTruth {
702 if parts.contains(&CfgTruth::False) {
703 CfgTruth::False
704 } else if parts.contains(&CfgTruth::Unknown) {
705 CfgTruth::Unknown
706 } else {
707 CfgTruth::True
708 }
709}
710
711fn cfg_any(parts: &[CfgTruth]) -> CfgTruth {
713 if parts.contains(&CfgTruth::True) {
714 CfgTruth::True
715 } else if parts.contains(&CfgTruth::Unknown) {
716 CfgTruth::Unknown
717 } else {
718 CfgTruth::False
719 }
720}
721
722fn cfg_negate(truth: CfgTruth) -> CfgTruth {
724 match truth {
725 CfgTruth::False => CfgTruth::True,
726 CfgTruth::True => CfgTruth::False,
727 CfgTruth::Unknown => CfgTruth::Unknown,
728 }
729}
730
731fn external_deps(root: &Path) -> Result<BTreeSet<String>> {
735 let manifest = root.join("Cargo.toml");
736 if !manifest.is_file() {
737 return Ok(BTreeSet::new());
738 }
739 let text = std::fs::read_to_string(&manifest)
740 .with_context(|| format!("reading `{}`", manifest.display()))?;
741 let value: toml::Value =
742 toml::from_str(&text).with_context(|| format!("parsing `{}`", manifest.display()))?;
743 let mut deps = BTreeSet::new();
744 if let Some(table) = value.get("dependencies").and_then(toml::Value::as_table) {
745 for name in table.keys() {
746 deps.insert(name.replace('-', "_"));
747 }
748 }
749 Ok(deps)
750}
751
752fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
753 let entries =
754 std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
755 for entry in entries {
756 let path = crate::walk::dir_entry(entry, dir)?.path();
757 if path.is_dir() {
758 collect_rust_files(&path, out)?;
759 } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
760 out.push(path);
761 }
762 }
763 Ok(())
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769 use std::sync::atomic::{AtomicU64, Ordering};
770
771 fn violations_in(src: &str, deps: &[&str]) -> Vec<Violation> {
773 let ast = syn::parse_file(src).expect("snippet parses");
774 let dep_set: BTreeSet<String> = deps.iter().map(|s| (*s).to_string()).collect();
775 let mut visitor = IsolationVisitor {
776 file: Path::new("snippet.rs"),
777 deps: &dep_set,
778 test_depth: 0,
779 violations: Vec::new(),
780 };
781 visitor.visit_file(&ast);
782 visitor.violations
783 }
784
785 #[test]
786 fn flags_each_out_of_module_form() {
787 let src = "\
788#[cfg(test)]
789mod tests {
790 use super::*;
791 #[test]
792 fn t() {
793 let _ = crate::store::load();
794 let _ = std::net::TcpStream::connect(\"x\");
795 let _ = rand::random::<u8>();
796 let _ = super::super::util::help();
797 }
798}
799";
800 let violations = violations_in(src, &["rand"]);
801 assert_eq!(violations.len(), 4, "got {violations:?}");
802 assert!(violations.iter().all(|v| v.rule == RULE_CALL));
803 }
804
805 #[test]
806 fn allows_in_module_calls() {
807 let src = "\
808#[cfg(test)]
809mod tests {
810 use super::*;
811 use std::io::Cursor;
812 #[test]
813 fn t() {
814 let _ = super::widget();
815 let _ = self::helper();
816 let _ = Cursor::new(b\"x\");
817 let _ = std::collections::HashMap::<u8, u8>::new();
818 assert_eq!(1, 1);
819 }
820}
821";
822 assert!(violations_in(src, &["rand"]).is_empty());
823 }
824
825 #[test]
826 fn ignores_calls_outside_test_modules() {
827 let src = "fn run() { let _ = crate::other::go(); }";
828 assert!(violations_in(src, &[]).is_empty());
829 }
830
831 #[test]
832 fn reports_the_call_line() {
833 let src = "\
835#[cfg(test)]
836mod tests {
837 fn t() {
838 let _ = crate::other::go();
839 }
840}
841";
842 let violations = violations_in(src, &[]);
843 assert_eq!(violations.len(), 1);
844 assert_eq!(violations[0].line, 4);
845 }
846
847 #[test]
848 fn effectful_std_policy() {
849 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
850 assert!(is_effectful_std(&segs("std::net::TcpStream::connect")));
851 assert!(is_effectful_std(&segs("std::env::var")));
852 assert!(is_effectful_std(&segs("std::env")));
853 assert!(is_effectful_std(&segs("std::process::exit")));
854 assert!(is_effectful_std(&segs("std::process::Command::new")));
855 assert!(is_effectful_std(&segs("std::process")));
856 assert!(is_effectful_std(&segs("std::thread::sleep")));
857 assert!(is_effectful_std(&segs("std::time::SystemTime::now")));
858 assert!(is_effectful_std(&segs("std::io::stdout")));
859 assert!(!is_effectful_std(&segs("std::fs::read")));
860 assert!(!is_effectful_std(&segs("std::fs")));
861 assert!(!is_effectful_std(&segs("std::env::temp_dir")));
862 assert!(!is_effectful_std(&segs("std::process::id")));
863 assert!(!is_effectful_std(&segs("std::collections::HashMap")));
864 assert!(!is_effectful_std(&segs("std::io::Cursor")));
865 assert!(!is_effectful_std(&segs("std::time::Duration")));
866 assert!(!is_effectful_std(&segs("std::cmp::min")));
867 }
868
869 #[test]
870 fn classify_leading_segment() {
871 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
872 let path = |s: &str| syn::parse_str::<syn::Path>(s).expect("path parses");
873 assert_eq!(classify(&path("super::foo"), &deps), None);
874 assert_eq!(classify(&path("self::foo"), &deps), None);
875 assert_eq!(classify(&path("Local::new"), &deps), None);
876 assert_eq!(
877 classify(&path("super::super::foo"), &deps),
878 Some("ancestor module")
879 );
880 assert_eq!(
881 classify(&path("crate::a::b"), &deps),
882 Some("first-party module")
883 );
884 assert_eq!(
885 classify(&path("rand::random"), &deps),
886 Some("external crate")
887 );
888 assert_eq!(
889 classify(&path("std::net::TcpStream::connect"), &deps),
890 Some("effectful std")
891 );
892 assert_eq!(classify(&path("std::fs::read"), &deps), None);
893 assert_eq!(classify(&path("std::io::Cursor"), &deps), None);
894 }
895
896 #[test]
897 fn recognizes_cfg_test_attribute() {
898 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
899 assert!(has_cfg_test(&module("#[cfg(test)] mod t {}").attrs));
900 assert!(has_cfg_test(
901 &module("#[cfg(all(test, feature = \"x\"))] mod t {}").attrs
902 ));
903 assert!(!has_cfg_test(
904 &module("#[cfg(feature = \"test\")] mod t {}").attrs
905 ));
906 assert!(!has_cfg_test(&module("mod t {}").attrs));
907 assert!(!has_cfg_test(&module("#[cfg(not(test))] mod t {}").attrs));
908 assert!(!has_cfg_test(
909 &module("#[cfg(all(not(test), unix))] mod t {}").attrs
910 ));
911 assert!(!has_cfg_test(
912 &module("#[cfg(not(all(test, unix)))] mod t {}").attrs
913 ));
914 assert!(has_cfg_test(
915 &module("#[cfg(not(not(test)))] mod t {}").attrs
916 ));
917 }
918
919 #[test]
920 fn flags_each_foreign_import() {
921 let src = "\
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use super::Thing;
926 use crate::other::*;
927 use crate::other::Named;
928 use rand::Rng;
929 use std::net;
930 use std::fs;
931 use std::collections::HashMap;
932 use std::io::Cursor;
933}
934";
935 let violations = violations_in(src, &["rand"]);
938 assert_eq!(violations.len(), 4, "got {violations:?}");
939 assert!(violations.iter().all(|v| v.rule == RULE_IMPORT));
940 }
941
942 #[test]
943 fn classify_use_roots() {
944 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
945 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
946 assert_eq!(classify_use(&segs("super"), true, &deps), None); assert_eq!(classify_use(&segs("super::Thing"), false, &deps), None);
948 assert_eq!(classify_use(&segs("self::helper"), false, &deps), None);
949 assert_eq!(
950 classify_use(&segs("std::collections::HashMap"), false, &deps),
951 None
952 );
953 assert_eq!(classify_use(&segs("std::io::Cursor"), false, &deps), None);
954 assert_eq!(
955 classify_use(&segs("super::super"), true, &deps),
956 Some("ancestor module")
957 );
958 assert_eq!(
959 classify_use(&segs("crate::other"), true, &deps),
960 Some("first-party module")
961 );
962 assert_eq!(
963 classify_use(&segs("crate::other::Named"), false, &deps),
964 Some("first-party module")
965 );
966 assert_eq!(
967 classify_use(&segs("rand::Rng"), false, &deps),
968 Some("external crate")
969 );
970 assert_eq!(
971 classify_use(&segs("std::net"), false, &deps),
972 Some("effectful std")
973 );
974 assert_eq!(classify_use(&segs("std::fs"), false, &deps), None);
975 assert_eq!(
976 classify_use(&segs("std::collections"), true, &deps),
977 Some("glob import")
978 );
979 }
980
981 #[test]
982 fn imports_outside_test_modules_are_ignored() {
983 let src = "use crate::other::*; fn run() {}";
984 assert!(violations_in(src, &[]).is_empty());
985 }
986
987 fn integration_violations_in(src: &str, first_party: &[&str]) -> Vec<Violation> {
989 let ast = syn::parse_file(src).expect("snippet parses");
990 let set: BTreeSet<String> = first_party.iter().map(|s| (*s).to_string()).collect();
991 let mut visitor = DoubleVisitor {
992 file: Path::new("integration.rs"),
993 first_party: &set,
994 violations: Vec::new(),
995 };
996 visitor.visit_file(&ast);
997 visitor.violations
998 }
999
1000 #[test]
1001 fn flags_double_of_first_party_only() {
1002 let src = "\
1003use mockall_double::double;
1004#[double]
1005use widget::Renderer;
1006#[double]
1007use rand::rngs::ThreadRng;
1008#[double]
1009use crate::support::Helper;
1010";
1011 let violations = integration_violations_in(src, &["widget"]);
1013 assert_eq!(violations.len(), 1, "got {violations:?}");
1014 assert_eq!(violations[0].rule, RULE_DOUBLE);
1015 }
1016
1017 #[test]
1018 fn ignores_use_without_double() {
1019 let src = "use widget::Renderer; fn t() {}";
1020 assert!(integration_violations_in(src, &["widget"]).is_empty());
1021 }
1022
1023 #[test]
1024 fn recognizes_double_attribute() {
1025 let item = |s: &str| syn::parse_str::<syn::ItemUse>(s).expect("use parses");
1026 assert!(has_double_attr(&item("#[double] use a::B;").attrs));
1027 assert!(has_double_attr(
1028 &item("#[mockall_double::double] use a::B;").attrs
1029 ));
1030 assert!(!has_double_attr(
1031 &item("#[allow(unused_imports)] use a::B;").attrs
1032 ));
1033 assert!(!has_double_attr(&item("use a::B;").attrs));
1034 }
1035
1036 struct TempTree(PathBuf);
1037
1038 impl TempTree {
1039 fn new(files: &[(&str, &str)]) -> Self {
1040 static COUNTER: AtomicU64 = AtomicU64::new(0);
1041 let root = std::env::temp_dir().join(format!(
1042 "tc-isolation-{}-{}",
1043 std::process::id(),
1044 COUNTER.fetch_add(1, Ordering::Relaxed),
1045 ));
1046 for (rel, content) in files {
1047 let path = root.join(rel);
1048 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1049 std::fs::write(path, content).unwrap();
1050 }
1051 std::fs::create_dir_all(&root).unwrap();
1052 TempTree(root)
1053 }
1054
1055 fn path(&self) -> &Path {
1056 &self.0
1057 }
1058 }
1059
1060 impl Drop for TempTree {
1061 fn drop(&mut self) {
1062 let _ = std::fs::remove_dir_all(&self.0);
1063 }
1064 }
1065
1066 #[test]
1067 fn a_tree_without_a_manifest_resolves_to_empty_crate_sets() {
1068 let tree = TempTree::new(&[("src/lib.rs", "fn run() {}\n")]);
1069 assert!(first_party_crates(tree.path()).unwrap().is_empty());
1070 assert!(external_deps(tree.path()).unwrap().is_empty());
1071 }
1072
1073 #[test]
1074 fn a_path_dependency_is_first_party_and_a_registry_one_is_not() {
1075 let tree = TempTree::new(&[(
1076 "Cargo.toml",
1077 "[package]\n\
1078 name = \"my-crate\"\n\n\
1079 [dependencies]\n\
1080 sibling-lib = { path = \"../sibling-lib\" }\n\
1081 rand = \"0.8\"\n\n\
1082 [dev-dependencies]\n\
1083 test-support = { path = \"../test-support\" }\n\
1084 mockall = \"0.13\"\n",
1085 )]);
1086
1087 let first_party = first_party_crates(tree.path()).unwrap();
1088 assert_eq!(
1089 first_party,
1090 ["my_crate", "sibling_lib", "test_support"]
1091 .iter()
1092 .map(|s| (*s).to_string())
1093 .collect::<BTreeSet<String>>(),
1094 "the crate's own name and every path dep, hyphens normalized"
1095 );
1096
1097 let external = external_deps(tree.path()).unwrap();
1098 assert_eq!(
1099 external,
1100 ["rand", "sibling_lib"]
1101 .iter()
1102 .map(|s| (*s).to_string())
1103 .collect::<BTreeSet<String>>(),
1104 "`[dependencies]` only — a dev-dependency is test tooling, not a collaborator"
1105 );
1106 }
1107
1108 #[test]
1109 fn a_call_through_a_non_path_callee_is_left_alone() {
1110 let src = "\
1111#[cfg(test)]
1112mod tests {
1113 #[test]
1114 fn t() {
1115 let _ = (make())(1);
1116 }
1117}
1118";
1119 assert!(
1120 violations_in(src, &["rand"]).is_empty(),
1121 "a callee that is not a path carries no leading segment to classify"
1122 );
1123 }
1124
1125 #[test]
1126 fn a_renamed_import_is_judged_by_its_source_path() {
1127 let src = "\
1128#[cfg(test)]
1129mod tests {
1130 use crate::other::Thing as Local;
1131 use super::Widget as W;
1132}
1133";
1134 let violations = violations_in(src, &[]);
1135 assert_eq!(violations.len(), 1, "got {violations:?}");
1136 let m = &violations[0].message;
1137 assert!(
1138 m.contains("crate::other::Thing"),
1139 "the message names the source path, not the alias: {m}"
1140 );
1141 }
1142
1143 #[test]
1144 fn a_grouped_import_is_flattened_leaf_by_leaf() {
1145 let src = "\
1146#[cfg(test)]
1147mod tests {
1148 use crate::other::{Named, deeper::Other};
1149 use super::{Widget, helper};
1150}
1151";
1152 let violations = violations_in(src, &[]);
1153 assert_eq!(violations.len(), 2, "got {violations:?}");
1154 let (first, second) = (&violations[0].message, &violations[1].message);
1155 assert!(first.contains("crate::other::Named"), "{first}");
1156 assert!(second.contains("crate::other::deeper::Other"), "{second}");
1157 }
1158
1159 #[test]
1160 fn a_glob_of_an_unresolvable_root_is_still_a_glob_import() {
1161 let deps: BTreeSet<String> = ["rand"].iter().map(|s| s.to_string()).collect();
1162 let segs = |p: &str| p.split("::").map(str::to_string).collect::<Vec<_>>();
1163 assert_eq!(
1164 classify_use(&segs("helpers"), true, &deps),
1165 Some("glob import"),
1166 "a glob is foreign even when `syn` cannot resolve its root"
1167 );
1168 assert_eq!(
1169 classify_use(&segs("helpers::Thing"), false, &deps),
1170 None,
1171 "a named import of an unresolvable root is the heuristic's documented limit"
1172 );
1173 }
1174
1175 #[test]
1176 fn a_leading_colon_survives_into_the_message() {
1177 let src = "\
1178#[cfg(test)]
1179mod tests {
1180 #[test]
1181 fn t() {
1182 let _ = ::std::net::TcpStream::connect(\"x\");
1183 }
1184}
1185";
1186 let violations = violations_in(src, &[]);
1187 assert_eq!(violations.len(), 1, "got {violations:?}");
1188 let m = &violations[0].message;
1189 assert!(m.contains("`::std::net::TcpStream::connect`"), "{m}");
1190 }
1191
1192 #[test]
1193 fn a_bare_cfg_not_is_not_a_test_module() {
1194 let module = |s: &str| syn::parse_str::<syn::ItemMod>(s).expect("module parses");
1195 assert!(!has_cfg_test(&module("#[cfg(not)] mod t {}").attrs));
1196 }
1197
1198 #[test]
1199 fn an_unreadable_unit_source_names_the_file() {
1200 let tree = TempTree::new(&[("src/widget.rs", "")]);
1201 std::fs::write(tree.path().join("src/widget.rs"), [0xFF, 0xFE]).unwrap();
1202 let err = find_violations(tree.path(), tree.path()).unwrap_err();
1203 assert!(
1204 format!("{err:#}").contains("reading source file"),
1205 "got: {err:#}"
1206 );
1207 }
1208
1209 #[test]
1210 fn an_unparsable_unit_source_names_the_file() {
1211 let tree = TempTree::new(&[("src/widget.rs", "fn broken( {\n")]);
1212 let err = find_violations(tree.path(), tree.path()).unwrap_err();
1213 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1214 }
1215
1216 #[test]
1217 fn an_unreadable_integration_source_names_the_file() {
1218 let tree = TempTree::new(&[("tests/int.rs", "")]);
1219 std::fs::write(tree.path().join("tests/int.rs"), [0xFF, 0xFE]).unwrap();
1220 let err = find_integration_violations(tree.path()).unwrap_err();
1221 assert!(
1222 format!("{err:#}").contains("reading source file"),
1223 "got: {err:#}"
1224 );
1225 }
1226
1227 #[test]
1228 fn an_unparsable_integration_source_names_the_file() {
1229 let tree = TempTree::new(&[("tests/int.rs", "fn broken( {\n")]);
1230 let err = find_integration_violations(tree.path()).unwrap_err();
1231 assert!(format!("{err:#}").contains("parsing"), "got: {err:#}");
1232 }
1233
1234 #[test]
1235 fn integration_violations_are_sorted_by_file_and_line() {
1236 let tree = TempTree::new(&[
1237 (
1238 "Cargo.toml",
1239 "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1240 ),
1241 (
1242 "tests/int.rs",
1243 "#[double]\nuse widget::Renderer;\n#[double]\nuse widget::Store;\n",
1244 ),
1245 ]);
1246 let violations = find_integration_violations(tree.path()).unwrap();
1247 assert_eq!(violations.len(), 2, "got {violations:?}");
1248 assert!(violations[0].line < violations[1].line);
1249 }
1250
1251 #[test]
1252 fn an_unreadable_manifest_is_an_error_for_both_crate_sets() {
1253 let tree = TempTree::new(&[("Cargo.toml", "")]);
1254 std::fs::write(tree.path().join("Cargo.toml"), [0xFF, 0xFE]).unwrap();
1255 let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1256 let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1257 assert!(first.contains("reading"), "got: {first}");
1258 assert!(external.contains("reading"), "got: {external}");
1259 }
1260
1261 #[test]
1262 fn an_unparsable_manifest_is_an_error_for_both_crate_sets() {
1263 let tree = TempTree::new(&[("Cargo.toml", "not = toml =\n")]);
1264 let first = format!("{:#}", first_party_crates(tree.path()).unwrap_err());
1265 let external = format!("{:#}", external_deps(tree.path()).unwrap_err());
1266 assert!(first.contains("parsing"), "got: {first}");
1267 assert!(external.contains("parsing"), "got: {external}");
1268 }
1269
1270 #[test]
1271 fn a_manifest_without_dependency_tables_resolves_to_the_package_name_alone() {
1272 let tree = TempTree::new(&[(
1273 "Cargo.toml",
1274 "[package]\nname = \"widget\"\nversion = \"0.0.1\"\n",
1275 )]);
1276 let first = first_party_crates(tree.path()).unwrap();
1277 assert_eq!(first.iter().collect::<Vec<_>>(), ["widget"]);
1278 assert!(external_deps(tree.path()).unwrap().is_empty());
1279 }
1280
1281 #[test]
1282 fn a_registry_only_dependency_table_feeds_external_deps() {
1283 let tree = TempTree::new(&[("Cargo.toml", "[dependencies]\nserde = \"1\"\n")]);
1284 let external = external_deps(tree.path()).unwrap();
1285 assert_eq!(external.iter().collect::<Vec<_>>(), ["serde"]);
1286 }
1287
1288 #[test]
1289 fn a_missing_root_is_an_error_for_integration_collection() {
1290 let err = find_integration_violations(Path::new("/nonexistent-tc-isolation")).unwrap_err();
1291 assert!(
1292 format!("{err:#}").contains("reading directory"),
1293 "got: {err:#}"
1294 );
1295 }
1296
1297 #[test]
1298 fn a_cfg_not_test_function_hides_its_own_lines_and_no_others() {
1299 let source = "\
1300#[cfg(not(test))]
1301pub fn main() -> u8 {
1302 run()
1303}
1304
1305fn run() -> u8 {
1306 1
1307}
1308";
1309 assert_eq!(
1310 lines_hidden_from_tests(source),
1311 BTreeSet::from([1, 2, 3, 4])
1312 );
1313 }
1314
1315 #[test]
1316 fn a_gated_module_hides_everything_inside_it() {
1317 let source = "\
1318#[cfg(not(test))]
1319mod real {
1320 pub fn go() -> u8 {
1321 1
1322 }
1323}
1324";
1325 assert_eq!(
1326 lines_hidden_from_tests(source),
1327 BTreeSet::from([1, 2, 3, 4, 5, 6])
1328 );
1329 }
1330
1331 #[test]
1332 fn a_gated_method_hides_only_that_method() {
1333 let source = "\
1334impl Runner {
1335 #[cfg(not(test))]
1336 fn go(&self) -> u8 {
1337 1
1338 }
1339
1340 fn stay(&self) -> u8 {
1341 2
1342 }
1343}
1344";
1345 assert_eq!(
1346 lines_hidden_from_tests(source),
1347 BTreeSet::from([2, 3, 4, 5])
1348 );
1349 }
1350
1351 #[test]
1352 fn an_ungated_file_hides_nothing() {
1353 let source = "#[cfg(test)]\nmod tests {\n fn t() {}\n}\n\nfn go() -> u8 {\n 1\n}\n";
1354
1355 assert!(lines_hidden_from_tests(source).is_empty());
1356 }
1357
1358 #[test]
1359 fn unparseable_source_hides_nothing() {
1360 assert!(lines_hidden_from_tests("fn go( {").is_empty());
1361 }
1362
1363 fn hides_under(attr: &str) -> bool {
1365 !lines_hidden_from_tests(&format!("{attr}\nfn go() -> u8 {{\n 1\n}}\n")).is_empty()
1366 }
1367
1368 #[test]
1369 fn a_gate_no_test_build_can_satisfy_hides_the_item() {
1370 assert!(hides_under("#[cfg(not(test))]"));
1371 assert!(hides_under("#[cfg(all(not(test), unix))]"));
1372 assert!(hides_under("#[cfg(not(any(test, unix)))]"));
1373 assert!(hides_under("#[cfg(any())]"));
1374 assert!(hides_under("#[cfg(not(all()))]"));
1375 }
1376
1377 #[test]
1378 fn a_gate_a_test_build_can_still_satisfy_hides_nothing() {
1379 assert!(!hides_under("#[cfg(test)]"));
1380 assert!(!hides_under("#[cfg(unix)]"));
1381 assert!(!hides_under("#[cfg(feature = \"x\")]"));
1382 assert!(!hides_under("#[cfg(any(not(test), unix))]"));
1383 assert!(!hides_under("#[cfg(not(not(test)))]"));
1384 assert!(!hides_under("#[cfg(all())]"));
1385 assert!(!hides_under("#[inline]"));
1386 }
1387
1388 #[test]
1389 fn a_gate_resting_on_a_condition_we_cannot_decide_hides_nothing() {
1390 assert!(!hides_under("#[cfg(not(unix))]"));
1391 assert!(!hides_under("#[cfg(all(test, unix))]"));
1392 }
1393
1394 #[test]
1395 fn a_malformed_gate_hides_nothing() {
1396 assert!(!hides_under("#[cfg(not())]"));
1397 assert!(!hides_under("#[cfg(nope(test))]"));
1398 assert!(!hides_under("#[cfg(not(test), unix)]"));
1399 }
1400}