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