1use std::collections::{HashMap, HashSet};
8use std::ffi::OsStr;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use rayon::prelude::*;
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;
19use walkdir::WalkDir;
20
21use crate::config::CompiledConfig;
22use crate::metrics::coupling::CouplingMetrics;
23use crate::metrics::dimensions::{Distance, IntegrationStrength, Visibility};
24use crate::metrics::module::ModuleMetrics;
25use crate::metrics::project::ProjectMetrics;
26use crate::volatility::Volatility;
27use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
28
29fn convert_visibility(vis: &syn::Visibility) -> Visibility {
33 match vis {
34 syn::Visibility::Public(_) => Visibility::Public,
35 syn::Visibility::Restricted(restricted) => {
36 let path_str = restricted
38 .path
39 .segments
40 .iter()
41 .map(|s| s.ident.to_string())
42 .collect::<Vec<_>>()
43 .join("::");
44
45 match path_str.as_str() {
46 "crate" => Visibility::PubCrate,
47 "super" => Visibility::PubSuper,
48 "self" => Visibility::Private, _ => Visibility::PubIn, }
51 }
52 syn::Visibility::Inherited => Visibility::Private,
53 }
54}
55
56fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
58 attrs.iter().any(|attr| attr.path().is_ident("test"))
59}
60
61fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
63 attrs.iter().any(|attr| {
64 if attr.path().is_ident("cfg") {
65 if let Ok(meta) = attr.meta.require_list() {
67 let tokens = meta.tokens.to_string();
68 return tokens.contains("test");
69 }
70 }
71 false
72 })
73}
74
75fn is_test_module(item: &ItemMod) -> bool {
77 item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
78}
79
80fn file_path_to_module_path(file_path: &Path, src_root: &Path) -> String {
91 let relative = file_path.strip_prefix(src_root).unwrap_or(file_path);
93
94 let mut parts: Vec<String> = Vec::new();
95
96 for component in relative.components() {
97 if let Some(component_name) = component.as_os_str().to_str() {
98 parts.push(component_name.to_string());
99 }
100 }
101
102 if let Some(last) = parts.last().cloned() {
104 parts.pop();
105 match last.as_str() {
106 "lib.rs" | "main.rs" => {
107 }
109 "mod.rs" => {
110 }
112 _ => {
113 if let Some(stem) = last.strip_suffix(".rs") {
115 parts.push(stem.to_string());
116 } else {
117 parts.push(last);
118 }
119 }
120 }
121 }
122
123 parts.join("::")
124}
125
126#[derive(Error, Debug)]
130pub enum AnalyzerError {
131 #[error("Failed to read file: {0}")]
132 IoError(#[from] std::io::Error),
133
134 #[error("Failed to parse Rust file: {0}")]
135 ParseError(String),
136
137 #[error("Invalid path: {0}")]
138 InvalidPath(String),
139
140 #[error("Workspace error: {0}")]
141 WorkspaceError(#[from] WorkspaceError),
142}
143
144#[derive(Debug, Clone)]
146pub struct Dependency {
147 pub path: String,
149 pub kind: DependencyKind,
151 pub line: usize,
153 pub usage: UsageContext,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum DependencyKind {
160 InternalUse,
162 ExternalUse,
164 TraitImpl,
166 InherentImpl,
168 TypeRef,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
174pub enum UsageContext {
175 Import,
177 TraitBound,
179 FieldAccess,
181 MethodCall,
183 FunctionCall,
185 StructConstruction,
187 TypeParameter,
189 FunctionParameter,
191 ReturnType,
193 InherentImplBlock,
195}
196
197impl UsageContext {
198 pub fn to_strength(&self) -> IntegrationStrength {
200 match self {
201 UsageContext::FieldAccess => IntegrationStrength::Intrusive,
203 UsageContext::StructConstruction => IntegrationStrength::Intrusive,
204 UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
205
206 UsageContext::MethodCall => IntegrationStrength::Functional,
208 UsageContext::FunctionCall => IntegrationStrength::Functional,
209 UsageContext::FunctionParameter => IntegrationStrength::Functional,
210 UsageContext::ReturnType => IntegrationStrength::Functional,
211
212 UsageContext::TypeParameter => IntegrationStrength::Model,
214 UsageContext::Import => IntegrationStrength::Model,
215
216 UsageContext::TraitBound => IntegrationStrength::Contract,
218 }
219 }
220}
221
222impl DependencyKind {
223 pub fn to_strength(&self) -> IntegrationStrength {
225 match self {
226 DependencyKind::TraitImpl => IntegrationStrength::Contract,
227 DependencyKind::InternalUse => IntegrationStrength::Model,
228 DependencyKind::ExternalUse => IntegrationStrength::Model,
229 DependencyKind::TypeRef => IntegrationStrength::Model,
230 DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
231 }
232 }
233}
234
235#[derive(Debug)]
237pub struct CouplingAnalyzer {
238 pub current_module: String,
240 pub file_path: std::path::PathBuf,
242 pub metrics: ModuleMetrics,
244 pub dependencies: Vec<Dependency>,
246 pub defined_types: HashSet<String>,
248 pub defined_traits: HashSet<String>,
250 pub defined_functions: HashMap<String, Visibility>,
252 imported_types: HashMap<String, String>,
254 seen_dependencies: HashSet<(String, UsageContext)>,
256 pub usage_counts: UsageCounts,
258 pub type_visibility: HashMap<String, Visibility>,
260 current_item: Option<(String, ItemKind)>,
262 pub item_dependencies: Vec<ItemDependency>,
264}
265
266#[derive(Debug, Default, Clone)]
268pub struct UsageCounts {
269 pub field_accesses: usize,
271 pub method_calls: usize,
273 pub function_calls: usize,
275 pub struct_constructions: usize,
277 pub trait_bounds: usize,
279 pub type_parameters: usize,
281}
282
283#[derive(Debug, Clone)]
285pub struct ItemDependency {
286 pub source_item: String,
288 pub source_kind: ItemKind,
290 pub target: String,
292 pub target_module: Option<String>,
294 pub dep_type: ItemDepType,
296 pub line: usize,
298 pub expression: Option<String>,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ItemKind {
305 Function,
306 Method,
307 Struct,
308 Enum,
309 Trait,
310 Impl,
311 Module,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum ItemDepType {
317 FunctionCall,
319 MethodCall,
321 TypeUsage,
323 FieldAccess,
325 StructConstruction,
327 TraitImpl,
329 TraitBound,
331 Import,
333}
334
335impl CouplingAnalyzer {
338 pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
340 Self {
341 current_module: module_name.clone(),
342 file_path: path.clone(),
343 metrics: ModuleMetrics::new(path, module_name),
344 dependencies: Vec::new(),
345 defined_types: HashSet::new(),
346 defined_traits: HashSet::new(),
347 defined_functions: HashMap::new(),
348 imported_types: HashMap::new(),
349 seen_dependencies: HashSet::new(),
350 usage_counts: UsageCounts::default(),
351 type_visibility: HashMap::new(),
352 current_item: None,
353 item_dependencies: Vec::new(),
354 }
355 }
356
357 pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
359 let syntax: File =
360 syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
361
362 self.visit_file(&syntax);
363
364 Ok(())
365 }
366
367 fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
369 let key = (path.clone(), usage);
370 if self.seen_dependencies.contains(&key) {
371 return;
372 }
373 self.seen_dependencies.insert(key);
374
375 self.dependencies.push(Dependency {
376 path,
377 kind,
378 line: 0,
379 usage,
380 });
381 }
382
383 fn add_item_dependency(
385 &mut self,
386 target: String,
387 dep_type: ItemDepType,
388 line: usize,
389 expression: Option<String>,
390 ) {
391 if let Some((ref source_item, source_kind)) = self.current_item {
392 let target_module = self.imported_types.get(&target).cloned().or_else(|| {
394 if self.defined_types.contains(&target)
395 || self.defined_functions.contains_key(&target)
396 {
397 Some(self.current_module.clone())
398 } else {
399 None
400 }
401 });
402
403 self.item_dependencies.push(ItemDependency {
404 source_item: source_item.clone(),
405 source_kind,
406 target,
407 target_module,
408 dep_type,
409 line,
410 expression,
411 });
412 }
413 }
414
415 fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
417 let mut paths = Vec::new();
418
419 match tree {
420 UseTree::Path(path) => {
421 let new_prefix = if prefix.is_empty() {
422 path.ident.to_string()
423 } else {
424 format!("{}::{}", prefix, path.ident)
425 };
426 paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
427 }
428 UseTree::Name(name) => {
429 let full_path = if prefix.is_empty() {
430 name.ident.to_string()
431 } else {
432 format!("{}::{}", prefix, name.ident)
433 };
434 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
435 DependencyKind::InternalUse
436 } else {
437 DependencyKind::ExternalUse
438 };
439 paths.push((full_path, kind));
440 }
441 UseTree::Rename(rename) => {
442 let full_path = if prefix.is_empty() {
443 rename.ident.to_string()
444 } else {
445 format!("{}::{}", prefix, rename.ident)
446 };
447 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
448 DependencyKind::InternalUse
449 } else {
450 DependencyKind::ExternalUse
451 };
452 paths.push((full_path, kind));
453 }
454 UseTree::Glob(_) => {
455 let full_path = format!("{}::*", prefix);
456 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
457 DependencyKind::InternalUse
458 } else {
459 DependencyKind::ExternalUse
460 };
461 paths.push((full_path, kind));
462 }
463 UseTree::Group(group) => {
464 for item in &group.items {
465 paths.extend(self.extract_use_paths(item, prefix));
466 }
467 }
468 }
469
470 paths
471 }
472
473 fn extract_type_name(&self, ty: &Type) -> Option<String> {
475 match ty {
476 Type::Path(type_path) => {
477 let segments: Vec<_> = type_path
478 .path
479 .segments
480 .iter()
481 .map(|s| s.ident.to_string())
482 .collect();
483 Some(segments.join("::"))
484 }
485 Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
486 Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
487 Type::Array(array_type) => self.extract_type_name(&array_type.elem),
488 Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
489 Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
490 Type::Group(group_type) => self.extract_type_name(&group_type.elem),
491 _ => None,
492 }
493 }
494
495 fn analyze_signature(&mut self, sig: &Signature) {
497 for arg in &sig.inputs {
499 if let FnArg::Typed(pat_type) = arg
500 && let Some(type_name) = self.extract_type_name(&pat_type.ty)
501 && !self.is_primitive_type(&type_name)
502 {
503 self.add_dependency(
504 type_name,
505 DependencyKind::TypeRef,
506 UsageContext::FunctionParameter,
507 );
508 }
509 }
510
511 if let ReturnType::Type(_, ty) = &sig.output
513 && let Some(type_name) = self.extract_type_name(ty)
514 && !self.is_primitive_type(&type_name)
515 {
516 self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
517 }
518 }
519
520 fn is_primitive_type(&self, type_name: &str) -> bool {
522 if matches!(
524 type_name,
525 "bool"
526 | "char"
527 | "str"
528 | "u8"
529 | "u16"
530 | "u32"
531 | "u64"
532 | "u128"
533 | "usize"
534 | "i8"
535 | "i16"
536 | "i32"
537 | "i64"
538 | "i128"
539 | "isize"
540 | "f32"
541 | "f64"
542 | "String"
543 | "Self"
544 | "()"
545 | "Option"
546 | "Result"
547 | "Vec"
548 | "Box"
549 | "Rc"
550 | "Arc"
551 | "RefCell"
552 | "Cell"
553 | "Mutex"
554 | "RwLock"
555 ) {
556 return true;
557 }
558
559 if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
562 return true;
563 }
564
565 if type_name.starts_with("self") || type_name == "self" {
567 return true;
568 }
569
570 false
571 }
572}
573
574impl<'ast> Visit<'ast> for CouplingAnalyzer {
575 fn visit_item_use(&mut self, node: &'ast ItemUse) {
576 let paths = self.extract_use_paths(&node.tree, "");
577
578 for (path, kind) in paths {
579 if path == "self" || path.starts_with("self::") {
581 continue;
582 }
583
584 if let Some(type_name) = path.split("::").last() {
586 self.imported_types
587 .insert(type_name.to_string(), path.clone());
588 }
589
590 self.add_dependency(path.clone(), kind, UsageContext::Import);
591
592 if kind == DependencyKind::InternalUse {
594 if !self.metrics.internal_deps.contains(&path) {
595 self.metrics.internal_deps.push(path.clone());
596 }
597 } else if kind == DependencyKind::ExternalUse {
598 let crate_name = path.split("::").next().unwrap_or(&path).to_string();
600 if !self.metrics.external_deps.contains(&crate_name) {
601 self.metrics.external_deps.push(crate_name);
602 }
603 }
604 }
605
606 syn::visit::visit_item_use(self, node);
607 }
608
609 fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
610 if let Some((_, trait_path, _)) = &node.trait_ {
611 self.metrics.trait_impl_count += 1;
613
614 let trait_name: String = trait_path
616 .segments
617 .iter()
618 .map(|s| s.ident.to_string())
619 .collect::<Vec<_>>()
620 .join("::");
621
622 self.add_dependency(
623 trait_name,
624 DependencyKind::TraitImpl,
625 UsageContext::TraitBound,
626 );
627 self.usage_counts.trait_bounds += 1;
628 } else {
629 self.metrics.inherent_impl_count += 1;
631
632 if let Some(type_name) = self.extract_type_name(&node.self_ty)
634 && !self.defined_types.contains(&type_name)
635 {
636 self.add_dependency(
637 type_name,
638 DependencyKind::InherentImpl,
639 UsageContext::InherentImplBlock,
640 );
641 }
642 }
643 syn::visit::visit_item_impl(self, node);
644 }
645
646 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
647 let fn_name = node.sig.ident.to_string();
649 let visibility = convert_visibility(&node.vis);
650 self.defined_functions.insert(fn_name.clone(), visibility);
651
652 if has_test_attribute(&node.attrs) {
654 self.metrics.test_function_count += 1;
655 }
656
657 let mut param_count = 0;
659 let mut primitive_param_count = 0;
660 let mut param_types = Vec::new();
661
662 for arg in &node.sig.inputs {
663 if let FnArg::Typed(pat_type) = arg {
664 param_count += 1;
665 if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
666 param_types.push(type_name.clone());
667 if self.is_primitive_type(&type_name) {
668 primitive_param_count += 1;
669 }
670 }
671 }
672 }
673
674 self.metrics.add_function_definition_full(
676 fn_name.clone(),
677 visibility,
678 param_count,
679 primitive_param_count,
680 param_types,
681 );
682
683 let previous_item = self.current_item.take();
685 self.current_item = Some((fn_name, ItemKind::Function));
686
687 self.analyze_signature(&node.sig);
689 syn::visit::visit_item_fn(self, node);
690
691 self.current_item = previous_item;
693 }
694
695 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
696 let name = node.ident.to_string();
697 let visibility = convert_visibility(&node.vis);
698
699 self.defined_types.insert(name.clone());
700 self.type_visibility.insert(name.clone(), visibility);
701
702 let (is_newtype, inner_type) = match &node.fields {
704 syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
705 let inner = fields
706 .unnamed
707 .first()
708 .and_then(|f| self.extract_type_name(&f.ty));
709 (true, inner)
710 }
711 _ => (false, None),
712 };
713
714 let has_serde_derive = node.attrs.iter().any(|attr| {
716 if attr.path().is_ident("derive")
717 && let Ok(nested) = attr.parse_args_with(
718 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
719 )
720 {
721 return nested.iter().any(|path| {
722 let path_str = path
723 .segments
724 .iter()
725 .map(|s| s.ident.to_string())
726 .collect::<Vec<_>>()
727 .join("::");
728 path_str == "Serialize"
729 || path_str == "Deserialize"
730 || path_str == "serde::Serialize"
731 || path_str == "serde::Deserialize"
732 });
733 }
734 false
735 });
736
737 let (total_field_count, public_field_count) = match &node.fields {
739 syn::Fields::Named(fields) => {
740 let total = fields.named.len();
741 let public = fields
742 .named
743 .iter()
744 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
745 .count();
746 (total, public)
747 }
748 syn::Fields::Unnamed(fields) => {
749 let total = fields.unnamed.len();
750 let public = fields
751 .unnamed
752 .iter()
753 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
754 .count();
755 (total, public)
756 }
757 syn::Fields::Unit => (0, 0),
758 };
759
760 self.metrics.add_type_definition_full(
762 name,
763 visibility,
764 false, is_newtype,
766 inner_type,
767 has_serde_derive,
768 public_field_count,
769 total_field_count,
770 );
771
772 match &node.fields {
774 syn::Fields::Named(fields) => {
775 self.metrics.type_usage_count += fields.named.len();
776 for field in &fields.named {
777 if let Some(type_name) = self.extract_type_name(&field.ty)
778 && !self.is_primitive_type(&type_name)
779 {
780 self.add_dependency(
781 type_name,
782 DependencyKind::TypeRef,
783 UsageContext::TypeParameter,
784 );
785 self.usage_counts.type_parameters += 1;
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 syn::visit::visit_item_struct(self, node);
805 }
806
807 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
808 let name = node.ident.to_string();
809 let visibility = convert_visibility(&node.vis);
810
811 self.defined_types.insert(name.clone());
812 self.type_visibility.insert(name.clone(), visibility);
813
814 self.metrics.add_type_definition(name, visibility, false);
816
817 for variant in &node.variants {
819 match &variant.fields {
820 syn::Fields::Named(fields) => {
821 for field in &fields.named {
822 if let Some(type_name) = self.extract_type_name(&field.ty)
823 && !self.is_primitive_type(&type_name)
824 {
825 self.add_dependency(
826 type_name,
827 DependencyKind::TypeRef,
828 UsageContext::TypeParameter,
829 );
830 }
831 }
832 }
833 syn::Fields::Unnamed(fields) => {
834 for field in &fields.unnamed {
835 if let Some(type_name) = self.extract_type_name(&field.ty)
836 && !self.is_primitive_type(&type_name)
837 {
838 self.add_dependency(
839 type_name,
840 DependencyKind::TypeRef,
841 UsageContext::TypeParameter,
842 );
843 }
844 }
845 }
846 syn::Fields::Unit => {}
847 }
848 }
849 syn::visit::visit_item_enum(self, node);
850 }
851
852 fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
853 let name = node.ident.to_string();
854 let visibility = convert_visibility(&node.vis);
855
856 self.defined_traits.insert(name.clone());
857 self.type_visibility.insert(name.clone(), visibility);
858
859 self.metrics.add_type_definition(name, visibility, true);
861
862 self.metrics.trait_impl_count += 1;
863 syn::visit::visit_item_trait(self, node);
864 }
865
866 fn visit_item_mod(&mut self, node: &'ast ItemMod) {
867 if is_test_module(node) {
869 self.metrics.is_test_module = true;
870 }
871
872 if node.content.is_some() {
873 self.metrics.internal_deps.push(node.ident.to_string());
874 }
875 syn::visit::visit_item_mod(self, node);
876 }
877
878 fn visit_expr_field(&mut self, node: &'ast ExprField) {
880 let field_name = match &node.member {
881 syn::Member::Named(ident) => ident.to_string(),
882 syn::Member::Unnamed(idx) => format!("{}", idx.index),
883 };
884
885 if let Expr::Path(path_expr) = &*node.base {
887 let base_name = path_expr
888 .path
889 .segments
890 .iter()
891 .map(|s| s.ident.to_string())
892 .collect::<Vec<_>>()
893 .join("::");
894
895 let full_path = self
897 .imported_types
898 .get(&base_name)
899 .cloned()
900 .unwrap_or(base_name.clone());
901
902 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
903 self.add_dependency(
904 full_path.clone(),
905 DependencyKind::TypeRef,
906 UsageContext::FieldAccess,
907 );
908 self.usage_counts.field_accesses += 1;
909 }
910
911 let expr = format!("{}.{}", base_name, field_name);
913 self.add_item_dependency(
914 format!("{}.{}", full_path, field_name),
915 ItemDepType::FieldAccess,
916 0,
917 Some(expr),
918 );
919 }
920 syn::visit::visit_expr_field(self, node);
921 }
922
923 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
925 let method_name = node.method.to_string();
926
927 if let Expr::Path(path_expr) = &*node.receiver {
929 let receiver_name = path_expr
930 .path
931 .segments
932 .iter()
933 .map(|s| s.ident.to_string())
934 .collect::<Vec<_>>()
935 .join("::");
936
937 let full_path = self
938 .imported_types
939 .get(&receiver_name)
940 .cloned()
941 .unwrap_or(receiver_name.clone());
942
943 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
944 self.add_dependency(
945 full_path.clone(),
946 DependencyKind::TypeRef,
947 UsageContext::MethodCall,
948 );
949 self.usage_counts.method_calls += 1;
950 }
951
952 let expr = format!("{}.{}()", receiver_name, method_name);
954 self.add_item_dependency(
955 format!("{}::{}", full_path, method_name),
956 ItemDepType::MethodCall,
957 0, Some(expr),
959 );
960 }
961 syn::visit::visit_expr_method_call(self, node);
962 }
963
964 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
966 if let Expr::Path(path_expr) = &*node.func {
967 let path_str = path_expr
968 .path
969 .segments
970 .iter()
971 .map(|s| s.ident.to_string())
972 .collect::<Vec<_>>()
973 .join("::");
974
975 if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
977 {
978 let full_path = self
979 .imported_types
980 .get(&path_str)
981 .cloned()
982 .unwrap_or(path_str.clone());
983
984 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
985 self.add_dependency(
986 full_path.clone(),
987 DependencyKind::TypeRef,
988 UsageContext::FunctionCall,
989 );
990 self.usage_counts.function_calls += 1;
991 }
992
993 self.add_item_dependency(
995 full_path,
996 ItemDepType::FunctionCall,
997 0,
998 Some(format!("{}()", path_str)),
999 );
1000 } else {
1001 self.add_item_dependency(
1003 path_str.clone(),
1004 ItemDepType::FunctionCall,
1005 0,
1006 Some(format!("{}()", path_str)),
1007 );
1008 }
1009 }
1010 syn::visit::visit_expr_call(self, node);
1011 }
1012
1013 fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
1015 let struct_name = node
1016 .path
1017 .segments
1018 .iter()
1019 .map(|s| s.ident.to_string())
1020 .collect::<Vec<_>>()
1021 .join("::");
1022
1023 if struct_name == "Self" || struct_name.starts_with("Self::") {
1025 syn::visit::visit_expr_struct(self, node);
1026 return;
1027 }
1028
1029 let full_path = self
1030 .imported_types
1031 .get(&struct_name)
1032 .cloned()
1033 .unwrap_or(struct_name.clone());
1034
1035 if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
1036 self.add_dependency(
1037 full_path,
1038 DependencyKind::TypeRef,
1039 UsageContext::StructConstruction,
1040 );
1041 self.usage_counts.struct_constructions += 1;
1042 }
1043 syn::visit::visit_expr_struct(self, node);
1044 }
1045}
1046
1047#[derive(Debug, Clone)]
1051struct AnalyzedFile {
1052 module_name: String,
1053 #[allow(dead_code)]
1054 file_path: PathBuf,
1055 metrics: ModuleMetrics,
1056 dependencies: Vec<Dependency>,
1057 type_visibility: HashMap<String, Visibility>,
1059 item_dependencies: Vec<ItemDependency>,
1061}
1062
1063pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1065 analyze_project_parallel(path)
1066}
1067
1068fn is_path_excluded(file_path: &Path, exclude_base: &Path, config: &CompiledConfig) -> bool {
1074 let normalized_file = normalize_exclude_path(file_path);
1075 let normalized_base = normalize_exclude_path(exclude_base);
1076 let relative = normalized_file
1077 .strip_prefix(&normalized_base)
1078 .unwrap_or(&normalized_file);
1079 let relative_str = relative.to_string_lossy().replace('\\', "/");
1080 config.should_exclude(&relative_str)
1081}
1082
1083fn path_for_config_matching(file_path: &Path, config: &CompiledConfig) -> String {
1085 let normalized_file = normalize_exclude_path(file_path);
1086 let path = config
1087 .config_root()
1088 .map(normalize_exclude_path)
1089 .and_then(|base| {
1090 normalized_file
1091 .strip_prefix(base)
1092 .ok()
1093 .map(Path::to_path_buf)
1094 })
1095 .unwrap_or(normalized_file);
1096
1097 path.to_string_lossy().replace('\\', "/")
1098}
1099
1100fn normalize_exclude_path(path: &Path) -> PathBuf {
1105 let absolute = if path.is_absolute() {
1106 path.to_path_buf()
1107 } else {
1108 std::env::current_dir()
1109 .map(|cwd| cwd.join(path))
1110 .unwrap_or_else(|_| path.to_path_buf())
1111 };
1112
1113 let mut normalized = PathBuf::new();
1114 for component in absolute.components() {
1115 match component {
1116 Component::CurDir => {}
1117 Component::ParentDir => {
1118 normalized.pop();
1119 }
1120 other => normalized.push(other.as_os_str()),
1121 }
1122 }
1123
1124 normalized
1125}
1126
1127fn rs_files(dir: &Path) -> impl Iterator<Item = PathBuf> {
1133 WalkDir::new(dir)
1134 .follow_links(true)
1135 .into_iter()
1136 .filter_map(|e| e.ok())
1137 .filter(move |entry| {
1138 let file_path = entry.path();
1139 let file_path = file_path.strip_prefix(dir).unwrap_or(file_path);
1144
1145 !file_path.components().any(|c| {
1147 let s = c.as_os_str().to_string_lossy();
1148 s == "target" || s.starts_with('.')
1149 }) && file_path.extension() == Some(OsStr::new("rs"))
1150 })
1151 .map(|e| e.path().to_path_buf())
1152}
1153
1154pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1159 analyze_project_parallel_with_config(path, &CompiledConfig::empty())
1160}
1161
1162pub fn analyze_project_parallel_with_config(
1164 path: &Path,
1165 config: &CompiledConfig,
1166) -> Result<ProjectMetrics, AnalyzerError> {
1167 if !path.exists() {
1168 return Err(AnalyzerError::InvalidPath(path.display().to_string()));
1169 }
1170
1171 let exclude_base = config.config_root().unwrap_or(path);
1172
1173 let file_paths: Vec<PathBuf> = rs_files(path)
1175 .filter(|fp| !is_path_excluded(fp, exclude_base, config))
1176 .collect();
1177
1178 let num_threads = rayon::current_num_threads();
1182 let file_count = file_paths.len();
1183
1184 let chunk_size = if file_count < num_threads * 2 {
1187 1 } else {
1189 (file_count / (num_threads * 4)).max(1)
1192 };
1193
1194 let analyzed_results: Vec<_> = file_paths
1196 .par_chunks(chunk_size)
1197 .flat_map(|chunk| {
1198 chunk
1199 .iter()
1200 .filter_map(|file_path| match analyze_rust_file_full(file_path) {
1201 Ok(result) => {
1202 let module_path = file_path_to_module_path(file_path, path);
1204 let original_module_name = result.metrics.name.clone();
1205 let module_name = if module_path.is_empty() {
1206 original_module_name.clone()
1208 } else {
1209 module_path
1210 };
1211
1212 let item_dependencies = result
1214 .item_dependencies
1215 .into_iter()
1216 .map(|mut dep| {
1217 if dep.target_module.as_ref() == Some(&original_module_name) {
1218 dep.target_module = Some(module_name.clone());
1219 }
1220 dep
1221 })
1222 .collect();
1223
1224 Some(AnalyzedFile {
1225 module_name: module_name.clone(),
1226 file_path: file_path.clone(),
1227 metrics: {
1228 let mut module_metrics = result.metrics;
1229 module_metrics.name = module_name;
1230 module_metrics
1231 },
1232 dependencies: result.dependencies,
1233 type_visibility: result.type_visibility,
1234 item_dependencies,
1235 })
1236 }
1237 Err(e) => {
1238 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1239 None
1240 }
1241 })
1242 .collect::<Vec<_>>()
1243 })
1244 .collect();
1245
1246 let module_names: HashSet<String> = analyzed_results
1248 .iter()
1249 .map(|a| a.module_name.clone())
1250 .collect();
1251
1252 let mut project = ProjectMetrics::new();
1254 project.total_files = analyzed_results.len();
1255 project.parse_failures = file_paths.len().saturating_sub(analyzed_results.len());
1256
1257 for analyzed in &analyzed_results {
1259 for (type_name, visibility) in &analyzed.type_visibility {
1260 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1261 }
1262 }
1263
1264 for analyzed in &analyzed_results {
1266 let mut metrics = analyzed.metrics.clone();
1268 metrics.item_dependencies = analyzed.item_dependencies.clone();
1269 metrics.subdomain =
1270 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1271 project.add_module(metrics);
1272
1273 for dep in &analyzed.dependencies {
1274 if !is_valid_dependency_path(&dep.path) {
1276 continue;
1277 }
1278
1279 let target_module =
1281 resolve_target_module(&dep.path, &analyzed.module_name, &module_names);
1282
1283 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1285 continue;
1286 }
1287
1288 let distance = calculate_distance(&dep.path, &module_names);
1290
1291 let strength = dep.usage.to_strength();
1293
1294 let volatility = Volatility::Low;
1296
1297 let target_type = dep.path.split("::").last().unwrap_or(&dep.path);
1299 let visibility = project
1300 .get_type_visibility(target_type)
1301 .unwrap_or(Visibility::Public); let coupling = CouplingMetrics::with_location(
1305 analyzed.module_name.clone(),
1306 target_module.clone(),
1307 strength,
1308 distance,
1309 volatility,
1310 visibility,
1311 analyzed.file_path.clone(),
1312 dep.line,
1313 );
1314
1315 project.add_coupling(coupling);
1316 }
1317 }
1318
1319 project.update_coupling_visibility();
1321
1322 Ok(project)
1323}
1324
1325pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1327 analyze_workspace_with_config(path, &CompiledConfig::empty())
1328}
1329
1330pub fn analyze_workspace_with_config(
1332 path: &Path,
1333 config: &CompiledConfig,
1334) -> Result<ProjectMetrics, AnalyzerError> {
1335 let workspace = match WorkspaceInfo::from_path(path) {
1337 Ok(ws) => Some(ws),
1338 Err(e) => {
1339 eprintln!("Note: Could not load workspace metadata: {}", e);
1340 eprintln!("Falling back to basic analysis...");
1341 None
1342 }
1343 };
1344
1345 if let Some(ws) = workspace {
1346 analyze_with_workspace(path, &ws, config)
1347 } else {
1348 analyze_project_parallel_with_config(path, config)
1350 }
1351}
1352
1353fn analyze_with_workspace(
1355 _project_root: &Path,
1356 workspace: &WorkspaceInfo,
1357 config: &CompiledConfig,
1358) -> Result<ProjectMetrics, AnalyzerError> {
1359 let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1362
1363 let mut project = ProjectMetrics::new();
1364
1365 project.workspace_name = Some(
1367 workspace
1368 .root
1369 .file_name()
1370 .and_then(|n| n.to_str())
1371 .unwrap_or("workspace")
1372 .to_string(),
1373 );
1374 project.workspace_members = workspace.members.clone();
1375
1376 let mut file_crate_pairs: Vec<(PathBuf, String, PathBuf)> = Vec::new();
1379
1380 for member_name in &workspace.members {
1381 if let Some(crate_info) = workspace.get_crate(member_name) {
1382 if !crate_info.src_path.exists() {
1383 continue;
1384 }
1385
1386 let src_root = crate_info.src_path.clone();
1387 for file_path in rs_files(&crate_info.src_path) {
1388 if is_path_excluded(&file_path, exclude_base, config) {
1389 continue;
1390 }
1391 file_crate_pairs.push((
1392 file_path.to_path_buf(),
1393 member_name.clone(),
1394 src_root.clone(),
1395 ));
1396 }
1397 }
1398 }
1399
1400 let num_threads = rayon::current_num_threads();
1402 let file_count = file_crate_pairs.len();
1403 let chunk_size = if file_count < num_threads * 2 {
1404 1
1405 } else {
1406 (file_count / (num_threads * 4)).max(1)
1407 };
1408
1409 let analyzed_files: Vec<AnalyzedFileWithCrate> = file_crate_pairs
1411 .par_chunks(chunk_size)
1412 .flat_map(|chunk| {
1413 chunk
1414 .iter()
1415 .filter_map(|(file_path, crate_name, src_root)| {
1416 match analyze_rust_file_full(file_path) {
1417 Ok(result) => {
1418 let module_path = file_path_to_module_path(file_path, src_root);
1420 let original_module_name = result.metrics.name.clone();
1421 let module_name = if module_path.is_empty() {
1422 original_module_name.clone()
1424 } else {
1425 module_path
1426 };
1427
1428 let item_dependencies = result
1430 .item_dependencies
1431 .into_iter()
1432 .map(|mut dep| {
1433 if dep.target_module.as_ref() == Some(&original_module_name) {
1434 dep.target_module = Some(module_name.clone());
1435 }
1436 dep
1437 })
1438 .collect();
1439
1440 Some(AnalyzedFileWithCrate {
1441 module_name: module_name.clone(),
1442 crate_name: crate_name.clone(),
1443 file_path: file_path.clone(),
1444 metrics: {
1445 let mut module_metrics = result.metrics;
1446 module_metrics.name = module_name;
1447 module_metrics
1448 },
1449 dependencies: result.dependencies,
1450 item_dependencies,
1451 })
1452 }
1453 Err(e) => {
1454 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1455 None
1456 }
1457 }
1458 })
1459 .collect::<Vec<_>>()
1460 })
1461 .collect();
1462
1463 project.total_files = analyzed_files.len();
1464 project.parse_failures = file_crate_pairs.len().saturating_sub(analyzed_files.len());
1465
1466 let module_names: HashSet<String> = analyzed_files
1468 .iter()
1469 .map(|a| a.module_name.clone())
1470 .collect();
1471
1472 for analyzed in &analyzed_files {
1474 let mut metrics = analyzed.metrics.clone();
1476 metrics.item_dependencies = analyzed.item_dependencies.clone();
1477 metrics.subdomain =
1478 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1479 project.add_module(metrics);
1480
1481 for dep in &analyzed.dependencies {
1482 if !is_valid_dependency_path(&dep.path) {
1484 continue;
1485 }
1486
1487 let resolved_crate =
1489 resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1490
1491 let target_module =
1492 resolve_target_module(&dep.path, &analyzed.module_name, &module_names);
1493
1494 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1496 continue;
1497 }
1498
1499 let distance =
1501 calculate_distance_with_workspace(&dep.path, &analyzed.crate_name, workspace);
1502
1503 let strength = dep.usage.to_strength();
1505
1506 let volatility = Volatility::Low;
1508
1509 let mut coupling = CouplingMetrics::with_location(
1511 format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1512 if let Some(ref crate_name) = resolved_crate {
1513 format!("{}::{}", crate_name, target_module)
1514 } else {
1515 target_module.clone()
1516 },
1517 strength,
1518 distance,
1519 volatility,
1520 Visibility::Public, analyzed.file_path.clone(),
1522 dep.line,
1523 );
1524
1525 coupling.source_crate = Some(analyzed.crate_name.clone());
1527 coupling.target_crate = resolved_crate;
1528
1529 project.add_coupling(coupling);
1530 }
1531 }
1532
1533 for (crate_name, deps) in &workspace.dependency_graph {
1535 if workspace.is_workspace_member(crate_name) {
1536 for dep in deps {
1537 project
1539 .crate_dependencies
1540 .entry(crate_name.clone())
1541 .or_default()
1542 .push(dep.clone());
1543 }
1544 }
1545 }
1546
1547 Ok(project)
1548}
1549
1550fn calculate_distance_with_workspace(
1554 dep_path: &str,
1555 current_crate: &str,
1556 workspace: &WorkspaceInfo,
1557) -> Distance {
1558 if dep_path.starts_with("crate::") || dep_path.starts_with("self::") {
1559 Distance::SameModule
1561 } else if dep_path.starts_with("super::") {
1562 Distance::DifferentModule
1564 } else {
1565 if let Some(target_crate) = resolve_crate_from_path(dep_path, current_crate, workspace) {
1567 if target_crate == current_crate {
1568 Distance::SameModule
1569 } else if workspace.is_workspace_member(&target_crate) {
1570 Distance::DifferentModule
1572 } else {
1573 Distance::DifferentCrate
1575 }
1576 } else {
1577 Distance::DifferentCrate
1578 }
1579 }
1580}
1581
1582#[derive(Debug, Clone)]
1584struct AnalyzedFileWithCrate {
1585 module_name: String,
1586 crate_name: String,
1587 #[allow(dead_code)]
1588 file_path: PathBuf,
1589 metrics: ModuleMetrics,
1590 dependencies: Vec<Dependency>,
1591 item_dependencies: Vec<ItemDependency>,
1593}
1594
1595fn extract_target_module(path: &str) -> String {
1597 let cleaned = path
1599 .trim_start_matches("crate::")
1600 .trim_start_matches("super::")
1601 .trim_start_matches("::");
1602
1603 cleaned.split("::").next().unwrap_or(path).to_string()
1605}
1606
1607fn resolve_target_module(
1608 path: &str,
1609 source_module: &str,
1610 known_modules: &HashSet<String>,
1611) -> String {
1612 let resolved = resolve_relative_module_path(path, source_module);
1613 let segments: Vec<&str> = resolved
1614 .split("::")
1615 .filter(|segment| !segment.is_empty())
1616 .collect();
1617
1618 for len in (1..=segments.len()).rev() {
1619 let candidate = segments[..len].join("::");
1620 if known_modules.contains(&candidate) {
1621 return candidate;
1622 }
1623 }
1624
1625 extract_target_module(path)
1626}
1627
1628fn resolve_relative_module_path(path: &str, source_module: &str) -> String {
1629 if let Some(rest) = path.strip_prefix("crate::") {
1630 return rest.to_string();
1631 }
1632 if let Some(rest) = path.strip_prefix("self::") {
1633 return join_module_path(source_module, rest);
1634 }
1635
1636 let mut rest = path;
1637 let mut parent_levels = 0;
1638 while let Some(next) = rest.strip_prefix("super::") {
1639 parent_levels += 1;
1640 rest = next;
1641 }
1642
1643 if parent_levels == 0 {
1644 return path.trim_start_matches("::").to_string();
1645 }
1646
1647 let mut base: Vec<&str> = source_module.split("::").collect();
1648 for _ in 0..parent_levels {
1649 base.pop();
1650 }
1651 let prefix = base.join("::");
1652 join_module_path(&prefix, rest)
1653}
1654
1655fn join_module_path(prefix: &str, rest: &str) -> String {
1656 if prefix.is_empty() {
1657 rest.to_string()
1658 } else if rest.is_empty() {
1659 prefix.to_string()
1660 } else {
1661 format!("{prefix}::{rest}")
1662 }
1663}
1664
1665fn is_valid_dependency_path(path: &str) -> bool {
1667 if path.is_empty() {
1669 return false;
1670 }
1671
1672 if path == "Self" || path.starts_with("Self::") {
1674 return false;
1675 }
1676
1677 let segments: Vec<&str> = path.split("::").collect();
1678
1679 if segments.len() == 1 {
1681 let name = segments[0];
1682 if name.len() <= 8 && name.chars().all(|c| c.is_lowercase() || c == '_') {
1683 return false;
1684 }
1685 }
1686
1687 if segments.len() >= 2 {
1689 let last = segments.last().unwrap();
1690 let second_last = segments.get(segments.len() - 2).unwrap();
1691 if last == second_last {
1692 return false;
1693 }
1694 }
1695
1696 let last_segment = segments.last().unwrap_or(&path);
1698 let common_locals = [
1699 "request",
1700 "response",
1701 "result",
1702 "content",
1703 "config",
1704 "proto",
1705 "domain",
1706 "info",
1707 "data",
1708 "item",
1709 "value",
1710 "error",
1711 "message",
1712 "expected",
1713 "actual",
1714 "status",
1715 "state",
1716 "context",
1717 "params",
1718 "args",
1719 "options",
1720 "settings",
1721 "violation",
1722 "page_token",
1723 ];
1724 if common_locals.contains(last_segment) && segments.len() <= 2 {
1725 return false;
1726 }
1727
1728 true
1729}
1730
1731fn calculate_distance(dep_path: &str, _known_modules: &HashSet<String>) -> Distance {
1733 if dep_path.starts_with("crate::") || dep_path.starts_with("super::") {
1734 Distance::DifferentModule
1736 } else if dep_path.starts_with("self::") {
1737 Distance::SameModule
1738 } else {
1739 Distance::DifferentCrate
1741 }
1742}
1743
1744pub struct AnalyzedFileResult {
1746 pub metrics: ModuleMetrics,
1748 pub dependencies: Vec<Dependency>,
1750 pub type_visibility: HashMap<String, Visibility>,
1752 pub item_dependencies: Vec<ItemDependency>,
1754}
1755
1756pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1758 let result = analyze_rust_file_full(path)?;
1759 Ok((result.metrics, result.dependencies))
1760}
1761
1762pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1764 let content = fs::read_to_string(path)?;
1765
1766 let module_name = path
1767 .file_stem()
1768 .and_then(|s| s.to_str())
1769 .unwrap_or("unknown")
1770 .to_string();
1771
1772 let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1773 analyzer.analyze_file(&content)?;
1774
1775 Ok(AnalyzedFileResult {
1776 metrics: analyzer.metrics,
1777 dependencies: analyzer.dependencies,
1778 type_visibility: analyzer.type_visibility,
1779 item_dependencies: analyzer.item_dependencies,
1780 })
1781}
1782
1783#[cfg(test)]
1784mod tests {
1785 use super::*;
1786
1787 #[test]
1788 fn test_analyzer_creation() {
1789 let analyzer = CouplingAnalyzer::new(
1790 "test_module".to_string(),
1791 std::path::PathBuf::from("test.rs"),
1792 );
1793 assert_eq!(analyzer.current_module, "test_module");
1794 }
1795
1796 #[test]
1797 fn test_analyze_simple_file() {
1798 let mut analyzer =
1799 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1800
1801 let code = r#"
1802 pub struct User {
1803 name: String,
1804 email: String,
1805 }
1806
1807 impl User {
1808 pub fn new(name: String, email: String) -> Self {
1809 Self { name, email }
1810 }
1811 }
1812 "#;
1813
1814 let result = analyzer.analyze_file(code);
1815 assert!(result.is_ok());
1816 assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1817 }
1818
1819 #[test]
1820 fn test_item_dependencies() {
1821 let mut analyzer =
1822 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1823
1824 let code = r#"
1825 pub struct Config {
1826 pub value: i32,
1827 }
1828
1829 pub fn process(config: Config) -> i32 {
1830 let x = config.value;
1831 helper(x)
1832 }
1833
1834 fn helper(n: i32) -> i32 {
1835 n * 2
1836 }
1837 "#;
1838
1839 let result = analyzer.analyze_file(code);
1840 assert!(result.is_ok());
1841
1842 assert!(analyzer.defined_functions.contains_key("process"));
1844 assert!(analyzer.defined_functions.contains_key("helper"));
1845
1846 println!(
1848 "Item dependencies count: {}",
1849 analyzer.item_dependencies.len()
1850 );
1851 for dep in &analyzer.item_dependencies {
1852 println!(
1853 " {} -> {} ({:?})",
1854 dep.source_item, dep.target, dep.dep_type
1855 );
1856 }
1857
1858 let process_deps: Vec<_> = analyzer
1860 .item_dependencies
1861 .iter()
1862 .filter(|d| d.source_item == "process")
1863 .collect();
1864
1865 assert!(
1866 !process_deps.is_empty(),
1867 "process function should have item dependencies"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_analyze_trait_impl() {
1873 let mut analyzer =
1874 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1875
1876 let code = r#"
1877 trait Printable {
1878 fn print(&self);
1879 }
1880
1881 struct Document;
1882
1883 impl Printable for Document {
1884 fn print(&self) {}
1885 }
1886 "#;
1887
1888 let result = analyzer.analyze_file(code);
1889 assert!(result.is_ok());
1890 assert!(analyzer.metrics.trait_impl_count >= 1);
1891 }
1892
1893 #[test]
1894 fn test_analyze_use_statements() {
1895 let mut analyzer =
1896 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1897
1898 let code = r#"
1899 use std::collections::HashMap;
1900 use serde::Serialize;
1901 use crate::utils;
1902 use crate::models::{User, Post};
1903 "#;
1904
1905 let result = analyzer.analyze_file(code);
1906 assert!(result.is_ok());
1907 assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1908 assert!(
1909 analyzer
1910 .metrics
1911 .external_deps
1912 .contains(&"serde".to_string())
1913 );
1914 assert!(!analyzer.dependencies.is_empty());
1915
1916 let internal_deps: Vec<_> = analyzer
1918 .dependencies
1919 .iter()
1920 .filter(|d| d.kind == DependencyKind::InternalUse)
1921 .collect();
1922 assert!(!internal_deps.is_empty());
1923 }
1924
1925 #[test]
1926 fn test_extract_use_paths() {
1927 let analyzer =
1928 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1929
1930 let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1932 let paths = analyzer.extract_use_paths(&tree, "");
1933 assert_eq!(paths.len(), 1);
1934 assert_eq!(paths[0].0, "std::collections::HashMap");
1935
1936 let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1938 let paths = analyzer.extract_use_paths(&tree, "");
1939 assert_eq!(paths.len(), 2);
1940 }
1941
1942 #[test]
1943 fn test_extract_target_module() {
1944 assert_eq!(extract_target_module("crate::models::user"), "models");
1945 assert_eq!(extract_target_module("super::utils"), "utils");
1946 assert_eq!(extract_target_module("std::collections"), "std");
1947 }
1948
1949 #[test]
1950 fn test_resolve_target_module_prefers_longest_known_module() {
1951 let known = HashSet::from([
1952 "balance".to_string(),
1953 "balance::issues".to_string(),
1954 "balance::score".to_string(),
1955 ]);
1956
1957 assert_eq!(
1958 resolve_target_module("crate::balance::issues::CouplingIssue", "report", &known),
1959 "balance::issues"
1960 );
1961 assert_eq!(
1962 resolve_target_module("super::issues::IssueType", "balance::coupling", &known),
1963 "balance::issues"
1964 );
1965 assert_eq!(
1966 resolve_target_module("std::collections::HashMap", "balance::coupling", &known),
1967 "std"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_field_access_detection() {
1973 let mut analyzer =
1974 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1975
1976 let code = r#"
1977 use crate::models::User;
1978
1979 fn get_name(user: &User) -> String {
1980 user.name.clone()
1981 }
1982 "#;
1983
1984 let result = analyzer.analyze_file(code);
1985 assert!(result.is_ok());
1986
1987 let _field_deps: Vec<_> = analyzer
1989 .dependencies
1990 .iter()
1991 .filter(|d| d.usage == UsageContext::FieldAccess)
1992 .collect();
1993 }
1996
1997 #[test]
1998 fn test_method_call_detection() {
1999 let mut analyzer =
2000 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2001
2002 let code = r#"
2003 fn process() {
2004 let data = String::new();
2005 data.push_str("hello");
2006 }
2007 "#;
2008
2009 let result = analyzer.analyze_file(code);
2010 assert!(result.is_ok());
2011 }
2013
2014 #[test]
2015 fn test_struct_construction_detection() {
2016 let mut analyzer =
2017 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2018
2019 let code = r#"
2020 use crate::config::Config;
2021
2022 fn create_config() {
2023 let c = Config { value: 42 };
2024 }
2025 "#;
2026
2027 let result = analyzer.analyze_file(code);
2028 assert!(result.is_ok());
2029
2030 let struct_deps: Vec<_> = analyzer
2032 .dependencies
2033 .iter()
2034 .filter(|d| d.usage == UsageContext::StructConstruction)
2035 .collect();
2036 assert!(!struct_deps.is_empty());
2037 }
2038
2039 #[test]
2040 fn test_usage_context_to_strength() {
2041 assert_eq!(
2042 UsageContext::FieldAccess.to_strength(),
2043 IntegrationStrength::Intrusive
2044 );
2045 assert_eq!(
2046 UsageContext::MethodCall.to_strength(),
2047 IntegrationStrength::Functional
2048 );
2049 assert_eq!(
2050 UsageContext::TypeParameter.to_strength(),
2051 IntegrationStrength::Model
2052 );
2053 assert_eq!(
2054 UsageContext::TraitBound.to_strength(),
2055 IntegrationStrength::Contract
2056 );
2057 }
2058
2059 #[test]
2062 fn test_rs_files_with_hidden_parent_directory() {
2063 use std::fs;
2064 use tempfile::TempDir;
2065
2066 let temp = TempDir::new().unwrap();
2069 let hidden_parent = temp.path().join(".hidden-parent");
2070 let project_dir = hidden_parent.join("myproject").join("src");
2071 fs::create_dir_all(&project_dir).unwrap();
2072
2073 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
2075 fs::write(project_dir.join("main.rs"), "fn main() {}").unwrap();
2076
2077 let files: Vec<_> = rs_files(&project_dir).collect();
2079 assert_eq!(
2080 files.len(),
2081 2,
2082 "Should find 2 .rs files in hidden parent path"
2083 );
2084
2085 let file_names: Vec<_> = files
2087 .iter()
2088 .filter_map(|p| p.file_name())
2089 .filter_map(|n| n.to_str())
2090 .collect();
2091 assert!(file_names.contains(&"lib.rs"));
2092 assert!(file_names.contains(&"main.rs"));
2093 }
2094
2095 #[test]
2097 fn test_rs_files_excludes_hidden_dirs_in_project() {
2098 use std::fs;
2099 use tempfile::TempDir;
2100
2101 let temp = TempDir::new().unwrap();
2102 let project_dir = temp.path().join("myproject").join("src");
2103 let hidden_dir = project_dir.join(".hidden");
2104 fs::create_dir_all(&hidden_dir).unwrap();
2105
2106 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
2108 fs::write(hidden_dir.join("secret.rs"), "fn secret() {}").unwrap();
2109
2110 let files: Vec<_> = rs_files(&project_dir).collect();
2112 assert_eq!(
2113 files.len(),
2114 1,
2115 "Should find only 1 .rs file (excluding .hidden/)"
2116 );
2117
2118 let file_names: Vec<_> = files
2119 .iter()
2120 .filter_map(|p| p.file_name())
2121 .filter_map(|n| n.to_str())
2122 .collect();
2123 assert!(file_names.contains(&"lib.rs"));
2124 assert!(!file_names.contains(&"secret.rs"));
2125 }
2126
2127 #[test]
2129 fn test_rs_files_excludes_target_directory() {
2130 use std::fs;
2131 use tempfile::TempDir;
2132
2133 let temp = TempDir::new().unwrap();
2134 let project_dir = temp.path().join("myproject");
2135 let src_dir = project_dir.join("src");
2136 let target_dir = project_dir.join("target").join("debug");
2137 fs::create_dir_all(&src_dir).unwrap();
2138 fs::create_dir_all(&target_dir).unwrap();
2139
2140 fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
2142 fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
2143
2144 let files: Vec<_> = rs_files(&project_dir).collect();
2146 assert_eq!(
2147 files.len(),
2148 1,
2149 "Should find only 1 .rs file (excluding target/)"
2150 );
2151
2152 let file_names: Vec<_> = files
2153 .iter()
2154 .filter_map(|p| p.file_name())
2155 .filter_map(|n| n.to_str())
2156 .collect();
2157 assert!(file_names.contains(&"lib.rs"));
2158 assert!(!file_names.contains(&"generated.rs"));
2159 }
2160
2161 #[test]
2162 fn test_file_path_to_module_path_nested() {
2163 let src_root = Path::new("/project/src");
2165 let file_path = Path::new("/project/src/level/enemy/spawner.rs");
2166 assert_eq!(
2167 file_path_to_module_path(file_path, src_root),
2168 "level::enemy::spawner"
2169 );
2170 }
2171
2172 #[test]
2173 fn test_file_path_to_module_path_lib() {
2174 let src_root = Path::new("/project/src");
2176 let file_path = Path::new("/project/src/lib.rs");
2177 assert_eq!(file_path_to_module_path(file_path, src_root), "");
2178 }
2179
2180 #[test]
2181 fn test_file_path_to_module_path_main() {
2182 let src_root = Path::new("/project/src");
2184 let file_path = Path::new("/project/src/main.rs");
2185 assert_eq!(file_path_to_module_path(file_path, src_root), "");
2186 }
2187
2188 #[test]
2189 fn test_file_path_to_module_path_mod() {
2190 let src_root = Path::new("/project/src");
2192 let file_path = Path::new("/project/src/level/mod.rs");
2193 assert_eq!(file_path_to_module_path(file_path, src_root), "level");
2194 }
2195
2196 #[test]
2197 fn test_file_path_to_module_path_deeply_nested_mod() {
2198 let src_root = Path::new("/project/src");
2200 let file_path = Path::new("/project/src/a/b/c/mod.rs");
2201 assert_eq!(file_path_to_module_path(file_path, src_root), "a::b::c");
2202 }
2203
2204 #[test]
2205 fn test_file_path_to_module_path_simple() {
2206 let src_root = Path::new("/project/src");
2208 let file_path = Path::new("/project/src/utils.rs");
2209 assert_eq!(file_path_to_module_path(file_path, src_root), "utils");
2210 }
2211
2212 #[test]
2213 fn test_file_path_to_module_path_two_levels() {
2214 let src_root = Path::new("/project/src");
2216 let file_path = Path::new("/project/src/foo/bar.rs");
2217 assert_eq!(file_path_to_module_path(file_path, src_root), "foo::bar");
2218 }
2219
2220 #[test]
2221 fn test_file_path_to_module_path_bin() {
2222 let src_root = Path::new("/project/src");
2224 let file_path = Path::new("/project/src/bin/cli.rs");
2225 assert_eq!(file_path_to_module_path(file_path, src_root), "bin::cli");
2226 }
2227
2228 #[test]
2229 fn test_file_path_to_module_path_mismatched_root() {
2230 let src_root = Path::new("/other/src");
2233 let file_path = Path::new("/project/src/utils.rs");
2234 let result = file_path_to_module_path(file_path, src_root);
2236 assert!(result.contains("utils"));
2238 }
2239
2240 #[test]
2241 fn test_has_test_attribute_with_test() {
2242 let code = r#"
2243 #[test]
2244 fn my_test() {}
2245 "#;
2246 let syntax: syn::File = syn::parse_str(code).unwrap();
2247 if let syn::Item::Fn(func) = &syntax.items[0] {
2248 assert!(has_test_attribute(&func.attrs));
2249 } else {
2250 panic!("Expected function");
2251 }
2252 }
2253
2254 #[test]
2255 fn test_has_test_attribute_without_test() {
2256 let code = r#"
2257 fn regular_fn() {}
2258 "#;
2259 let syntax: syn::File = syn::parse_str(code).unwrap();
2260 if let syn::Item::Fn(func) = &syntax.items[0] {
2261 assert!(!has_test_attribute(&func.attrs));
2262 } else {
2263 panic!("Expected function");
2264 }
2265 }
2266
2267 #[test]
2268 fn test_has_cfg_test_attribute_with_cfg_test() {
2269 let code = r#"
2270 #[cfg(test)]
2271 mod tests {}
2272 "#;
2273 let syntax: syn::File = syn::parse_str(code).unwrap();
2274 if let syn::Item::Mod(module) = &syntax.items[0] {
2275 assert!(has_cfg_test_attribute(&module.attrs));
2276 } else {
2277 panic!("Expected module");
2278 }
2279 }
2280
2281 #[test]
2282 fn test_has_cfg_test_attribute_without_cfg_test() {
2283 let code = r#"
2284 mod regular_mod {}
2285 "#;
2286 let syntax: syn::File = syn::parse_str(code).unwrap();
2287 if let syn::Item::Mod(module) = &syntax.items[0] {
2288 assert!(!has_cfg_test_attribute(&module.attrs));
2289 } else {
2290 panic!("Expected module");
2291 }
2292 }
2293
2294 #[test]
2295 fn test_has_cfg_test_attribute_with_other_cfg() {
2296 let code = r#"
2297 #[cfg(feature = "foo")]
2298 mod feature_mod {}
2299 "#;
2300 let syntax: syn::File = syn::parse_str(code).unwrap();
2301 if let syn::Item::Mod(module) = &syntax.items[0] {
2302 assert!(!has_cfg_test_attribute(&module.attrs));
2303 } else {
2304 panic!("Expected module");
2305 }
2306 }
2307
2308 #[test]
2309 fn test_is_test_module_named_tests() {
2310 let code = r#"
2311 mod tests {}
2312 "#;
2313 let syntax: syn::File = syn::parse_str(code).unwrap();
2314 if let syn::Item::Mod(module) = &syntax.items[0] {
2315 assert!(is_test_module(module));
2316 } else {
2317 panic!("Expected module");
2318 }
2319 }
2320
2321 #[test]
2322 fn test_is_test_module_with_cfg_test() {
2323 let code = r#"
2324 #[cfg(test)]
2325 mod my_tests {}
2326 "#;
2327 let syntax: syn::File = syn::parse_str(code).unwrap();
2328 if let syn::Item::Mod(module) = &syntax.items[0] {
2329 assert!(is_test_module(module));
2330 } else {
2331 panic!("Expected module");
2332 }
2333 }
2334
2335 #[test]
2336 fn test_is_test_module_regular_module() {
2337 let code = r#"
2338 mod utils {}
2339 "#;
2340 let syntax: syn::File = syn::parse_str(code).unwrap();
2341 if let syn::Item::Mod(module) = &syntax.items[0] {
2342 assert!(!is_test_module(module));
2343 } else {
2344 panic!("Expected module");
2345 }
2346 }
2347
2348 #[test]
2354 fn test_analyze_project_parallel_applies_exclude_patterns() {
2355 use crate::config::{CompiledConfig, CouplingConfig};
2356
2357 let tmp = tempfile::tempdir().expect("create tempdir");
2358 let root = tmp.path();
2359 let src = root.join("src");
2360 let generated = src.join("generated");
2361 std::fs::create_dir_all(&generated).expect("create generated dir");
2362 std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2363 .expect("write lib.rs");
2364 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2365 .expect("write generated/mod.rs");
2366
2367 let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2369 .expect("baseline analysis");
2370 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2371 assert!(
2372 baseline.modules.keys().any(|k| k.contains("generated")),
2373 "baseline must include the generated module; saw {:?}",
2374 baseline.modules.keys().collect::<Vec<_>>()
2375 );
2376
2377 let toml = r#"
2379 [analysis]
2380 exclude = ["src/generated/*", "src/generated/**"]
2381 "#;
2382 let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2383 let compiled = CompiledConfig::from_config(config).expect("compile config");
2384 let filtered =
2385 analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2386 assert_eq!(
2387 filtered.total_files, 1,
2388 "generated file should be excluded from analysis"
2389 );
2390 assert!(
2391 !filtered.modules.keys().any(|k| k.contains("generated")),
2392 "no generated module should remain; saw {:?}",
2393 filtered.modules.keys().collect::<Vec<_>>()
2394 );
2395 }
2396
2397 #[test]
2400 fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2401 use crate::config::{CompiledConfig, load_compiled_config};
2402
2403 let current_dir = std::env::current_dir().expect("get current dir");
2404 let target_dir = current_dir.join("target");
2405 let tmp = tempfile::Builder::new()
2406 .prefix("issue39-workspace-")
2407 .tempdir_in(&target_dir)
2408 .expect("create tempdir in target");
2409 let root = tmp.path();
2410 let src = root.join("src");
2411 let generated = src.join("generated");
2412 std::fs::create_dir_all(&generated).expect("create generated dir");
2413 std::fs::write(
2414 root.join("Cargo.toml"),
2415 r#"[package]
2416name = "coupling-fixture-exclude"
2417version = "0.1.0"
2418edition = "2024"
2419"#,
2420 )
2421 .expect("write Cargo.toml");
2422 std::fs::write(
2423 root.join(".coupling.toml"),
2424 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2425 )
2426 .expect("write .coupling.toml");
2427 std::fs::write(
2428 src.join("lib.rs"),
2429 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2430 )
2431 .expect("write lib.rs");
2432 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2433 .expect("write generated/mod.rs");
2434
2435 let relative_src = src
2436 .strip_prefix(¤t_dir)
2437 .expect("temp crate should be under current dir");
2438
2439 let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2440 .expect("baseline workspace analysis");
2441 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2442 assert!(
2443 baseline.modules.keys().any(|k| k.contains("generated")),
2444 "baseline must include the generated module; saw {:?}",
2445 baseline.modules.keys().collect::<Vec<_>>()
2446 );
2447
2448 let compiled = load_compiled_config(relative_src).expect("load compiled config");
2449 let filtered = analyze_workspace_with_config(relative_src, &compiled)
2450 .expect("filtered workspace analysis");
2451 assert_eq!(
2452 filtered.total_files, 1,
2453 "generated file should be excluded from workspace analysis"
2454 );
2455 assert!(
2456 !filtered.modules.keys().any(|k| k.contains("generated")),
2457 "no generated module should remain; saw {:?}",
2458 filtered.modules.keys().collect::<Vec<_>>()
2459 );
2460 }
2461
2462 #[test]
2465 fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2466 use crate::config::{CompiledConfig, load_compiled_config};
2467
2468 let tmp = tempfile::tempdir().expect("create tempdir");
2469 let root = tmp.path();
2470 let src = root.join("src");
2471 let generated = src.join("generated");
2472 std::fs::create_dir_all(&generated).expect("create generated dir");
2473 std::fs::write(
2474 root.join(".coupling.toml"),
2475 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2476 )
2477 .expect("write .coupling.toml");
2478 std::fs::write(
2479 src.join("lib.rs"),
2480 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2481 )
2482 .expect("write lib.rs");
2483 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2484 .expect("write generated/mod.rs");
2485
2486 let baseline = analyze_workspace_with_config(&src, &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 compiled = load_compiled_config(&src).expect("load compiled config");
2496 let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2497 assert_eq!(
2498 filtered.total_files, 1,
2499 "generated file should be excluded from fallback analysis"
2500 );
2501 assert!(
2502 !filtered.modules.keys().any(|k| k.contains("generated")),
2503 "no generated module should remain; saw {:?}",
2504 filtered.modules.keys().collect::<Vec<_>>()
2505 );
2506 }
2507}