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