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