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