1use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use rayon::prelude::*;
12use syn::spanned::Spanned;
13use syn::visit::Visit;
14use syn::{
15 Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
16 ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
17};
18use thiserror::Error;
19
20use crate::config::CompiledConfig;
21use crate::discovery::{
22 DiscoveredWorkspaceFile, canonical_file_key, discover_module_tree, file_path_to_module_path,
23 normalize_exclude_path, rs_files, rs_files_excluding_nested_packages,
24};
25use crate::metrics::coupling::CouplingMetrics;
26use crate::metrics::dimensions::{IntegrationStrength, Visibility};
27use crate::metrics::module::ModuleMetrics;
28use crate::metrics::project::ProjectMetrics;
29use crate::volatility::Volatility;
30use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
31
32fn convert_visibility(vis: &syn::Visibility) -> Visibility {
36 match vis {
37 syn::Visibility::Public(_) => Visibility::Public,
38 syn::Visibility::Restricted(restricted) => {
39 let path_str = restricted
41 .path
42 .segments
43 .iter()
44 .map(|s| s.ident.to_string())
45 .collect::<Vec<_>>()
46 .join("::");
47
48 match path_str.as_str() {
49 "crate" => Visibility::PubCrate,
50 "super" => Visibility::PubSuper,
51 "self" => Visibility::Private, _ => Visibility::PubIn, }
54 }
55 syn::Visibility::Inherited => Visibility::Private,
56 }
57}
58
59fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
61 attrs.iter().any(|attr| attr.path().is_ident("test"))
62}
63
64fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
66 attrs.iter().any(|attr| {
67 if attr.path().is_ident("cfg") {
68 if let Ok(meta) = attr.meta.require_list() {
70 let tokens = meta.tokens.to_string();
71 return tokens.contains("test");
72 }
73 }
74 false
75 })
76}
77
78fn is_test_module(item: &ItemMod) -> bool {
80 item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
81}
82
83#[derive(Error, Debug)]
87pub enum AnalyzerError {
88 #[error("Failed to read file: {0}")]
89 IoError(#[from] std::io::Error),
90
91 #[error("Failed to parse Rust file: {0}")]
92 ParseError(String),
93
94 #[error("Invalid path: {0}")]
95 InvalidPath(String),
96
97 #[error("Workspace error: {0}")]
98 WorkspaceError(#[from] WorkspaceError),
99}
100
101#[derive(Debug, Clone)]
103pub struct Dependency {
104 pub path: String,
106 pub kind: DependencyKind,
108 pub line: usize,
110 pub usage: UsageContext,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum DependencyKind {
117 InternalUse,
119 ExternalUse,
121 TraitImpl,
123 InherentImpl,
125 TypeRef,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
131pub enum UsageContext {
132 Import,
134 TraitBound,
136 FieldAccess,
138 MethodCall,
140 FunctionCall,
142 StructConstruction,
144 TypeParameter,
146 FunctionParameter,
148 ReturnType,
150 InherentImplBlock,
152}
153
154impl UsageContext {
155 pub fn to_strength(&self) -> IntegrationStrength {
157 match self {
158 UsageContext::FieldAccess => IntegrationStrength::Intrusive,
160 UsageContext::StructConstruction => IntegrationStrength::Intrusive,
161 UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
162
163 UsageContext::MethodCall => IntegrationStrength::Functional,
165 UsageContext::FunctionCall => IntegrationStrength::Functional,
166 UsageContext::FunctionParameter => IntegrationStrength::Functional,
167 UsageContext::ReturnType => IntegrationStrength::Functional,
168
169 UsageContext::TypeParameter => IntegrationStrength::Model,
171 UsageContext::Import => IntegrationStrength::Model,
172
173 UsageContext::TraitBound => IntegrationStrength::Contract,
175 }
176 }
177}
178
179impl DependencyKind {
180 pub fn to_strength(&self) -> IntegrationStrength {
182 match self {
183 DependencyKind::TraitImpl => IntegrationStrength::Contract,
184 DependencyKind::InternalUse => IntegrationStrength::Model,
185 DependencyKind::ExternalUse => IntegrationStrength::Model,
186 DependencyKind::TypeRef => IntegrationStrength::Model,
187 DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
188 }
189 }
190}
191
192#[derive(Debug)]
194pub struct CouplingAnalyzer {
195 current_line: usize,
197 pub current_module: String,
199 pub file_path: std::path::PathBuf,
201 pub metrics: ModuleMetrics,
203 pub dependencies: Vec<Dependency>,
205 pub defined_types: HashSet<String>,
207 pub defined_traits: HashSet<String>,
209 pub defined_functions: HashMap<String, Visibility>,
211 imported_types: HashMap<String, String>,
213 seen_dependencies: HashSet<(String, UsageContext)>,
215 pub usage_counts: UsageCounts,
217 pub type_visibility: HashMap<String, Visibility>,
219 current_item: Option<(String, ItemKind)>,
221 pub item_dependencies: Vec<ItemDependency>,
223}
224
225#[derive(Debug, Default, Clone)]
227pub struct UsageCounts {
228 pub field_accesses: usize,
230 pub method_calls: usize,
232 pub function_calls: usize,
234 pub struct_constructions: usize,
236 pub trait_bounds: usize,
238 pub type_parameters: usize,
240}
241
242#[derive(Debug, Clone)]
244pub struct ItemDependency {
245 pub source_item: String,
247 pub source_kind: ItemKind,
249 pub target: String,
251 pub target_module: Option<String>,
253 pub dep_type: ItemDepType,
255 pub line: usize,
257 pub expression: Option<String>,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum ItemKind {
264 Function,
265 Method,
266 Struct,
267 Enum,
268 Trait,
269 Impl,
270 Module,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum ItemDepType {
276 FunctionCall,
278 MethodCall,
280 TypeUsage,
282 FieldAccess,
284 StructConstruction,
286 TraitImpl,
288 TraitBound,
290 Import,
292}
293
294impl CouplingAnalyzer {
297 pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
299 Self {
300 current_module: module_name.clone(),
301 file_path: path.clone(),
302 metrics: ModuleMetrics::new(path, module_name),
303 dependencies: Vec::new(),
304 defined_types: HashSet::new(),
305 defined_traits: HashSet::new(),
306 defined_functions: HashMap::new(),
307 imported_types: HashMap::new(),
308 seen_dependencies: HashSet::new(),
309 usage_counts: UsageCounts::default(),
310 type_visibility: HashMap::new(),
311 current_item: None,
312 item_dependencies: Vec::new(),
313 current_line: 0,
314 }
315 }
316
317 pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
319 let syntax: File =
320 syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
321
322 self.visit_file(&syntax);
323
324 Ok(())
325 }
326
327 fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
329 let key = (path.clone(), usage);
330 if self.seen_dependencies.contains(&key) {
331 return;
332 }
333 self.seen_dependencies.insert(key);
334
335 self.dependencies.push(Dependency {
336 path,
337 kind,
338 line: self.current_line,
339 usage,
340 });
341 }
342
343 fn add_item_dependency(
345 &mut self,
346 target: String,
347 dep_type: ItemDepType,
348 line: usize,
349 expression: Option<String>,
350 ) {
351 if let Some((ref source_item, source_kind)) = self.current_item {
352 let target_module = self.imported_types.get(&target).cloned().or_else(|| {
354 if self.defined_types.contains(&target)
355 || self.defined_functions.contains_key(&target)
356 {
357 Some(self.current_module.clone())
358 } else {
359 None
360 }
361 });
362
363 self.item_dependencies.push(ItemDependency {
364 source_item: source_item.clone(),
365 source_kind,
366 target,
367 target_module,
368 dep_type,
369 line: if line == 0 { self.current_line } else { line },
370 expression,
371 });
372 }
373 }
374
375 fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
377 let mut paths = Vec::new();
378
379 match tree {
380 UseTree::Path(path) => {
381 let new_prefix = if prefix.is_empty() {
382 path.ident.to_string()
383 } else {
384 format!("{}::{}", prefix, path.ident)
385 };
386 paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
387 }
388 UseTree::Name(name) => {
389 let full_path = if prefix.is_empty() {
390 name.ident.to_string()
391 } else {
392 format!("{}::{}", prefix, name.ident)
393 };
394 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
395 DependencyKind::InternalUse
396 } else {
397 DependencyKind::ExternalUse
398 };
399 paths.push((full_path, kind));
400 }
401 UseTree::Rename(rename) => {
402 let full_path = if prefix.is_empty() {
403 rename.ident.to_string()
404 } else {
405 format!("{}::{}", prefix, rename.ident)
406 };
407 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
408 DependencyKind::InternalUse
409 } else {
410 DependencyKind::ExternalUse
411 };
412 paths.push((full_path, kind));
413 }
414 UseTree::Glob(_) => {
415 let full_path = format!("{}::*", prefix);
416 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
417 DependencyKind::InternalUse
418 } else {
419 DependencyKind::ExternalUse
420 };
421 paths.push((full_path, kind));
422 }
423 UseTree::Group(group) => {
424 for item in &group.items {
425 paths.extend(self.extract_use_paths(item, prefix));
426 }
427 }
428 }
429
430 paths
431 }
432
433 fn extract_type_name(&self, ty: &Type) -> Option<String> {
435 match ty {
436 Type::Path(type_path) => {
437 let segments: Vec<_> = type_path
438 .path
439 .segments
440 .iter()
441 .map(|s| s.ident.to_string())
442 .collect();
443 Some(segments.join("::"))
444 }
445 Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
446 Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
447 Type::Array(array_type) => self.extract_type_name(&array_type.elem),
448 Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
449 Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
450 Type::Group(group_type) => self.extract_type_name(&group_type.elem),
451 _ => None,
452 }
453 }
454
455 fn analyze_signature(&mut self, sig: &Signature) {
457 for arg in &sig.inputs {
459 if let FnArg::Typed(pat_type) = arg
460 && let Some(type_name) = self.extract_type_name(&pat_type.ty)
461 && !self.is_primitive_type(&type_name)
462 {
463 self.add_dependency(
464 type_name,
465 DependencyKind::TypeRef,
466 UsageContext::FunctionParameter,
467 );
468 }
469 }
470
471 if let ReturnType::Type(_, ty) = &sig.output
473 && let Some(type_name) = self.extract_type_name(ty)
474 && !self.is_primitive_type(&type_name)
475 {
476 self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
477 }
478 }
479
480 fn is_primitive_type(&self, type_name: &str) -> bool {
482 if matches!(
484 type_name,
485 "bool"
486 | "char"
487 | "str"
488 | "u8"
489 | "u16"
490 | "u32"
491 | "u64"
492 | "u128"
493 | "usize"
494 | "i8"
495 | "i16"
496 | "i32"
497 | "i64"
498 | "i128"
499 | "isize"
500 | "f32"
501 | "f64"
502 | "String"
503 | "Self"
504 | "()"
505 | "Option"
506 | "Result"
507 | "Vec"
508 | "Box"
509 | "Rc"
510 | "Arc"
511 | "RefCell"
512 | "Cell"
513 | "Mutex"
514 | "RwLock"
515 ) {
516 return true;
517 }
518
519 if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
522 return true;
523 }
524
525 if type_name.starts_with("self") || type_name == "self" {
527 return true;
528 }
529
530 false
531 }
532}
533
534impl<'ast> Visit<'ast> for CouplingAnalyzer {
535 fn visit_item(&mut self, node: &'ast syn::Item) {
536 let previous = self.current_line;
537 self.current_line = node.span().start().line;
538 syn::visit::visit_item(self, node);
539 self.current_line = previous;
540 }
541
542 fn visit_expr(&mut self, node: &'ast syn::Expr) {
543 let previous = self.current_line;
544 self.current_line = node.span().start().line;
545 syn::visit::visit_expr(self, node);
546 self.current_line = previous;
547 }
548
549 fn visit_type(&mut self, node: &'ast syn::Type) {
550 let previous = self.current_line;
551 self.current_line = node.span().start().line;
552 syn::visit::visit_type(self, node);
553 self.current_line = previous;
554 }
555 fn visit_item_use(&mut self, node: &'ast ItemUse) {
556 let paths = self.extract_use_paths(&node.tree, "");
557
558 for (path, kind) in paths {
559 if path == "self" || path.starts_with("self::") {
561 continue;
562 }
563
564 if let Some(type_name) = path.split("::").last() {
566 self.imported_types
567 .insert(type_name.to_string(), path.clone());
568 }
569
570 self.add_dependency(path.clone(), kind, UsageContext::Import);
571
572 if kind == DependencyKind::InternalUse {
574 if !self.metrics.internal_deps.contains(&path) {
575 self.metrics.internal_deps.push(path.clone());
576 }
577 } else if kind == DependencyKind::ExternalUse {
578 let crate_name = path.split("::").next().unwrap_or(&path).to_string();
580 if !self.metrics.external_deps.contains(&crate_name) {
581 self.metrics.external_deps.push(crate_name);
582 }
583 }
584 }
585
586 syn::visit::visit_item_use(self, node);
587 }
588
589 fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
590 let parent = self
591 .extract_type_name(&node.self_ty)
592 .unwrap_or_else(|| "Self".into());
593 for item in &node.items {
594 if let syn::ImplItem::Fn(method) = item {
595 let name = if let Some((_, path, _)) = &node.trait_ {
596 let trait_name = path
597 .segments
598 .iter()
599 .map(|segment| segment.ident.to_string())
600 .collect::<Vec<_>>()
601 .join("::");
602 format!("<{parent} as {trait_name}>::{}", method.sig.ident)
603 } else {
604 format!("{parent}::{}", method.sig.ident)
605 };
606 self.metrics
607 .item_locations
608 .insert(name.clone(), method.sig.span().start().line);
609 self.metrics
610 .method_definitions
611 .insert(name, convert_visibility(&method.vis));
612 }
613 }
614 if let Some((_, trait_path, _)) = &node.trait_ {
615 self.metrics.trait_impl_count += 1;
617
618 let trait_name: String = trait_path
620 .segments
621 .iter()
622 .map(|s| s.ident.to_string())
623 .collect::<Vec<_>>()
624 .join("::");
625
626 self.add_dependency(
627 trait_name,
628 DependencyKind::TraitImpl,
629 UsageContext::TraitBound,
630 );
631 self.usage_counts.trait_bounds += 1;
632 } else {
633 self.metrics.inherent_impl_count += 1;
635
636 if let Some(type_name) = self.extract_type_name(&node.self_ty)
638 && !self.defined_types.contains(&type_name)
639 {
640 self.add_dependency(
641 type_name,
642 DependencyKind::InherentImpl,
643 UsageContext::InherentImplBlock,
644 );
645 }
646 }
647 syn::visit::visit_item_impl(self, node);
648 }
649
650 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
651 let fn_name = node.sig.ident.to_string();
653 self.metrics
654 .item_locations
655 .insert(fn_name.clone(), node.sig.span().start().line);
656 let visibility = convert_visibility(&node.vis);
657 self.defined_functions.insert(fn_name.clone(), visibility);
658
659 if has_test_attribute(&node.attrs) {
661 self.metrics.test_function_count += 1;
662 }
663
664 let mut param_count = 0;
666 let mut primitive_param_count = 0;
667 let mut param_types = Vec::new();
668
669 for arg in &node.sig.inputs {
670 if let FnArg::Typed(pat_type) = arg {
671 param_count += 1;
672 if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
673 param_types.push(type_name.clone());
674 if self.is_primitive_type(&type_name) {
675 primitive_param_count += 1;
676 }
677 }
678 }
679 }
680
681 self.metrics.add_function_definition_full(
683 fn_name.clone(),
684 visibility,
685 param_count,
686 primitive_param_count,
687 param_types,
688 );
689
690 let previous_item = self.current_item.take();
692 self.current_item = Some((fn_name, ItemKind::Function));
693
694 self.analyze_signature(&node.sig);
696 syn::visit::visit_item_fn(self, node);
697
698 self.current_item = previous_item;
700 }
701
702 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
703 let name = node.ident.to_string();
704 let visibility = convert_visibility(&node.vis);
705
706 self.defined_types.insert(name.clone());
707 self.type_visibility.insert(name.clone(), visibility);
708
709 let (is_newtype, inner_type) = match &node.fields {
711 syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
712 let inner = fields
713 .unnamed
714 .first()
715 .and_then(|f| self.extract_type_name(&f.ty));
716 (true, inner)
717 }
718 _ => (false, None),
719 };
720
721 let has_serde_derive = node.attrs.iter().any(|attr| {
723 if attr.path().is_ident("derive")
724 && let Ok(nested) = attr.parse_args_with(
725 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
726 )
727 {
728 return nested.iter().any(|path| {
729 let path_str = path
730 .segments
731 .iter()
732 .map(|s| s.ident.to_string())
733 .collect::<Vec<_>>()
734 .join("::");
735 path_str == "Serialize"
736 || path_str == "Deserialize"
737 || path_str == "serde::Serialize"
738 || path_str == "serde::Deserialize"
739 });
740 }
741 false
742 });
743
744 let (total_field_count, public_field_count) = match &node.fields {
746 syn::Fields::Named(fields) => {
747 let total = fields.named.len();
748 let public = fields
749 .named
750 .iter()
751 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
752 .count();
753 (total, public)
754 }
755 syn::Fields::Unnamed(fields) => {
756 let total = fields.unnamed.len();
757 let public = fields
758 .unnamed
759 .iter()
760 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
761 .count();
762 (total, public)
763 }
764 syn::Fields::Unit => (0, 0),
765 };
766
767 self.metrics.add_type_definition_full(
769 name,
770 visibility,
771 false, is_newtype,
773 inner_type,
774 has_serde_derive,
775 public_field_count,
776 total_field_count,
777 );
778
779 match &node.fields {
781 syn::Fields::Named(fields) => {
782 self.metrics.type_usage_count += fields.named.len();
783 for field in &fields.named {
784 if let Some(type_name) = self.extract_type_name(&field.ty)
785 && !self.is_primitive_type(&type_name)
786 {
787 self.add_dependency(
788 type_name,
789 DependencyKind::TypeRef,
790 UsageContext::TypeParameter,
791 );
792 self.usage_counts.type_parameters += 1;
793 }
794 }
795 }
796 syn::Fields::Unnamed(fields) => {
797 for field in &fields.unnamed {
798 if let Some(type_name) = self.extract_type_name(&field.ty)
799 && !self.is_primitive_type(&type_name)
800 {
801 self.add_dependency(
802 type_name,
803 DependencyKind::TypeRef,
804 UsageContext::TypeParameter,
805 );
806 }
807 }
808 }
809 syn::Fields::Unit => {}
810 }
811 syn::visit::visit_item_struct(self, node);
812 }
813
814 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
815 let name = node.ident.to_string();
816 let visibility = convert_visibility(&node.vis);
817
818 self.defined_types.insert(name.clone());
819 self.type_visibility.insert(name.clone(), visibility);
820
821 self.metrics.add_type_definition(name, visibility, false);
823
824 for variant in &node.variants {
826 match &variant.fields {
827 syn::Fields::Named(fields) => {
828 for field in &fields.named {
829 if let Some(type_name) = self.extract_type_name(&field.ty)
830 && !self.is_primitive_type(&type_name)
831 {
832 self.add_dependency(
833 type_name,
834 DependencyKind::TypeRef,
835 UsageContext::TypeParameter,
836 );
837 }
838 }
839 }
840 syn::Fields::Unnamed(fields) => {
841 for field in &fields.unnamed {
842 if let Some(type_name) = self.extract_type_name(&field.ty)
843 && !self.is_primitive_type(&type_name)
844 {
845 self.add_dependency(
846 type_name,
847 DependencyKind::TypeRef,
848 UsageContext::TypeParameter,
849 );
850 }
851 }
852 }
853 syn::Fields::Unit => {}
854 }
855 }
856 syn::visit::visit_item_enum(self, node);
857 }
858
859 fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
860 let name = node.ident.to_string();
861 let visibility = convert_visibility(&node.vis);
862 for item in &node.items {
863 if let syn::TraitItem::Fn(method) = item {
864 let method_name = format!("{name}::{}", method.sig.ident);
865 self.metrics
866 .item_locations
867 .insert(method_name.clone(), method.sig.span().start().line);
868 self.metrics
869 .method_definitions
870 .insert(method_name, visibility);
871 }
872 }
873
874 self.defined_traits.insert(name.clone());
875 self.type_visibility.insert(name.clone(), visibility);
876
877 self.metrics.add_type_definition(name, visibility, true);
879
880 self.metrics.trait_impl_count += 1;
881 syn::visit::visit_item_trait(self, node);
882 }
883
884 fn visit_item_mod(&mut self, node: &'ast ItemMod) {
885 if is_test_module(node) {
887 self.metrics.is_test_module = true;
888 }
889
890 if node.content.is_some() {
891 self.metrics.internal_deps.push(node.ident.to_string());
892 }
893 syn::visit::visit_item_mod(self, node);
894 }
895
896 fn visit_expr_field(&mut self, node: &'ast ExprField) {
898 let field_name = match &node.member {
899 syn::Member::Named(ident) => ident.to_string(),
900 syn::Member::Unnamed(idx) => format!("{}", idx.index),
901 };
902
903 if let Expr::Path(path_expr) = &*node.base {
905 let base_name = path_expr
906 .path
907 .segments
908 .iter()
909 .map(|s| s.ident.to_string())
910 .collect::<Vec<_>>()
911 .join("::");
912
913 let full_path = self
915 .imported_types
916 .get(&base_name)
917 .cloned()
918 .unwrap_or(base_name.clone());
919
920 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
921 self.add_dependency(
922 full_path.clone(),
923 DependencyKind::TypeRef,
924 UsageContext::FieldAccess,
925 );
926 self.usage_counts.field_accesses += 1;
927 }
928
929 let expr = format!("{}.{}", base_name, field_name);
931 self.add_item_dependency(
932 format!("{}.{}", full_path, field_name),
933 ItemDepType::FieldAccess,
934 0,
935 Some(expr),
936 );
937 }
938 syn::visit::visit_expr_field(self, node);
939 }
940
941 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
943 let method_name = node.method.to_string();
944
945 if let Expr::Path(path_expr) = &*node.receiver {
947 let receiver_name = path_expr
948 .path
949 .segments
950 .iter()
951 .map(|s| s.ident.to_string())
952 .collect::<Vec<_>>()
953 .join("::");
954
955 let full_path = self
956 .imported_types
957 .get(&receiver_name)
958 .cloned()
959 .unwrap_or(receiver_name.clone());
960
961 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
962 self.add_dependency(
963 full_path.clone(),
964 DependencyKind::TypeRef,
965 UsageContext::MethodCall,
966 );
967 self.usage_counts.method_calls += 1;
968 }
969
970 let expr = format!("{}.{}()", receiver_name, method_name);
972 self.add_item_dependency(
973 format!("{}::{}", full_path, method_name),
974 ItemDepType::MethodCall,
975 0, Some(expr),
977 );
978 }
979 syn::visit::visit_expr_method_call(self, node);
980 }
981
982 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
984 if let Expr::Path(path_expr) = &*node.func {
985 let path_str = path_expr
986 .path
987 .segments
988 .iter()
989 .map(|s| s.ident.to_string())
990 .collect::<Vec<_>>()
991 .join("::");
992
993 if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
995 {
996 let full_path = self
997 .imported_types
998 .get(&path_str)
999 .cloned()
1000 .unwrap_or(path_str.clone());
1001
1002 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
1003 self.add_dependency(
1004 full_path.clone(),
1005 DependencyKind::TypeRef,
1006 UsageContext::FunctionCall,
1007 );
1008 self.usage_counts.function_calls += 1;
1009 }
1010
1011 self.add_item_dependency(
1013 full_path,
1014 ItemDepType::FunctionCall,
1015 0,
1016 Some(format!("{}()", path_str)),
1017 );
1018 } else {
1019 self.add_item_dependency(
1021 path_str.clone(),
1022 ItemDepType::FunctionCall,
1023 0,
1024 Some(format!("{}()", path_str)),
1025 );
1026 }
1027 }
1028 syn::visit::visit_expr_call(self, node);
1029 }
1030
1031 fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
1033 let struct_name = node
1034 .path
1035 .segments
1036 .iter()
1037 .map(|s| s.ident.to_string())
1038 .collect::<Vec<_>>()
1039 .join("::");
1040
1041 if struct_name == "Self" || struct_name.starts_with("Self::") {
1043 syn::visit::visit_expr_struct(self, node);
1044 return;
1045 }
1046
1047 let full_path = self
1048 .imported_types
1049 .get(&struct_name)
1050 .cloned()
1051 .unwrap_or(struct_name.clone());
1052
1053 if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
1054 self.add_dependency(
1055 full_path,
1056 DependencyKind::TypeRef,
1057 UsageContext::StructConstruction,
1058 );
1059 self.usage_counts.struct_constructions += 1;
1060 }
1061 syn::visit::visit_expr_struct(self, node);
1062 }
1063}
1064
1065#[derive(Debug, Clone)]
1069struct AnalyzedFile {
1070 module_name: String,
1071 #[allow(dead_code)]
1072 file_path: PathBuf,
1073 metrics: ModuleMetrics,
1074 dependencies: Vec<Dependency>,
1075 type_visibility: HashMap<String, Visibility>,
1077 item_dependencies: Vec<ItemDependency>,
1079}
1080
1081pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1083 analyze_project_parallel(path)
1084}
1085
1086fn is_path_excluded(file_path: &Path, exclude_base: &Path, config: &CompiledConfig) -> bool {
1092 let normalized_file = normalize_exclude_path(file_path);
1093 let normalized_base = normalize_exclude_path(exclude_base);
1094 let relative = normalized_file
1095 .strip_prefix(&normalized_base)
1096 .unwrap_or(&normalized_file);
1097 let relative_str = relative.to_string_lossy().replace('\\', "/");
1098 config.should_exclude(&relative_str)
1099}
1100
1101fn path_for_config_matching(file_path: &Path, config: &CompiledConfig) -> String {
1103 let normalized_file = normalize_exclude_path(file_path);
1104 let path = config
1105 .config_root()
1106 .map(normalize_exclude_path)
1107 .and_then(|base| {
1108 normalized_file
1109 .strip_prefix(base)
1110 .ok()
1111 .map(Path::to_path_buf)
1112 })
1113 .unwrap_or(normalized_file);
1114
1115 path.to_string_lossy().replace('\\', "/")
1116}
1117
1118pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1123 analyze_project_parallel_with_config(path, &CompiledConfig::empty())
1124}
1125
1126pub fn analyze_project_parallel_with_config(
1128 path: &Path,
1129 config: &CompiledConfig,
1130) -> Result<ProjectMetrics, AnalyzerError> {
1131 if !path.exists() {
1132 return Err(AnalyzerError::InvalidPath(path.display().to_string()));
1133 }
1134
1135 let exclude_base = config.config_root().unwrap_or(path);
1136
1137 let file_paths: Vec<PathBuf> = rs_files(path)
1139 .filter(|fp| !is_path_excluded(fp, exclude_base, config))
1140 .collect();
1141
1142 let num_threads = rayon::current_num_threads();
1146 let file_count = file_paths.len();
1147
1148 let chunk_size = if file_count < num_threads * 2 {
1151 1 } else {
1153 (file_count / (num_threads * 4)).max(1)
1156 };
1157
1158 let analyzed_results: Vec<_> = file_paths
1160 .par_chunks(chunk_size)
1161 .flat_map(|chunk| {
1162 chunk
1163 .iter()
1164 .filter_map(|file_path| match analyze_rust_file_full(file_path) {
1165 Ok(result) => {
1166 let module_path = file_path_to_module_path(file_path, path);
1168 let original_module_name = result.metrics.name.clone();
1169 let module_name = if module_path.is_empty() {
1170 original_module_name.clone()
1172 } else {
1173 module_path
1174 };
1175
1176 let item_dependencies = result
1178 .item_dependencies
1179 .into_iter()
1180 .map(|mut dep| {
1181 if dep.target_module.as_ref() == Some(&original_module_name) {
1182 dep.target_module = Some(module_name.clone());
1183 }
1184 dep
1185 })
1186 .collect();
1187
1188 Some(AnalyzedFile {
1189 module_name: module_name.clone(),
1190 file_path: file_path.clone(),
1191 metrics: {
1192 let mut module_metrics = result.metrics;
1193 module_metrics.name = module_name;
1194 module_metrics
1195 },
1196 dependencies: result.dependencies,
1197 type_visibility: result.type_visibility,
1198 item_dependencies,
1199 })
1200 }
1201 Err(e) => {
1202 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1203 None
1204 }
1205 })
1206 .collect::<Vec<_>>()
1207 })
1208 .collect();
1209
1210 let module_names: HashSet<String> = analyzed_results
1212 .iter()
1213 .map(|a| a.module_name.clone())
1214 .collect();
1215
1216 let mut project = ProjectMetrics::new();
1218 project.total_files = analyzed_results.len();
1219 project.parse_failures = file_paths.len().saturating_sub(analyzed_results.len());
1220 let candidate_config_paths = file_paths
1223 .iter()
1224 .map(|file_path| path_for_config_matching(file_path, config))
1225 .collect::<Vec<_>>();
1226
1227 for analyzed in &analyzed_results {
1229 for (type_name, visibility) in &analyzed.type_visibility {
1230 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1231 }
1232 }
1233
1234 for analyzed in &analyzed_results {
1236 let mut metrics = analyzed.metrics.clone();
1238 metrics.item_dependencies = analyzed.item_dependencies.clone();
1239 metrics.subdomain =
1240 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1241 project.add_module(metrics);
1242
1243 for dep in &analyzed.dependencies {
1244 if !is_valid_dependency_path(&dep.path) {
1246 continue;
1247 }
1248
1249 let target_module =
1251 resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
1252 let target_is_known_internal_module = module_names.contains(&target_module);
1253
1254 if !target_is_known_internal_module && !is_valid_dependency_path(&target_module) {
1256 continue;
1257 }
1258
1259 let distance = calculate_distance(
1261 &analyzed.module_name,
1262 &target_module,
1263 target_is_known_internal_module,
1264 );
1265
1266 let target_type = target_type_name(&dep.path);
1267 let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
1268 let strength = strength_for_dependency(dep, target_visibility);
1269
1270 let volatility = Volatility::Low;
1272
1273 let visibility = visibility_for_dependency(dep, target_visibility);
1274
1275 let coupling = CouplingMetrics::with_location(
1277 analyzed.module_name.clone(),
1278 target_module.clone(),
1279 strength,
1280 distance,
1281 volatility,
1282 visibility,
1283 analyzed.file_path.clone(),
1284 dep.line,
1285 )
1286 .with_observed_usage(format!("{:?}", dep.usage));
1287
1288 project.add_coupling(coupling);
1289 }
1290 }
1291
1292 project.update_coupling_visibility();
1294 project.dead_config_patterns =
1295 format_dead_config_patterns(config, &candidate_config_paths, path);
1296
1297 Ok(project)
1298}
1299
1300pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1302 analyze_workspace_with_config(path, &CompiledConfig::empty())
1303}
1304
1305pub fn analyze_workspace_with_config(
1307 path: &Path,
1308 config: &CompiledConfig,
1309) -> Result<ProjectMetrics, AnalyzerError> {
1310 let workspace = match WorkspaceInfo::from_path(path) {
1312 Ok(ws) => Some(ws),
1313 Err(e) => {
1314 eprintln!("Note: Could not load workspace metadata: {}", e);
1315 eprintln!("Falling back to basic analysis...");
1316 None
1317 }
1318 };
1319
1320 if let Some(ws) = workspace {
1321 analyze_with_workspace(path, &ws, config)
1322 } else {
1323 analyze_project_parallel_with_config(path, config)
1325 }
1326}
1327
1328fn analyze_with_workspace(
1330 path: &Path,
1331 workspace: &WorkspaceInfo,
1332 config: &CompiledConfig,
1333) -> Result<ProjectMetrics, AnalyzerError> {
1334 let analysis_scope = if path.file_name().is_some_and(|name| name == "Cargo.toml") {
1335 path.parent().unwrap_or(path)
1336 } else {
1337 path
1338 };
1339 let canonical_scope = canonical_file_key(analysis_scope);
1340 let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1343
1344 let mut project = ProjectMetrics::new();
1345
1346 project.workspace_name = Some(
1348 workspace
1349 .root
1350 .file_name()
1351 .and_then(|n| n.to_str())
1352 .unwrap_or("workspace")
1353 .to_string(),
1354 );
1355 project.workspace_members = workspace.members.clone();
1356
1357 let mut discovered_files: Vec<DiscoveredWorkspaceFile> = Vec::new();
1359 let mut selected_members = HashSet::new();
1360
1361 for member_name in &workspace.members {
1362 if let Some(crate_info) = workspace.get_crate(member_name) {
1363 let member_root =
1364 canonical_file_key(crate_info.manifest_path.parent().unwrap_or(&workspace.root));
1365 let whole_member = member_root.starts_with(&canonical_scope);
1368 let mut member_files: HashMap<PathBuf, DiscoveredWorkspaceFile> = HashMap::new();
1369 let mut source_contents = HashMap::new();
1370 let mut boundary_skipped_files = 0;
1371
1372 for source_root in &crate_info.source_roots {
1373 if !source_root.exists() {
1374 continue;
1375 }
1376
1377 for file_path in
1378 rs_files_excluding_nested_packages(source_root, &crate_info.manifest_path)
1379 {
1380 if is_path_excluded(&file_path, exclude_base, config) {
1381 continue;
1382 }
1383 let file_key = canonical_file_key(&file_path);
1384 member_files
1385 .entry(file_key)
1386 .or_insert_with(|| DiscoveredWorkspaceFile {
1387 file_path: file_path.to_path_buf(),
1388 crate_name: member_name.clone(),
1389 source_root: source_root.clone(),
1390 module_name: None,
1391 });
1392 }
1393 }
1394
1395 let has_path_attribute = member_files
1396 .keys()
1397 .any(|file_key| cache_file_and_scan_path_attribute(file_key, &mut source_contents));
1398
1399 if has_path_attribute {
1400 let mut visited = HashSet::new();
1401
1402 for crate_root in &crate_info.crate_roots {
1406 let discovery = discover_module_tree(
1407 crate_root,
1408 &workspace.root,
1409 &crate_info.manifest_path,
1410 &mut visited,
1411 &mut source_contents,
1412 );
1413 boundary_skipped_files += discovery.boundary_skipped_files;
1414
1415 for module_file in discovery.files {
1416 if is_path_excluded(&module_file.file_path, exclude_base, config) {
1417 continue;
1418 }
1419 let file_key = canonical_file_key(&module_file.file_path);
1420 member_files
1421 .entry(file_key)
1422 .or_insert_with(|| DiscoveredWorkspaceFile {
1423 file_path: module_file.file_path.clone(),
1424 crate_name: member_name.clone(),
1425 source_root: module_file
1426 .file_path
1427 .parent()
1428 .map(Path::to_path_buf)
1429 .unwrap_or_default(),
1430 module_name: Some(module_file.module_name),
1431 });
1432 }
1433 }
1434 }
1435
1436 if !whole_member {
1437 member_files.retain(|file_path, _| file_path.starts_with(&canonical_scope));
1438 }
1439 if whole_member || !member_files.is_empty() {
1440 selected_members.insert(member_name.clone());
1441 project.boundary_skipped_files += boundary_skipped_files;
1442 }
1443 if member_files.is_empty() {
1444 if whole_member {
1445 project.skipped_crates.push(member_name.clone());
1446 }
1447 } else {
1448 discovered_files.extend(member_files.into_values());
1449 }
1450 }
1451 }
1452
1453 let num_threads = rayon::current_num_threads();
1455 let file_count = discovered_files.len();
1456 let chunk_size = if file_count < num_threads * 2 {
1457 1
1458 } else {
1459 (file_count / (num_threads * 4)).max(1)
1460 };
1461
1462 let analyzed_files: Vec<AnalyzedFileWithCrate> = discovered_files
1464 .par_chunks(chunk_size)
1465 .flat_map(|chunk| {
1466 chunk
1467 .iter()
1468 .filter_map(|discovered| {
1469 match analyze_rust_file_full(&discovered.file_path) {
1470 Ok(result) => {
1471 let module_path = discovered.module_name.clone().unwrap_or_else(|| {
1473 file_path_to_module_path(
1474 &discovered.file_path,
1475 &discovered.source_root,
1476 )
1477 });
1478 let original_module_name = result.metrics.name.clone();
1479 let module_name = if module_path.is_empty() {
1480 original_module_name.clone()
1482 } else {
1483 module_path
1484 };
1485
1486 let item_dependencies = result
1488 .item_dependencies
1489 .into_iter()
1490 .map(|mut dep| {
1491 if dep.target_module.as_ref() == Some(&original_module_name) {
1492 dep.target_module = Some(module_name.clone());
1493 }
1494 dep
1495 })
1496 .collect();
1497
1498 Some(AnalyzedFileWithCrate {
1499 module_name: module_name.clone(),
1500 crate_name: discovered.crate_name.clone(),
1501 file_path: discovered.file_path.clone(),
1502 metrics: {
1503 let mut module_metrics = result.metrics;
1504 module_metrics.name = module_name;
1505 module_metrics
1506 },
1507 dependencies: result.dependencies,
1508 type_visibility: result.type_visibility,
1509 item_dependencies,
1510 })
1511 }
1512 Err(e) => {
1513 eprintln!(
1514 "Warning: Failed to analyze {}: {}",
1515 discovered.file_path.display(),
1516 e
1517 );
1518 None
1519 }
1520 }
1521 })
1522 .collect::<Vec<_>>()
1523 })
1524 .collect();
1525
1526 project.total_files = analyzed_files.len();
1527 project.parse_failures = discovered_files.len().saturating_sub(analyzed_files.len());
1528 let candidate_config_paths = discovered_files
1531 .iter()
1532 .map(|discovered| path_for_config_matching(&discovered.file_path, config))
1533 .collect::<Vec<_>>();
1534
1535 let module_names: HashSet<String> = analyzed_files
1537 .iter()
1538 .map(|a| a.module_name.clone())
1539 .collect();
1540
1541 for analyzed in &analyzed_files {
1543 for (type_name, visibility) in &analyzed.type_visibility {
1544 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1545 }
1546 }
1547
1548 for analyzed in &analyzed_files {
1550 let mut metrics = analyzed.metrics.clone();
1552 metrics.item_dependencies = analyzed.item_dependencies.clone();
1553 metrics.subdomain =
1554 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1555 project.add_module(metrics);
1556
1557 for dep in &analyzed.dependencies {
1558 if !is_valid_dependency_path(&dep.path) {
1560 continue;
1561 }
1562
1563 let resolved_crate =
1565 resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1566
1567 let target_module =
1568 resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
1569 let target_is_known_internal_module = module_names.contains(&target_module);
1570 let resolved_crate = if resolved_crate.is_none() && target_is_known_internal_module {
1571 Some(analyzed.crate_name.clone())
1572 } else {
1573 resolved_crate
1574 };
1575
1576 let target_is_known_crate = resolved_crate
1578 .as_deref()
1579 .is_some_and(|name| workspace.get_crate(name).is_some());
1580 if !target_is_known_internal_module
1581 && !target_is_known_crate
1582 && !is_valid_dependency_path(&target_module)
1583 {
1584 continue;
1585 }
1586
1587 let distance = calculate_distance_with_workspace(
1589 &analyzed.module_name,
1590 &target_module,
1591 target_is_known_internal_module,
1592 &analyzed.crate_name,
1593 resolved_crate.as_deref(),
1594 workspace,
1595 );
1596
1597 let target_type = target_type_name(&dep.path);
1598 let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
1599 let strength = strength_for_dependency(dep, target_visibility);
1600
1601 let volatility = Volatility::Low;
1603
1604 let mut coupling = CouplingMetrics::with_location(
1606 format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1607 if let Some(ref crate_name) = resolved_crate {
1608 format!("{}::{}", crate_name, target_module)
1609 } else {
1610 target_module.clone()
1611 },
1612 strength,
1613 distance,
1614 volatility,
1615 visibility_for_dependency(dep, target_visibility),
1616 analyzed.file_path.clone(),
1617 dep.line,
1618 )
1619 .with_observed_usage(format!("{:?}", dep.usage));
1620
1621 coupling.source_crate = Some(analyzed.crate_name.clone());
1623 coupling.target_crate = resolved_crate;
1624
1625 project.add_coupling(coupling);
1626 }
1627 }
1628
1629 for (crate_name, deps) in &workspace.dependency_graph {
1631 if selected_members.contains(crate_name) {
1632 for dep in deps {
1633 project
1635 .crate_dependencies
1636 .entry(crate_name.clone())
1637 .or_default()
1638 .push(dep.clone());
1639 }
1640 }
1641 }
1642
1643 project.dead_config_patterns =
1644 format_dead_config_patterns(config, &candidate_config_paths, analysis_scope);
1645
1646 Ok(project)
1647}
1648
1649fn format_dead_config_patterns(
1659 config: &CompiledConfig,
1660 candidate_paths: &[String],
1661 analysis_scope: &Path,
1662) -> Vec<String> {
1663 let Some(config_root) = config.config_root() else {
1664 return Vec::new();
1665 };
1666 if !config.has_subdomain_config() && !config.has_volatility_overrides() {
1667 return Vec::new();
1668 }
1669
1670 let normalized_scope = normalize_exclude_path(analysis_scope);
1671 let normalized_root = normalize_exclude_path(config_root);
1672 let scope = match normalized_scope.strip_prefix(&normalized_root) {
1673 Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
1674 Err(_) if normalized_root.starts_with(&normalized_scope) => String::new(),
1677 Err(_) => return Vec::new(),
1679 };
1680
1681 config
1682 .dead_patterns(candidate_paths)
1683 .into_iter()
1684 .filter(|dead| pattern_within_scope(&dead.pattern, &scope))
1685 .map(|dead| format!("{}: {}", dead.section, dead.pattern))
1686 .collect()
1687}
1688
1689fn pattern_within_scope(pattern: &str, scope: &str) -> bool {
1693 if scope.is_empty() {
1694 return true;
1695 }
1696 let (literal, had_meta) = match pattern.find(['*', '?', '[']) {
1697 Some(idx) => (&pattern[..idx], true),
1698 None => (pattern, false),
1699 };
1700 let literal = if had_meta {
1703 match literal.rfind('/') {
1704 Some(idx) => &literal[..idx],
1705 None => "",
1706 }
1707 } else {
1708 literal
1709 };
1710 let literal_components: Vec<&str> = literal.split('/').filter(|c| !c.is_empty()).collect();
1711 let scope_components: Vec<&str> = scope.split('/').filter(|c| !c.is_empty()).collect();
1712 literal_components.len() >= scope_components.len()
1713 && literal_components[..scope_components.len()] == scope_components[..]
1714}
1715
1716fn cache_file_and_scan_path_attribute(
1717 file_key: &Path,
1718 source_contents: &mut HashMap<PathBuf, String>,
1719) -> bool {
1720 if let Some(content) = source_contents.get(file_key) {
1721 return content.contains("#[path");
1722 }
1723
1724 let Ok(bytes) = fs::read(file_key) else {
1725 return false;
1726 };
1727 let has_path_attribute = bytes
1728 .windows(b"#[path".len())
1729 .any(|window| window == b"#[path");
1730
1731 if let Ok(content) = String::from_utf8(bytes) {
1732 source_contents.insert(file_key.to_path_buf(), content);
1733 }
1734
1735 has_path_attribute
1736}
1737
1738pub(crate) use crate::classification::{
1740 calculate_distance, calculate_distance_with_workspace, is_valid_dependency_path,
1741 resolve_target_module, strength_for_dependency, target_type_name, visibility_for_dependency,
1742};
1743
1744#[derive(Debug, Clone)]
1746struct AnalyzedFileWithCrate {
1747 module_name: String,
1748 crate_name: String,
1749 #[allow(dead_code)]
1750 file_path: PathBuf,
1751 metrics: ModuleMetrics,
1752 dependencies: Vec<Dependency>,
1753 type_visibility: HashMap<String, Visibility>,
1755 item_dependencies: Vec<ItemDependency>,
1757}
1758
1759pub struct AnalyzedFileResult {
1761 pub metrics: ModuleMetrics,
1763 pub dependencies: Vec<Dependency>,
1765 pub type_visibility: HashMap<String, Visibility>,
1767 pub item_dependencies: Vec<ItemDependency>,
1769}
1770
1771pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1773 let result = analyze_rust_file_full(path)?;
1774 Ok((result.metrics, result.dependencies))
1775}
1776
1777pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1779 let content = fs::read_to_string(path)?;
1780
1781 let module_name = path
1782 .file_stem()
1783 .and_then(|s| s.to_str())
1784 .unwrap_or("unknown")
1785 .to_string();
1786
1787 let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1788 analyzer.analyze_file(&content)?;
1789
1790 Ok(AnalyzedFileResult {
1791 metrics: analyzer.metrics,
1792 dependencies: analyzer.dependencies,
1793 type_visibility: analyzer.type_visibility,
1794 item_dependencies: analyzer.item_dependencies,
1795 })
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use super::*;
1801 use crate::classification::extract_target_module;
1802 use crate::metrics::dimensions::Distance;
1803
1804 fn resolve_target_module_for_test(
1805 path: &str,
1806 source_module: &str,
1807 known_modules: &HashSet<String>,
1808 ) -> String {
1809 resolve_target_module(path, source_module, known_modules, &ProjectMetrics::new())
1810 }
1811
1812 #[test]
1813 fn pattern_within_scope_requires_full_coverage() {
1814 assert!(pattern_within_scope("other/**", ""));
1816 assert!(pattern_within_scope("src/balance/**", "src"));
1818 assert!(pattern_within_scope("src/broken.rs", "src"));
1819 assert!(!pattern_within_scope("other/**", "src"));
1821 assert!(!pattern_within_scope("src/broken.rs", "src/web"));
1822 assert!(!pattern_within_scope("src/**", "src/web"));
1824 assert!(!pattern_within_scope("src/bal*", "src/balance"));
1826 assert!(pattern_within_scope("src/balance/mod*", "src/balance"));
1827 assert!(!pattern_within_scope("**/generated/**", "src"));
1829 }
1830
1831 #[test]
1832 fn test_analyzer_creation() {
1833 let analyzer = CouplingAnalyzer::new(
1834 "test_module".to_string(),
1835 std::path::PathBuf::from("test.rs"),
1836 );
1837 assert_eq!(analyzer.current_module, "test_module");
1838 }
1839
1840 #[test]
1841 fn test_analyze_simple_file() {
1842 let mut analyzer =
1843 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1844
1845 let code = r#"
1846 pub struct User {
1847 name: String,
1848 email: String,
1849 }
1850
1851 impl User {
1852 pub fn new(name: String, email: String) -> Self {
1853 Self { name, email }
1854 }
1855 }
1856 "#;
1857
1858 let result = analyzer.analyze_file(code);
1859 assert!(result.is_ok());
1860 assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1861 }
1862
1863 #[test]
1864 fn test_item_dependencies() {
1865 let mut analyzer =
1866 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1867
1868 let code = r#"
1869 pub struct Config {
1870 pub value: i32,
1871 }
1872
1873 pub fn process(config: Config) -> i32 {
1874 let x = config.value;
1875 helper(x)
1876 }
1877
1878 fn helper(n: i32) -> i32 {
1879 n * 2
1880 }
1881 "#;
1882
1883 let result = analyzer.analyze_file(code);
1884 assert!(result.is_ok());
1885
1886 assert!(analyzer.defined_functions.contains_key("process"));
1888 assert!(analyzer.defined_functions.contains_key("helper"));
1889
1890 println!(
1892 "Item dependencies count: {}",
1893 analyzer.item_dependencies.len()
1894 );
1895 for dep in &analyzer.item_dependencies {
1896 println!(
1897 " {} -> {} ({:?})",
1898 dep.source_item, dep.target, dep.dep_type
1899 );
1900 }
1901
1902 let process_deps: Vec<_> = analyzer
1904 .item_dependencies
1905 .iter()
1906 .filter(|d| d.source_item == "process")
1907 .collect();
1908
1909 assert!(
1910 !process_deps.is_empty(),
1911 "process function should have item dependencies"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_analyze_trait_impl() {
1917 let mut analyzer =
1918 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1919
1920 let code = r#"
1921 trait Printable {
1922 fn print(&self);
1923 }
1924
1925 struct Document;
1926
1927 impl Printable for Document {
1928 fn print(&self) {}
1929 }
1930 "#;
1931
1932 let result = analyzer.analyze_file(code);
1933 assert!(result.is_ok());
1934 assert!(analyzer.metrics.trait_impl_count >= 1);
1935 }
1936
1937 #[test]
1938 fn test_analyze_use_statements() {
1939 let mut analyzer =
1940 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1941
1942 let code = r#"
1943 use std::collections::HashMap;
1944 use serde::Serialize;
1945 use crate::utils;
1946 use crate::models::{User, Post};
1947 "#;
1948
1949 let result = analyzer.analyze_file(code);
1950 assert!(result.is_ok());
1951 assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1952 assert!(
1953 analyzer
1954 .metrics
1955 .external_deps
1956 .contains(&"serde".to_string())
1957 );
1958 assert!(!analyzer.dependencies.is_empty());
1959
1960 let internal_deps: Vec<_> = analyzer
1962 .dependencies
1963 .iter()
1964 .filter(|d| d.kind == DependencyKind::InternalUse)
1965 .collect();
1966 assert!(!internal_deps.is_empty());
1967 }
1968
1969 #[test]
1970 fn test_extract_use_paths() {
1971 let analyzer =
1972 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1973
1974 let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1976 let paths = analyzer.extract_use_paths(&tree, "");
1977 assert_eq!(paths.len(), 1);
1978 assert_eq!(paths[0].0, "std::collections::HashMap");
1979
1980 let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1982 let paths = analyzer.extract_use_paths(&tree, "");
1983 assert_eq!(paths.len(), 2);
1984 }
1985
1986 #[test]
1987 fn test_extract_target_module() {
1988 assert_eq!(extract_target_module("crate::models::user"), "models");
1989 assert_eq!(extract_target_module("super::utils"), "utils");
1990 assert_eq!(extract_target_module("std::collections"), "std");
1991 }
1992
1993 #[test]
1994 fn test_resolve_target_module_prefers_longest_known_module() {
1995 let known = HashSet::from([
1996 "balance".to_string(),
1997 "balance::issues".to_string(),
1998 "balance::score".to_string(),
1999 ]);
2000
2001 assert_eq!(
2002 resolve_target_module_for_test(
2003 "crate::balance::issues::CouplingIssue",
2004 "report",
2005 &known
2006 ),
2007 "balance::issues"
2008 );
2009 assert_eq!(
2010 resolve_target_module_for_test("super::issues::IssueType", "balance::coupling", &known),
2011 "balance::issues"
2012 );
2013 assert_eq!(
2014 resolve_target_module_for_test(
2015 "std::collections::HashMap",
2016 "balance::coupling",
2017 &known
2018 ),
2019 "std"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_reexported_type_name_resolves_to_defining_module() {
2025 let known = HashSet::from(["a".to_string(), "a::b".to_string(), "consumer".to_string()]);
2026 let mut project = ProjectMetrics::new();
2027 project.register_type(
2028 "SomeType".to_string(),
2029 "a::b".to_string(),
2030 Visibility::Public,
2031 );
2032
2033 let target = resolve_target_module("crate::SomeType", "a", &known, &project);
2034
2035 assert_eq!(target, "a::b");
2036 assert_eq!(
2037 calculate_distance("a", &target, known.contains(&target)),
2038 Distance::SameModule
2039 );
2040 }
2041
2042 #[test]
2043 fn test_unknown_reexported_type_name_keeps_external_fallback() {
2044 let known = HashSet::from(["a::b".to_string(), "consumer".to_string()]);
2045 let project = ProjectMetrics::new();
2046
2047 let target = resolve_target_module("crate::UnknownType", "consumer", &known, &project);
2048
2049 assert_eq!(target, "UnknownType");
2050 assert_eq!(
2051 calculate_distance("consumer", &target, known.contains(&target)),
2052 Distance::DifferentCrate
2053 );
2054 }
2055
2056 #[test]
2057 fn test_type_registry_ambiguity_prefers_public_then_lexicographic_module() {
2058 let mut project = ProjectMetrics::new();
2059 project.register_type(
2060 "Shared".to_string(),
2061 "z::private".to_string(),
2062 Visibility::Private,
2063 );
2064 project.register_type(
2065 "Shared".to_string(),
2066 "m::public".to_string(),
2067 Visibility::Public,
2068 );
2069 project.register_type(
2070 "Shared".to_string(),
2071 "a::public".to_string(),
2072 Visibility::Public,
2073 );
2074
2075 assert_eq!(project.get_type_module("Shared"), Some("a::public"));
2076 assert_eq!(
2077 project.get_type_visibility("Shared"),
2078 Some(Visibility::Public)
2079 );
2080 }
2081
2082 #[test]
2083 fn test_strength_mapping_uses_public_model_for_data_access_only() {
2084 let public_field = Dependency {
2085 path: "crate::PublicType".to_string(),
2086 kind: DependencyKind::TypeRef,
2087 line: 0,
2088 usage: UsageContext::FieldAccess,
2089 };
2090 let public_struct = Dependency {
2091 path: "crate::PublicType".to_string(),
2092 kind: DependencyKind::TypeRef,
2093 line: 0,
2094 usage: UsageContext::StructConstruction,
2095 };
2096 let crate_field = Dependency {
2097 path: "crate::CrateType".to_string(),
2098 kind: DependencyKind::TypeRef,
2099 line: 0,
2100 usage: UsageContext::FieldAccess,
2101 };
2102 let unknown_struct = Dependency {
2103 path: "crate::UnknownType".to_string(),
2104 kind: DependencyKind::TypeRef,
2105 line: 0,
2106 usage: UsageContext::StructConstruction,
2107 };
2108 let inherent_impl = Dependency {
2109 path: "crate::PublicType".to_string(),
2110 kind: DependencyKind::InherentImpl,
2111 line: 0,
2112 usage: UsageContext::InherentImplBlock,
2113 };
2114
2115 assert_eq!(
2116 strength_for_dependency(&public_field, Some(Visibility::Public)),
2117 IntegrationStrength::Model
2118 );
2119 assert_eq!(
2120 strength_for_dependency(&public_struct, Some(Visibility::Public)),
2121 IntegrationStrength::Model
2122 );
2123 assert_eq!(
2124 strength_for_dependency(&crate_field, Some(Visibility::PubCrate)),
2125 IntegrationStrength::Intrusive
2126 );
2127 assert_eq!(
2128 strength_for_dependency(&unknown_struct, None),
2129 IntegrationStrength::Intrusive
2130 );
2131 assert_eq!(
2132 strength_for_dependency(&inherent_impl, Some(Visibility::Public)),
2133 IntegrationStrength::Intrusive
2134 );
2135 }
2136
2137 #[test]
2138 fn test_structural_distance_same_file_is_same_module() {
2139 assert_eq!(
2140 calculate_distance("balance::grade", "balance::grade", true),
2141 Distance::SameModule
2142 );
2143 }
2144
2145 #[test]
2146 fn test_structural_distance_siblings_are_same_module_when_parent_is_not_root() {
2147 let known = HashSet::from([
2148 "balance::grade".to_string(),
2149 "balance::rationale".to_string(),
2150 ]);
2151
2152 let super_target = resolve_target_module_for_test(
2153 "super::rationale::GradeRationale",
2154 "balance::grade",
2155 &known,
2156 );
2157 let crate_target = resolve_target_module(
2158 "crate::balance::rationale::GradeRationale",
2159 "balance::grade",
2160 &known,
2161 &ProjectMetrics::new(),
2162 );
2163
2164 assert_eq!(super_target, "balance::rationale");
2165 assert_eq!(crate_target, "balance::rationale");
2166 assert_eq!(
2167 calculate_distance(
2168 "balance::grade",
2169 &super_target,
2170 known.contains(&super_target)
2171 ),
2172 Distance::SameModule
2173 );
2174 assert_eq!(
2175 calculate_distance(
2176 "balance::grade",
2177 &crate_target,
2178 known.contains(&crate_target)
2179 ),
2180 Distance::SameModule
2181 );
2182 }
2183
2184 #[test]
2185 fn test_structural_distance_parent_child_is_same_module() {
2186 assert_eq!(
2187 calculate_distance("balance", "balance::grade", true),
2188 Distance::SameModule
2189 );
2190 assert_eq!(
2191 calculate_distance("balance::grade", "balance", true),
2192 Distance::SameModule
2193 );
2194 assert_eq!(
2195 calculate_distance("web", "web::server::internal", true),
2196 Distance::SameModule
2197 );
2198 assert_eq!(
2199 calculate_distance("web::server::internal", "web", true),
2200 Distance::SameModule
2201 );
2202 }
2203
2204 #[test]
2205 fn test_structural_distance_cousins_are_different_module() {
2206 assert_eq!(
2207 calculate_distance("web::a", "web::b::c", true),
2208 Distance::DifferentModule
2209 );
2210 }
2211
2212 #[test]
2213 fn test_structural_distance_root_level_siblings_are_different_module() {
2214 assert_eq!(
2215 calculate_distance("diff", "analyzer", true),
2216 Distance::DifferentModule
2217 );
2218 assert_eq!(
2219 calculate_distance("diff", "balance::issue", true),
2220 Distance::DifferentModule
2221 );
2222 }
2223
2224 #[test]
2225 fn test_structural_distance_crate_syntax_does_not_make_far_module_close() {
2226 let known = HashSet::from(["far::away".to_string()]);
2227 let target = resolve_target_module_for_test("crate::far::away::X", "diff", &known);
2228
2229 assert_eq!(target, "far::away");
2230 assert_eq!(
2231 calculate_distance("diff", &target, known.contains(&target)),
2232 Distance::DifferentModule
2233 );
2234 }
2235
2236 #[test]
2237 fn test_structural_distance_workspace_member_and_external_crate() {
2238 let workspace = WorkspaceInfo {
2239 root: PathBuf::new(),
2240 crates: HashMap::new(),
2241 members: vec!["app".to_string(), "domain".to_string()],
2242 dependency_graph: HashMap::new(),
2243 reverse_deps: HashMap::new(),
2244 };
2245
2246 assert_eq!(
2247 calculate_distance_with_workspace(
2248 "app_module",
2249 "serde",
2250 false,
2251 "app",
2252 Some("serde"),
2253 &workspace,
2254 ),
2255 Distance::DifferentCrate
2256 );
2257 assert_eq!(
2258 calculate_distance_with_workspace(
2259 "app_module",
2260 "domain_module",
2261 false,
2262 "app",
2263 Some("domain"),
2264 &workspace,
2265 ),
2266 Distance::DifferentModule
2267 );
2268 }
2269
2270 #[test]
2271 fn test_field_access_detection() {
2272 let mut analyzer =
2273 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2274
2275 let code = r#"
2276 use crate::models::User;
2277
2278 fn get_name(user: &User) -> String {
2279 user.name.clone()
2280 }
2281 "#;
2282
2283 let result = analyzer.analyze_file(code);
2284 assert!(result.is_ok());
2285
2286 let _field_deps: Vec<_> = analyzer
2288 .dependencies
2289 .iter()
2290 .filter(|d| d.usage == UsageContext::FieldAccess)
2291 .collect();
2292 }
2295
2296 #[test]
2297 fn test_method_call_detection() {
2298 let mut analyzer =
2299 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2300
2301 let code = r#"
2302 fn process() {
2303 let data = String::new();
2304 data.push_str("hello");
2305 }
2306 "#;
2307
2308 let result = analyzer.analyze_file(code);
2309 assert!(result.is_ok());
2310 }
2312
2313 #[test]
2314 fn test_struct_construction_detection() {
2315 let mut analyzer =
2316 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2317
2318 let code = r#"
2319 use crate::config::Config;
2320
2321 fn create_config() {
2322 let c = Config { value: 42 };
2323 }
2324 "#;
2325
2326 let result = analyzer.analyze_file(code);
2327 assert!(result.is_ok());
2328
2329 let struct_deps: Vec<_> = analyzer
2331 .dependencies
2332 .iter()
2333 .filter(|d| d.usage == UsageContext::StructConstruction)
2334 .collect();
2335 assert!(!struct_deps.is_empty());
2336 }
2337
2338 #[test]
2339 fn test_usage_context_to_strength() {
2340 assert_eq!(
2341 UsageContext::FieldAccess.to_strength(),
2342 IntegrationStrength::Intrusive
2343 );
2344 assert_eq!(
2345 UsageContext::MethodCall.to_strength(),
2346 IntegrationStrength::Functional
2347 );
2348 assert_eq!(
2349 UsageContext::TypeParameter.to_strength(),
2350 IntegrationStrength::Model
2351 );
2352 assert_eq!(
2353 UsageContext::TraitBound.to_strength(),
2354 IntegrationStrength::Contract
2355 );
2356 }
2357
2358 #[test]
2359 fn test_has_test_attribute_with_test() {
2360 let code = r#"
2361 #[test]
2362 fn my_test() {}
2363 "#;
2364 let syntax: syn::File = syn::parse_str(code).unwrap();
2365 if let syn::Item::Fn(func) = &syntax.items[0] {
2366 assert!(has_test_attribute(&func.attrs));
2367 } else {
2368 panic!("Expected function");
2369 }
2370 }
2371
2372 #[test]
2373 fn test_has_test_attribute_without_test() {
2374 let code = r#"
2375 fn regular_fn() {}
2376 "#;
2377 let syntax: syn::File = syn::parse_str(code).unwrap();
2378 if let syn::Item::Fn(func) = &syntax.items[0] {
2379 assert!(!has_test_attribute(&func.attrs));
2380 } else {
2381 panic!("Expected function");
2382 }
2383 }
2384
2385 #[test]
2386 fn test_has_cfg_test_attribute_with_cfg_test() {
2387 let code = r#"
2388 #[cfg(test)]
2389 mod tests {}
2390 "#;
2391 let syntax: syn::File = syn::parse_str(code).unwrap();
2392 if let syn::Item::Mod(module) = &syntax.items[0] {
2393 assert!(has_cfg_test_attribute(&module.attrs));
2394 } else {
2395 panic!("Expected module");
2396 }
2397 }
2398
2399 #[test]
2400 fn test_has_cfg_test_attribute_without_cfg_test() {
2401 let code = r#"
2402 mod regular_mod {}
2403 "#;
2404 let syntax: syn::File = syn::parse_str(code).unwrap();
2405 if let syn::Item::Mod(module) = &syntax.items[0] {
2406 assert!(!has_cfg_test_attribute(&module.attrs));
2407 } else {
2408 panic!("Expected module");
2409 }
2410 }
2411
2412 #[test]
2413 fn test_has_cfg_test_attribute_with_other_cfg() {
2414 let code = r#"
2415 #[cfg(feature = "foo")]
2416 mod feature_mod {}
2417 "#;
2418 let syntax: syn::File = syn::parse_str(code).unwrap();
2419 if let syn::Item::Mod(module) = &syntax.items[0] {
2420 assert!(!has_cfg_test_attribute(&module.attrs));
2421 } else {
2422 panic!("Expected module");
2423 }
2424 }
2425
2426 #[test]
2427 fn test_is_test_module_named_tests() {
2428 let code = r#"
2429 mod tests {}
2430 "#;
2431 let syntax: syn::File = syn::parse_str(code).unwrap();
2432 if let syn::Item::Mod(module) = &syntax.items[0] {
2433 assert!(is_test_module(module));
2434 } else {
2435 panic!("Expected module");
2436 }
2437 }
2438
2439 #[test]
2440 fn test_is_test_module_with_cfg_test() {
2441 let code = r#"
2442 #[cfg(test)]
2443 mod my_tests {}
2444 "#;
2445 let syntax: syn::File = syn::parse_str(code).unwrap();
2446 if let syn::Item::Mod(module) = &syntax.items[0] {
2447 assert!(is_test_module(module));
2448 } else {
2449 panic!("Expected module");
2450 }
2451 }
2452
2453 #[test]
2454 fn test_is_test_module_regular_module() {
2455 let code = r#"
2456 mod utils {}
2457 "#;
2458 let syntax: syn::File = syn::parse_str(code).unwrap();
2459 if let syn::Item::Mod(module) = &syntax.items[0] {
2460 assert!(!is_test_module(module));
2461 } else {
2462 panic!("Expected module");
2463 }
2464 }
2465
2466 #[test]
2472 fn test_analyze_project_parallel_applies_exclude_patterns() {
2473 use crate::config::{CompiledConfig, CouplingConfig};
2474
2475 let tmp = tempfile::tempdir().expect("create tempdir");
2476 let root = tmp.path();
2477 let src = root.join("src");
2478 let generated = src.join("generated");
2479 std::fs::create_dir_all(&generated).expect("create generated dir");
2480 std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2481 .expect("write lib.rs");
2482 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2483 .expect("write generated/mod.rs");
2484
2485 let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2487 .expect("baseline analysis");
2488 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2489 assert!(
2490 baseline.modules.keys().any(|k| k.contains("generated")),
2491 "baseline must include the generated module; saw {:?}",
2492 baseline.modules.keys().collect::<Vec<_>>()
2493 );
2494
2495 let toml = r#"
2497 [analysis]
2498 exclude = ["src/generated/*", "src/generated/**"]
2499 "#;
2500 let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2501 let compiled = CompiledConfig::from_config(config).expect("compile config");
2502 let filtered =
2503 analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2504 assert_eq!(
2505 filtered.total_files, 1,
2506 "generated file should be excluded from analysis"
2507 );
2508 assert!(
2509 !filtered.modules.keys().any(|k| k.contains("generated")),
2510 "no generated module should remain; saw {:?}",
2511 filtered.modules.keys().collect::<Vec<_>>()
2512 );
2513 }
2514
2515 #[test]
2518 fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2519 use crate::config::{CompiledConfig, load_compiled_config};
2520
2521 let current_dir = std::env::current_dir().expect("get current dir");
2522 let target_dir = current_dir.join("target");
2523 let tmp = tempfile::Builder::new()
2524 .prefix("issue39-workspace-")
2525 .tempdir_in(&target_dir)
2526 .expect("create tempdir in target");
2527 let root = tmp.path();
2528 let src = root.join("src");
2529 let generated = src.join("generated");
2530 std::fs::create_dir_all(&generated).expect("create generated dir");
2531 std::fs::write(
2532 root.join("Cargo.toml"),
2533 r#"[package]
2534name = "coupling-fixture-exclude"
2535version = "0.1.0"
2536edition = "2024"
2537"#,
2538 )
2539 .expect("write Cargo.toml");
2540 std::fs::write(
2541 root.join(".coupling.toml"),
2542 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2543 )
2544 .expect("write .coupling.toml");
2545 std::fs::write(
2546 src.join("lib.rs"),
2547 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2548 )
2549 .expect("write lib.rs");
2550 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2551 .expect("write generated/mod.rs");
2552
2553 let relative_src = src
2554 .strip_prefix(¤t_dir)
2555 .expect("temp crate should be under current dir");
2556
2557 let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2558 .expect("baseline workspace analysis");
2559 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2560 assert!(
2561 baseline.modules.keys().any(|k| k.contains("generated")),
2562 "baseline must include the generated module; saw {:?}",
2563 baseline.modules.keys().collect::<Vec<_>>()
2564 );
2565
2566 let compiled = load_compiled_config(relative_src).expect("load compiled config");
2567 let filtered = analyze_workspace_with_config(relative_src, &compiled)
2568 .expect("filtered workspace analysis");
2569 assert_eq!(
2570 filtered.total_files, 1,
2571 "generated file should be excluded from workspace analysis"
2572 );
2573 assert!(
2574 !filtered.modules.keys().any(|k| k.contains("generated")),
2575 "no generated module should remain; saw {:?}",
2576 filtered.modules.keys().collect::<Vec<_>>()
2577 );
2578 }
2579
2580 #[test]
2583 fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2584 use crate::config::{CompiledConfig, load_compiled_config};
2585
2586 let tmp = tempfile::tempdir().expect("create tempdir");
2587 let root = tmp.path();
2588 let src = root.join("src");
2589 let generated = src.join("generated");
2590 std::fs::create_dir_all(&generated).expect("create generated dir");
2591 std::fs::write(
2592 root.join(".coupling.toml"),
2593 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2594 )
2595 .expect("write .coupling.toml");
2596 std::fs::write(
2597 src.join("lib.rs"),
2598 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2599 )
2600 .expect("write lib.rs");
2601 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2602 .expect("write generated/mod.rs");
2603
2604 let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
2605 .expect("baseline analysis");
2606 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2607 assert!(
2608 baseline.modules.keys().any(|k| k.contains("generated")),
2609 "baseline must include the generated module; saw {:?}",
2610 baseline.modules.keys().collect::<Vec<_>>()
2611 );
2612
2613 let compiled = load_compiled_config(&src).expect("load compiled config");
2614 let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2615 assert_eq!(
2616 filtered.total_files, 1,
2617 "generated file should be excluded from fallback analysis"
2618 );
2619 assert!(
2620 !filtered.modules.keys().any(|k| k.contains("generated")),
2621 "no generated module should remain; saw {:?}",
2622 filtered.modules.keys().collect::<Vec<_>>()
2623 );
2624 }
2625}