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 join_module_path, normalize_exclude_path, rs_files, rs_files_excluding_nested_packages,
23};
24use crate::metrics::coupling::CouplingMetrics;
25use crate::metrics::dimensions::{Distance, 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
1159 for analyzed in &analyzed_results {
1161 for (type_name, visibility) in &analyzed.type_visibility {
1162 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1163 }
1164 }
1165
1166 for analyzed in &analyzed_results {
1168 let mut metrics = analyzed.metrics.clone();
1170 metrics.item_dependencies = analyzed.item_dependencies.clone();
1171 metrics.subdomain =
1172 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1173 project.add_module(metrics);
1174
1175 for dep in &analyzed.dependencies {
1176 if !is_valid_dependency_path(&dep.path) {
1178 continue;
1179 }
1180
1181 let target_module =
1183 resolve_target_module(&dep.path, &analyzed.module_name, &module_names);
1184
1185 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1187 continue;
1188 }
1189
1190 let distance = calculate_distance(&dep.path, &module_names);
1192
1193 let strength = dep.usage.to_strength();
1195
1196 let volatility = Volatility::Low;
1198
1199 let target_type = dep.path.split("::").last().unwrap_or(&dep.path);
1201 let visibility = project
1202 .get_type_visibility(target_type)
1203 .unwrap_or(Visibility::Public); let coupling = CouplingMetrics::with_location(
1207 analyzed.module_name.clone(),
1208 target_module.clone(),
1209 strength,
1210 distance,
1211 volatility,
1212 visibility,
1213 analyzed.file_path.clone(),
1214 dep.line,
1215 );
1216
1217 project.add_coupling(coupling);
1218 }
1219 }
1220
1221 project.update_coupling_visibility();
1223
1224 Ok(project)
1225}
1226
1227pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1229 analyze_workspace_with_config(path, &CompiledConfig::empty())
1230}
1231
1232pub fn analyze_workspace_with_config(
1234 path: &Path,
1235 config: &CompiledConfig,
1236) -> Result<ProjectMetrics, AnalyzerError> {
1237 let workspace = match WorkspaceInfo::from_path(path) {
1239 Ok(ws) => Some(ws),
1240 Err(e) => {
1241 eprintln!("Note: Could not load workspace metadata: {}", e);
1242 eprintln!("Falling back to basic analysis...");
1243 None
1244 }
1245 };
1246
1247 if let Some(ws) = workspace {
1248 analyze_with_workspace(path, &ws, config)
1249 } else {
1250 analyze_project_parallel_with_config(path, config)
1252 }
1253}
1254
1255fn analyze_with_workspace(
1257 _project_root: &Path,
1258 workspace: &WorkspaceInfo,
1259 config: &CompiledConfig,
1260) -> Result<ProjectMetrics, AnalyzerError> {
1261 let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1264
1265 let mut project = ProjectMetrics::new();
1266
1267 project.workspace_name = Some(
1269 workspace
1270 .root
1271 .file_name()
1272 .and_then(|n| n.to_str())
1273 .unwrap_or("workspace")
1274 .to_string(),
1275 );
1276 project.workspace_members = workspace.members.clone();
1277
1278 let mut discovered_files: Vec<DiscoveredWorkspaceFile> = Vec::new();
1280
1281 for member_name in &workspace.members {
1282 if let Some(crate_info) = workspace.get_crate(member_name) {
1283 let mut member_files: HashMap<PathBuf, DiscoveredWorkspaceFile> = HashMap::new();
1284 let mut source_contents = HashMap::new();
1285
1286 for source_root in &crate_info.source_roots {
1287 if !source_root.exists() {
1288 continue;
1289 }
1290
1291 for file_path in
1292 rs_files_excluding_nested_packages(source_root, &crate_info.manifest_path)
1293 {
1294 if is_path_excluded(&file_path, exclude_base, config) {
1295 continue;
1296 }
1297 let file_key = canonical_file_key(&file_path);
1298 member_files
1299 .entry(file_key)
1300 .or_insert_with(|| DiscoveredWorkspaceFile {
1301 file_path: file_path.to_path_buf(),
1302 crate_name: member_name.clone(),
1303 source_root: source_root.clone(),
1304 module_name: None,
1305 });
1306 }
1307 }
1308
1309 let has_path_attribute = member_files
1310 .keys()
1311 .any(|file_key| cache_file_and_scan_path_attribute(file_key, &mut source_contents));
1312
1313 if has_path_attribute {
1314 let mut visited = HashSet::new();
1315
1316 for crate_root in &crate_info.crate_roots {
1320 let discovery = discover_module_tree(
1321 crate_root,
1322 &workspace.root,
1323 &crate_info.manifest_path,
1324 &mut visited,
1325 &mut source_contents,
1326 );
1327 project.boundary_skipped_files += discovery.boundary_skipped_files;
1328
1329 for module_file in discovery.files {
1330 if is_path_excluded(&module_file.file_path, exclude_base, config) {
1331 continue;
1332 }
1333 let file_key = canonical_file_key(&module_file.file_path);
1334 member_files
1335 .entry(file_key)
1336 .or_insert_with(|| DiscoveredWorkspaceFile {
1337 file_path: module_file.file_path.clone(),
1338 crate_name: member_name.clone(),
1339 source_root: module_file
1340 .file_path
1341 .parent()
1342 .map(Path::to_path_buf)
1343 .unwrap_or_default(),
1344 module_name: Some(module_file.module_name),
1345 });
1346 }
1347 }
1348 }
1349
1350 if member_files.is_empty() {
1351 project.skipped_crates.push(member_name.clone());
1352 } else {
1353 discovered_files.extend(member_files.into_values());
1354 }
1355 }
1356 }
1357
1358 let num_threads = rayon::current_num_threads();
1360 let file_count = discovered_files.len();
1361 let chunk_size = if file_count < num_threads * 2 {
1362 1
1363 } else {
1364 (file_count / (num_threads * 4)).max(1)
1365 };
1366
1367 let analyzed_files: Vec<AnalyzedFileWithCrate> = discovered_files
1369 .par_chunks(chunk_size)
1370 .flat_map(|chunk| {
1371 chunk
1372 .iter()
1373 .filter_map(|discovered| {
1374 match analyze_rust_file_full(&discovered.file_path) {
1375 Ok(result) => {
1376 let module_path = discovered.module_name.clone().unwrap_or_else(|| {
1378 file_path_to_module_path(
1379 &discovered.file_path,
1380 &discovered.source_root,
1381 )
1382 });
1383 let original_module_name = result.metrics.name.clone();
1384 let module_name = if module_path.is_empty() {
1385 original_module_name.clone()
1387 } else {
1388 module_path
1389 };
1390
1391 let item_dependencies = result
1393 .item_dependencies
1394 .into_iter()
1395 .map(|mut dep| {
1396 if dep.target_module.as_ref() == Some(&original_module_name) {
1397 dep.target_module = Some(module_name.clone());
1398 }
1399 dep
1400 })
1401 .collect();
1402
1403 Some(AnalyzedFileWithCrate {
1404 module_name: module_name.clone(),
1405 crate_name: discovered.crate_name.clone(),
1406 file_path: discovered.file_path.clone(),
1407 metrics: {
1408 let mut module_metrics = result.metrics;
1409 module_metrics.name = module_name;
1410 module_metrics
1411 },
1412 dependencies: result.dependencies,
1413 item_dependencies,
1414 })
1415 }
1416 Err(e) => {
1417 eprintln!(
1418 "Warning: Failed to analyze {}: {}",
1419 discovered.file_path.display(),
1420 e
1421 );
1422 None
1423 }
1424 }
1425 })
1426 .collect::<Vec<_>>()
1427 })
1428 .collect();
1429
1430 project.total_files = analyzed_files.len();
1431 project.parse_failures = discovered_files.len().saturating_sub(analyzed_files.len());
1432
1433 let module_names: HashSet<String> = analyzed_files
1435 .iter()
1436 .map(|a| a.module_name.clone())
1437 .collect();
1438
1439 for analyzed in &analyzed_files {
1441 let mut metrics = analyzed.metrics.clone();
1443 metrics.item_dependencies = analyzed.item_dependencies.clone();
1444 metrics.subdomain =
1445 config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1446 project.add_module(metrics);
1447
1448 for dep in &analyzed.dependencies {
1449 if !is_valid_dependency_path(&dep.path) {
1451 continue;
1452 }
1453
1454 let resolved_crate =
1456 resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1457
1458 let target_module =
1459 resolve_target_module(&dep.path, &analyzed.module_name, &module_names);
1460
1461 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1463 continue;
1464 }
1465
1466 let distance =
1468 calculate_distance_with_workspace(&dep.path, &analyzed.crate_name, workspace);
1469
1470 let strength = dep.usage.to_strength();
1472
1473 let volatility = Volatility::Low;
1475
1476 let mut coupling = CouplingMetrics::with_location(
1478 format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1479 if let Some(ref crate_name) = resolved_crate {
1480 format!("{}::{}", crate_name, target_module)
1481 } else {
1482 target_module.clone()
1483 },
1484 strength,
1485 distance,
1486 volatility,
1487 Visibility::Public, analyzed.file_path.clone(),
1489 dep.line,
1490 );
1491
1492 coupling.source_crate = Some(analyzed.crate_name.clone());
1494 coupling.target_crate = resolved_crate;
1495
1496 project.add_coupling(coupling);
1497 }
1498 }
1499
1500 for (crate_name, deps) in &workspace.dependency_graph {
1502 if workspace.is_workspace_member(crate_name) {
1503 for dep in deps {
1504 project
1506 .crate_dependencies
1507 .entry(crate_name.clone())
1508 .or_default()
1509 .push(dep.clone());
1510 }
1511 }
1512 }
1513
1514 Ok(project)
1515}
1516
1517fn cache_file_and_scan_path_attribute(
1518 file_key: &Path,
1519 source_contents: &mut HashMap<PathBuf, String>,
1520) -> bool {
1521 if let Some(content) = source_contents.get(file_key) {
1522 return content.contains("#[path");
1523 }
1524
1525 let Ok(bytes) = fs::read(file_key) else {
1526 return false;
1527 };
1528 let has_path_attribute = bytes
1529 .windows(b"#[path".len())
1530 .any(|window| window == b"#[path");
1531
1532 if let Ok(content) = String::from_utf8(bytes) {
1533 source_contents.insert(file_key.to_path_buf(), content);
1534 }
1535
1536 has_path_attribute
1537}
1538
1539fn calculate_distance_with_workspace(
1543 dep_path: &str,
1544 current_crate: &str,
1545 workspace: &WorkspaceInfo,
1546) -> Distance {
1547 if dep_path.starts_with("crate::") || dep_path.starts_with("self::") {
1548 Distance::SameModule
1550 } else if dep_path.starts_with("super::") {
1551 Distance::DifferentModule
1553 } else {
1554 if let Some(target_crate) = resolve_crate_from_path(dep_path, current_crate, workspace) {
1556 if target_crate == current_crate {
1557 Distance::SameModule
1558 } else if workspace.is_workspace_member(&target_crate) {
1559 Distance::DifferentModule
1561 } else {
1562 Distance::DifferentCrate
1564 }
1565 } else {
1566 Distance::DifferentCrate
1567 }
1568 }
1569}
1570
1571#[derive(Debug, Clone)]
1573struct AnalyzedFileWithCrate {
1574 module_name: String,
1575 crate_name: String,
1576 #[allow(dead_code)]
1577 file_path: PathBuf,
1578 metrics: ModuleMetrics,
1579 dependencies: Vec<Dependency>,
1580 item_dependencies: Vec<ItemDependency>,
1582}
1583
1584fn extract_target_module(path: &str) -> String {
1586 let cleaned = path
1588 .trim_start_matches("crate::")
1589 .trim_start_matches("super::")
1590 .trim_start_matches("::");
1591
1592 cleaned.split("::").next().unwrap_or(path).to_string()
1594}
1595
1596fn resolve_target_module(
1597 path: &str,
1598 source_module: &str,
1599 known_modules: &HashSet<String>,
1600) -> String {
1601 let resolved = resolve_relative_module_path(path, source_module);
1602 let segments: Vec<&str> = resolved
1603 .split("::")
1604 .filter(|segment| !segment.is_empty())
1605 .collect();
1606
1607 for len in (1..=segments.len()).rev() {
1608 let candidate = segments[..len].join("::");
1609 if known_modules.contains(&candidate) {
1610 return candidate;
1611 }
1612 }
1613
1614 extract_target_module(path)
1615}
1616
1617fn resolve_relative_module_path(path: &str, source_module: &str) -> String {
1618 if let Some(rest) = path.strip_prefix("crate::") {
1619 return rest.to_string();
1620 }
1621 if let Some(rest) = path.strip_prefix("self::") {
1622 return join_module_path(source_module, rest);
1623 }
1624
1625 let mut rest = path;
1626 let mut parent_levels = 0;
1627 while let Some(next) = rest.strip_prefix("super::") {
1628 parent_levels += 1;
1629 rest = next;
1630 }
1631
1632 if parent_levels == 0 {
1633 return path.trim_start_matches("::").to_string();
1634 }
1635
1636 let mut base: Vec<&str> = source_module.split("::").collect();
1637 for _ in 0..parent_levels {
1638 base.pop();
1639 }
1640 let prefix = base.join("::");
1641 join_module_path(&prefix, rest)
1642}
1643
1644fn is_valid_dependency_path(path: &str) -> bool {
1646 if path.is_empty() {
1648 return false;
1649 }
1650
1651 if path == "Self" || path.starts_with("Self::") {
1653 return false;
1654 }
1655
1656 let segments: Vec<&str> = path.split("::").collect();
1657
1658 if segments.len() == 1 {
1660 let name = segments[0];
1661 if name.len() <= 8 && name.chars().all(|c| c.is_lowercase() || c == '_') {
1662 return false;
1663 }
1664 }
1665
1666 if segments.len() >= 2 {
1668 let last = segments.last().unwrap();
1669 let second_last = segments.get(segments.len() - 2).unwrap();
1670 if last == second_last {
1671 return false;
1672 }
1673 }
1674
1675 let last_segment = segments.last().unwrap_or(&path);
1677 let common_locals = [
1678 "request",
1679 "response",
1680 "result",
1681 "content",
1682 "config",
1683 "proto",
1684 "domain",
1685 "info",
1686 "data",
1687 "item",
1688 "value",
1689 "error",
1690 "message",
1691 "expected",
1692 "actual",
1693 "status",
1694 "state",
1695 "context",
1696 "params",
1697 "args",
1698 "options",
1699 "settings",
1700 "violation",
1701 "page_token",
1702 ];
1703 if common_locals.contains(last_segment) && segments.len() <= 2 {
1704 return false;
1705 }
1706
1707 true
1708}
1709
1710fn calculate_distance(dep_path: &str, _known_modules: &HashSet<String>) -> Distance {
1712 if dep_path.starts_with("crate::") || dep_path.starts_with("super::") {
1713 Distance::DifferentModule
1715 } else if dep_path.starts_with("self::") {
1716 Distance::SameModule
1717 } else {
1718 Distance::DifferentCrate
1720 }
1721}
1722
1723pub struct AnalyzedFileResult {
1725 pub metrics: ModuleMetrics,
1727 pub dependencies: Vec<Dependency>,
1729 pub type_visibility: HashMap<String, Visibility>,
1731 pub item_dependencies: Vec<ItemDependency>,
1733}
1734
1735pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1737 let result = analyze_rust_file_full(path)?;
1738 Ok((result.metrics, result.dependencies))
1739}
1740
1741pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1743 let content = fs::read_to_string(path)?;
1744
1745 let module_name = path
1746 .file_stem()
1747 .and_then(|s| s.to_str())
1748 .unwrap_or("unknown")
1749 .to_string();
1750
1751 let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1752 analyzer.analyze_file(&content)?;
1753
1754 Ok(AnalyzedFileResult {
1755 metrics: analyzer.metrics,
1756 dependencies: analyzer.dependencies,
1757 type_visibility: analyzer.type_visibility,
1758 item_dependencies: analyzer.item_dependencies,
1759 })
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764 use super::*;
1765
1766 #[test]
1767 fn test_analyzer_creation() {
1768 let analyzer = CouplingAnalyzer::new(
1769 "test_module".to_string(),
1770 std::path::PathBuf::from("test.rs"),
1771 );
1772 assert_eq!(analyzer.current_module, "test_module");
1773 }
1774
1775 #[test]
1776 fn test_analyze_simple_file() {
1777 let mut analyzer =
1778 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1779
1780 let code = r#"
1781 pub struct User {
1782 name: String,
1783 email: String,
1784 }
1785
1786 impl User {
1787 pub fn new(name: String, email: String) -> Self {
1788 Self { name, email }
1789 }
1790 }
1791 "#;
1792
1793 let result = analyzer.analyze_file(code);
1794 assert!(result.is_ok());
1795 assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1796 }
1797
1798 #[test]
1799 fn test_item_dependencies() {
1800 let mut analyzer =
1801 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1802
1803 let code = r#"
1804 pub struct Config {
1805 pub value: i32,
1806 }
1807
1808 pub fn process(config: Config) -> i32 {
1809 let x = config.value;
1810 helper(x)
1811 }
1812
1813 fn helper(n: i32) -> i32 {
1814 n * 2
1815 }
1816 "#;
1817
1818 let result = analyzer.analyze_file(code);
1819 assert!(result.is_ok());
1820
1821 assert!(analyzer.defined_functions.contains_key("process"));
1823 assert!(analyzer.defined_functions.contains_key("helper"));
1824
1825 println!(
1827 "Item dependencies count: {}",
1828 analyzer.item_dependencies.len()
1829 );
1830 for dep in &analyzer.item_dependencies {
1831 println!(
1832 " {} -> {} ({:?})",
1833 dep.source_item, dep.target, dep.dep_type
1834 );
1835 }
1836
1837 let process_deps: Vec<_> = analyzer
1839 .item_dependencies
1840 .iter()
1841 .filter(|d| d.source_item == "process")
1842 .collect();
1843
1844 assert!(
1845 !process_deps.is_empty(),
1846 "process function should have item dependencies"
1847 );
1848 }
1849
1850 #[test]
1851 fn test_analyze_trait_impl() {
1852 let mut analyzer =
1853 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1854
1855 let code = r#"
1856 trait Printable {
1857 fn print(&self);
1858 }
1859
1860 struct Document;
1861
1862 impl Printable for Document {
1863 fn print(&self) {}
1864 }
1865 "#;
1866
1867 let result = analyzer.analyze_file(code);
1868 assert!(result.is_ok());
1869 assert!(analyzer.metrics.trait_impl_count >= 1);
1870 }
1871
1872 #[test]
1873 fn test_analyze_use_statements() {
1874 let mut analyzer =
1875 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1876
1877 let code = r#"
1878 use std::collections::HashMap;
1879 use serde::Serialize;
1880 use crate::utils;
1881 use crate::models::{User, Post};
1882 "#;
1883
1884 let result = analyzer.analyze_file(code);
1885 assert!(result.is_ok());
1886 assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1887 assert!(
1888 analyzer
1889 .metrics
1890 .external_deps
1891 .contains(&"serde".to_string())
1892 );
1893 assert!(!analyzer.dependencies.is_empty());
1894
1895 let internal_deps: Vec<_> = analyzer
1897 .dependencies
1898 .iter()
1899 .filter(|d| d.kind == DependencyKind::InternalUse)
1900 .collect();
1901 assert!(!internal_deps.is_empty());
1902 }
1903
1904 #[test]
1905 fn test_extract_use_paths() {
1906 let analyzer =
1907 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1908
1909 let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1911 let paths = analyzer.extract_use_paths(&tree, "");
1912 assert_eq!(paths.len(), 1);
1913 assert_eq!(paths[0].0, "std::collections::HashMap");
1914
1915 let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1917 let paths = analyzer.extract_use_paths(&tree, "");
1918 assert_eq!(paths.len(), 2);
1919 }
1920
1921 #[test]
1922 fn test_extract_target_module() {
1923 assert_eq!(extract_target_module("crate::models::user"), "models");
1924 assert_eq!(extract_target_module("super::utils"), "utils");
1925 assert_eq!(extract_target_module("std::collections"), "std");
1926 }
1927
1928 #[test]
1929 fn test_resolve_target_module_prefers_longest_known_module() {
1930 let known = HashSet::from([
1931 "balance".to_string(),
1932 "balance::issues".to_string(),
1933 "balance::score".to_string(),
1934 ]);
1935
1936 assert_eq!(
1937 resolve_target_module("crate::balance::issues::CouplingIssue", "report", &known),
1938 "balance::issues"
1939 );
1940 assert_eq!(
1941 resolve_target_module("super::issues::IssueType", "balance::coupling", &known),
1942 "balance::issues"
1943 );
1944 assert_eq!(
1945 resolve_target_module("std::collections::HashMap", "balance::coupling", &known),
1946 "std"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_field_access_detection() {
1952 let mut analyzer =
1953 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1954
1955 let code = r#"
1956 use crate::models::User;
1957
1958 fn get_name(user: &User) -> String {
1959 user.name.clone()
1960 }
1961 "#;
1962
1963 let result = analyzer.analyze_file(code);
1964 assert!(result.is_ok());
1965
1966 let _field_deps: Vec<_> = analyzer
1968 .dependencies
1969 .iter()
1970 .filter(|d| d.usage == UsageContext::FieldAccess)
1971 .collect();
1972 }
1975
1976 #[test]
1977 fn test_method_call_detection() {
1978 let mut analyzer =
1979 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1980
1981 let code = r#"
1982 fn process() {
1983 let data = String::new();
1984 data.push_str("hello");
1985 }
1986 "#;
1987
1988 let result = analyzer.analyze_file(code);
1989 assert!(result.is_ok());
1990 }
1992
1993 #[test]
1994 fn test_struct_construction_detection() {
1995 let mut analyzer =
1996 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1997
1998 let code = r#"
1999 use crate::config::Config;
2000
2001 fn create_config() {
2002 let c = Config { value: 42 };
2003 }
2004 "#;
2005
2006 let result = analyzer.analyze_file(code);
2007 assert!(result.is_ok());
2008
2009 let struct_deps: Vec<_> = analyzer
2011 .dependencies
2012 .iter()
2013 .filter(|d| d.usage == UsageContext::StructConstruction)
2014 .collect();
2015 assert!(!struct_deps.is_empty());
2016 }
2017
2018 #[test]
2019 fn test_usage_context_to_strength() {
2020 assert_eq!(
2021 UsageContext::FieldAccess.to_strength(),
2022 IntegrationStrength::Intrusive
2023 );
2024 assert_eq!(
2025 UsageContext::MethodCall.to_strength(),
2026 IntegrationStrength::Functional
2027 );
2028 assert_eq!(
2029 UsageContext::TypeParameter.to_strength(),
2030 IntegrationStrength::Model
2031 );
2032 assert_eq!(
2033 UsageContext::TraitBound.to_strength(),
2034 IntegrationStrength::Contract
2035 );
2036 }
2037
2038 #[test]
2039 fn test_has_test_attribute_with_test() {
2040 let code = r#"
2041 #[test]
2042 fn my_test() {}
2043 "#;
2044 let syntax: syn::File = syn::parse_str(code).unwrap();
2045 if let syn::Item::Fn(func) = &syntax.items[0] {
2046 assert!(has_test_attribute(&func.attrs));
2047 } else {
2048 panic!("Expected function");
2049 }
2050 }
2051
2052 #[test]
2053 fn test_has_test_attribute_without_test() {
2054 let code = r#"
2055 fn regular_fn() {}
2056 "#;
2057 let syntax: syn::File = syn::parse_str(code).unwrap();
2058 if let syn::Item::Fn(func) = &syntax.items[0] {
2059 assert!(!has_test_attribute(&func.attrs));
2060 } else {
2061 panic!("Expected function");
2062 }
2063 }
2064
2065 #[test]
2066 fn test_has_cfg_test_attribute_with_cfg_test() {
2067 let code = r#"
2068 #[cfg(test)]
2069 mod tests {}
2070 "#;
2071 let syntax: syn::File = syn::parse_str(code).unwrap();
2072 if let syn::Item::Mod(module) = &syntax.items[0] {
2073 assert!(has_cfg_test_attribute(&module.attrs));
2074 } else {
2075 panic!("Expected module");
2076 }
2077 }
2078
2079 #[test]
2080 fn test_has_cfg_test_attribute_without_cfg_test() {
2081 let code = r#"
2082 mod regular_mod {}
2083 "#;
2084 let syntax: syn::File = syn::parse_str(code).unwrap();
2085 if let syn::Item::Mod(module) = &syntax.items[0] {
2086 assert!(!has_cfg_test_attribute(&module.attrs));
2087 } else {
2088 panic!("Expected module");
2089 }
2090 }
2091
2092 #[test]
2093 fn test_has_cfg_test_attribute_with_other_cfg() {
2094 let code = r#"
2095 #[cfg(feature = "foo")]
2096 mod feature_mod {}
2097 "#;
2098 let syntax: syn::File = syn::parse_str(code).unwrap();
2099 if let syn::Item::Mod(module) = &syntax.items[0] {
2100 assert!(!has_cfg_test_attribute(&module.attrs));
2101 } else {
2102 panic!("Expected module");
2103 }
2104 }
2105
2106 #[test]
2107 fn test_is_test_module_named_tests() {
2108 let code = r#"
2109 mod tests {}
2110 "#;
2111 let syntax: syn::File = syn::parse_str(code).unwrap();
2112 if let syn::Item::Mod(module) = &syntax.items[0] {
2113 assert!(is_test_module(module));
2114 } else {
2115 panic!("Expected module");
2116 }
2117 }
2118
2119 #[test]
2120 fn test_is_test_module_with_cfg_test() {
2121 let code = r#"
2122 #[cfg(test)]
2123 mod my_tests {}
2124 "#;
2125 let syntax: syn::File = syn::parse_str(code).unwrap();
2126 if let syn::Item::Mod(module) = &syntax.items[0] {
2127 assert!(is_test_module(module));
2128 } else {
2129 panic!("Expected module");
2130 }
2131 }
2132
2133 #[test]
2134 fn test_is_test_module_regular_module() {
2135 let code = r#"
2136 mod utils {}
2137 "#;
2138 let syntax: syn::File = syn::parse_str(code).unwrap();
2139 if let syn::Item::Mod(module) = &syntax.items[0] {
2140 assert!(!is_test_module(module));
2141 } else {
2142 panic!("Expected module");
2143 }
2144 }
2145
2146 #[test]
2152 fn test_analyze_project_parallel_applies_exclude_patterns() {
2153 use crate::config::{CompiledConfig, CouplingConfig};
2154
2155 let tmp = tempfile::tempdir().expect("create tempdir");
2156 let root = tmp.path();
2157 let src = root.join("src");
2158 let generated = src.join("generated");
2159 std::fs::create_dir_all(&generated).expect("create generated dir");
2160 std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2161 .expect("write lib.rs");
2162 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2163 .expect("write generated/mod.rs");
2164
2165 let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2167 .expect("baseline analysis");
2168 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2169 assert!(
2170 baseline.modules.keys().any(|k| k.contains("generated")),
2171 "baseline must include the generated module; saw {:?}",
2172 baseline.modules.keys().collect::<Vec<_>>()
2173 );
2174
2175 let toml = r#"
2177 [analysis]
2178 exclude = ["src/generated/*", "src/generated/**"]
2179 "#;
2180 let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2181 let compiled = CompiledConfig::from_config(config).expect("compile config");
2182 let filtered =
2183 analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2184 assert_eq!(
2185 filtered.total_files, 1,
2186 "generated file should be excluded from analysis"
2187 );
2188 assert!(
2189 !filtered.modules.keys().any(|k| k.contains("generated")),
2190 "no generated module should remain; saw {:?}",
2191 filtered.modules.keys().collect::<Vec<_>>()
2192 );
2193 }
2194
2195 #[test]
2198 fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2199 use crate::config::{CompiledConfig, load_compiled_config};
2200
2201 let current_dir = std::env::current_dir().expect("get current dir");
2202 let target_dir = current_dir.join("target");
2203 let tmp = tempfile::Builder::new()
2204 .prefix("issue39-workspace-")
2205 .tempdir_in(&target_dir)
2206 .expect("create tempdir in target");
2207 let root = tmp.path();
2208 let src = root.join("src");
2209 let generated = src.join("generated");
2210 std::fs::create_dir_all(&generated).expect("create generated dir");
2211 std::fs::write(
2212 root.join("Cargo.toml"),
2213 r#"[package]
2214name = "coupling-fixture-exclude"
2215version = "0.1.0"
2216edition = "2024"
2217"#,
2218 )
2219 .expect("write Cargo.toml");
2220 std::fs::write(
2221 root.join(".coupling.toml"),
2222 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2223 )
2224 .expect("write .coupling.toml");
2225 std::fs::write(
2226 src.join("lib.rs"),
2227 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2228 )
2229 .expect("write lib.rs");
2230 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2231 .expect("write generated/mod.rs");
2232
2233 let relative_src = src
2234 .strip_prefix(¤t_dir)
2235 .expect("temp crate should be under current dir");
2236
2237 let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2238 .expect("baseline workspace analysis");
2239 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2240 assert!(
2241 baseline.modules.keys().any(|k| k.contains("generated")),
2242 "baseline must include the generated module; saw {:?}",
2243 baseline.modules.keys().collect::<Vec<_>>()
2244 );
2245
2246 let compiled = load_compiled_config(relative_src).expect("load compiled config");
2247 let filtered = analyze_workspace_with_config(relative_src, &compiled)
2248 .expect("filtered workspace analysis");
2249 assert_eq!(
2250 filtered.total_files, 1,
2251 "generated file should be excluded from workspace analysis"
2252 );
2253 assert!(
2254 !filtered.modules.keys().any(|k| k.contains("generated")),
2255 "no generated module should remain; saw {:?}",
2256 filtered.modules.keys().collect::<Vec<_>>()
2257 );
2258 }
2259
2260 #[test]
2263 fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2264 use crate::config::{CompiledConfig, load_compiled_config};
2265
2266 let tmp = tempfile::tempdir().expect("create tempdir");
2267 let root = tmp.path();
2268 let src = root.join("src");
2269 let generated = src.join("generated");
2270 std::fs::create_dir_all(&generated).expect("create generated dir");
2271 std::fs::write(
2272 root.join(".coupling.toml"),
2273 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2274 )
2275 .expect("write .coupling.toml");
2276 std::fs::write(
2277 src.join("lib.rs"),
2278 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2279 )
2280 .expect("write lib.rs");
2281 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2282 .expect("write generated/mod.rs");
2283
2284 let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
2285 .expect("baseline analysis");
2286 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2287 assert!(
2288 baseline.modules.keys().any(|k| k.contains("generated")),
2289 "baseline must include the generated module; saw {:?}",
2290 baseline.modules.keys().collect::<Vec<_>>()
2291 );
2292
2293 let compiled = load_compiled_config(&src).expect("load compiled config");
2294 let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2295 assert_eq!(
2296 filtered.total_files, 1,
2297 "generated file should be excluded from fallback analysis"
2298 );
2299 assert!(
2300 !filtered.modules.keys().any(|k| k.contains("generated")),
2301 "no generated module should remain; saw {:?}",
2302 filtered.modules.keys().collect::<Vec<_>>()
2303 );
2304 }
2305}