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