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