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