1use std::collections::{HashMap, HashSet};
8use std::ffi::OsStr;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use rayon::prelude::*;
13use syn::visit::Visit;
14use syn::{
15 Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
16 ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
17};
18use thiserror::Error;
19use walkdir::WalkDir;
20
21use crate::config::CompiledConfig;
22use crate::metrics::{
23 CouplingMetrics, Distance, IntegrationStrength, ModuleMetrics, ProjectMetrics, Visibility,
24 Volatility,
25};
26use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
27
28fn convert_visibility(vis: &syn::Visibility) -> Visibility {
30 match vis {
31 syn::Visibility::Public(_) => Visibility::Public,
32 syn::Visibility::Restricted(restricted) => {
33 let path_str = restricted
35 .path
36 .segments
37 .iter()
38 .map(|s| s.ident.to_string())
39 .collect::<Vec<_>>()
40 .join("::");
41
42 match path_str.as_str() {
43 "crate" => Visibility::PubCrate,
44 "super" => Visibility::PubSuper,
45 "self" => Visibility::Private, _ => Visibility::PubIn, }
48 }
49 syn::Visibility::Inherited => Visibility::Private,
50 }
51}
52
53fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
55 attrs.iter().any(|attr| attr.path().is_ident("test"))
56}
57
58fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
60 attrs.iter().any(|attr| {
61 if attr.path().is_ident("cfg") {
62 if let Ok(meta) = attr.meta.require_list() {
64 let tokens = meta.tokens.to_string();
65 return tokens.contains("test");
66 }
67 }
68 false
69 })
70}
71
72fn is_test_module(item: &ItemMod) -> bool {
74 item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
75}
76
77fn file_path_to_module_path(file_path: &Path, src_root: &Path) -> String {
88 let relative = file_path.strip_prefix(src_root).unwrap_or(file_path);
90
91 let mut parts: Vec<String> = Vec::new();
92
93 for component in relative.components() {
94 if let Some(s) = component.as_os_str().to_str() {
95 parts.push(s.to_string());
96 }
97 }
98
99 if let Some(last) = parts.last().cloned() {
101 parts.pop();
102 match last.as_str() {
103 "lib.rs" | "main.rs" => {
104 }
106 "mod.rs" => {
107 }
109 _ => {
110 if let Some(stem) = last.strip_suffix(".rs") {
112 parts.push(stem.to_string());
113 } else {
114 parts.push(last);
115 }
116 }
117 }
118 }
119
120 parts.join("::")
121}
122
123#[derive(Error, Debug)]
125pub enum AnalyzerError {
126 #[error("Failed to read file: {0}")]
127 IoError(#[from] std::io::Error),
128
129 #[error("Failed to parse Rust file: {0}")]
130 ParseError(String),
131
132 #[error("Invalid path: {0}")]
133 InvalidPath(String),
134
135 #[error("Workspace error: {0}")]
136 WorkspaceError(#[from] WorkspaceError),
137}
138
139#[derive(Debug, Clone)]
141pub struct Dependency {
142 pub path: String,
144 pub kind: DependencyKind,
146 pub line: usize,
148 pub usage: UsageContext,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum DependencyKind {
155 InternalUse,
157 ExternalUse,
159 TraitImpl,
161 InherentImpl,
163 TypeRef,
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum UsageContext {
170 Import,
172 TraitBound,
174 FieldAccess,
176 MethodCall,
178 FunctionCall,
180 StructConstruction,
182 TypeParameter,
184 FunctionParameter,
186 ReturnType,
188 InherentImplBlock,
190}
191
192impl UsageContext {
193 pub fn to_strength(&self) -> IntegrationStrength {
195 match self {
196 UsageContext::FieldAccess => IntegrationStrength::Intrusive,
198 UsageContext::StructConstruction => IntegrationStrength::Intrusive,
199 UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
200
201 UsageContext::MethodCall => IntegrationStrength::Functional,
203 UsageContext::FunctionCall => IntegrationStrength::Functional,
204 UsageContext::FunctionParameter => IntegrationStrength::Functional,
205 UsageContext::ReturnType => IntegrationStrength::Functional,
206
207 UsageContext::TypeParameter => IntegrationStrength::Model,
209 UsageContext::Import => IntegrationStrength::Model,
210
211 UsageContext::TraitBound => IntegrationStrength::Contract,
213 }
214 }
215}
216
217impl DependencyKind {
218 pub fn to_strength(&self) -> IntegrationStrength {
219 match self {
220 DependencyKind::TraitImpl => IntegrationStrength::Contract,
221 DependencyKind::InternalUse => IntegrationStrength::Model,
222 DependencyKind::ExternalUse => IntegrationStrength::Model,
223 DependencyKind::TypeRef => IntegrationStrength::Model,
224 DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
225 }
226 }
227}
228
229#[derive(Debug)]
231pub struct CouplingAnalyzer {
232 pub current_module: String,
234 pub file_path: std::path::PathBuf,
236 pub metrics: ModuleMetrics,
238 pub dependencies: Vec<Dependency>,
240 pub defined_types: HashSet<String>,
242 pub defined_traits: HashSet<String>,
244 pub defined_functions: HashMap<String, Visibility>,
246 imported_types: HashMap<String, String>,
248 seen_dependencies: HashSet<(String, UsageContext)>,
250 pub usage_counts: UsageCounts,
252 pub type_visibility: HashMap<String, Visibility>,
254 current_item: Option<(String, ItemKind)>,
256 pub item_dependencies: Vec<ItemDependency>,
258}
259
260#[derive(Debug, Default, Clone)]
262pub struct UsageCounts {
263 pub field_accesses: usize,
264 pub method_calls: usize,
265 pub function_calls: usize,
266 pub struct_constructions: usize,
267 pub trait_bounds: usize,
268 pub type_parameters: usize,
269}
270
271#[derive(Debug, Clone)]
273pub struct ItemDependency {
274 pub source_item: String,
276 pub source_kind: ItemKind,
278 pub target: String,
280 pub target_module: Option<String>,
282 pub dep_type: ItemDepType,
284 pub line: usize,
286 pub expression: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum ItemKind {
293 Function,
294 Method,
295 Struct,
296 Enum,
297 Trait,
298 Impl,
299 Module,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ItemDepType {
305 FunctionCall,
307 MethodCall,
309 TypeUsage,
311 FieldAccess,
313 StructConstruction,
315 TraitImpl,
317 TraitBound,
319 Import,
321}
322
323impl CouplingAnalyzer {
324 pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
326 Self {
327 current_module: module_name.clone(),
328 file_path: path.clone(),
329 metrics: ModuleMetrics::new(path, module_name),
330 dependencies: Vec::new(),
331 defined_types: HashSet::new(),
332 defined_traits: HashSet::new(),
333 defined_functions: HashMap::new(),
334 imported_types: HashMap::new(),
335 seen_dependencies: HashSet::new(),
336 usage_counts: UsageCounts::default(),
337 type_visibility: HashMap::new(),
338 current_item: None,
339 item_dependencies: Vec::new(),
340 }
341 }
342
343 pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
345 let syntax: File =
346 syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
347
348 self.visit_file(&syntax);
349
350 Ok(())
351 }
352
353 fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
355 let key = (path.clone(), usage);
356 if self.seen_dependencies.contains(&key) {
357 return;
358 }
359 self.seen_dependencies.insert(key);
360
361 self.dependencies.push(Dependency {
362 path,
363 kind,
364 line: 0,
365 usage,
366 });
367 }
368
369 fn add_item_dependency(
371 &mut self,
372 target: String,
373 dep_type: ItemDepType,
374 line: usize,
375 expression: Option<String>,
376 ) {
377 if let Some((ref source_item, source_kind)) = self.current_item {
378 let target_module = self.imported_types.get(&target).cloned().or_else(|| {
380 if self.defined_types.contains(&target)
381 || self.defined_functions.contains_key(&target)
382 {
383 Some(self.current_module.clone())
384 } else {
385 None
386 }
387 });
388
389 self.item_dependencies.push(ItemDependency {
390 source_item: source_item.clone(),
391 source_kind,
392 target,
393 target_module,
394 dep_type,
395 line,
396 expression,
397 });
398 }
399 }
400
401 fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
403 let mut paths = Vec::new();
404
405 match tree {
406 UseTree::Path(path) => {
407 let new_prefix = if prefix.is_empty() {
408 path.ident.to_string()
409 } else {
410 format!("{}::{}", prefix, path.ident)
411 };
412 paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
413 }
414 UseTree::Name(name) => {
415 let full_path = if prefix.is_empty() {
416 name.ident.to_string()
417 } else {
418 format!("{}::{}", prefix, name.ident)
419 };
420 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
421 DependencyKind::InternalUse
422 } else {
423 DependencyKind::ExternalUse
424 };
425 paths.push((full_path, kind));
426 }
427 UseTree::Rename(rename) => {
428 let full_path = if prefix.is_empty() {
429 rename.ident.to_string()
430 } else {
431 format!("{}::{}", prefix, rename.ident)
432 };
433 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
434 DependencyKind::InternalUse
435 } else {
436 DependencyKind::ExternalUse
437 };
438 paths.push((full_path, kind));
439 }
440 UseTree::Glob(_) => {
441 let full_path = format!("{}::*", prefix);
442 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
443 DependencyKind::InternalUse
444 } else {
445 DependencyKind::ExternalUse
446 };
447 paths.push((full_path, kind));
448 }
449 UseTree::Group(group) => {
450 for item in &group.items {
451 paths.extend(self.extract_use_paths(item, prefix));
452 }
453 }
454 }
455
456 paths
457 }
458
459 fn extract_type_name(&self, ty: &Type) -> Option<String> {
461 match ty {
462 Type::Path(type_path) => {
463 let segments: Vec<_> = type_path
464 .path
465 .segments
466 .iter()
467 .map(|s| s.ident.to_string())
468 .collect();
469 Some(segments.join("::"))
470 }
471 Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
472 Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
473 Type::Array(array_type) => self.extract_type_name(&array_type.elem),
474 Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
475 Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
476 Type::Group(group_type) => self.extract_type_name(&group_type.elem),
477 _ => None,
478 }
479 }
480
481 fn analyze_signature(&mut self, sig: &Signature) {
483 for arg in &sig.inputs {
485 if let FnArg::Typed(pat_type) = arg
486 && let Some(type_name) = self.extract_type_name(&pat_type.ty)
487 && !self.is_primitive_type(&type_name)
488 {
489 self.add_dependency(
490 type_name,
491 DependencyKind::TypeRef,
492 UsageContext::FunctionParameter,
493 );
494 }
495 }
496
497 if let ReturnType::Type(_, ty) = &sig.output
499 && let Some(type_name) = self.extract_type_name(ty)
500 && !self.is_primitive_type(&type_name)
501 {
502 self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
503 }
504 }
505
506 fn is_primitive_type(&self, type_name: &str) -> bool {
508 if matches!(
510 type_name,
511 "bool"
512 | "char"
513 | "str"
514 | "u8"
515 | "u16"
516 | "u32"
517 | "u64"
518 | "u128"
519 | "usize"
520 | "i8"
521 | "i16"
522 | "i32"
523 | "i64"
524 | "i128"
525 | "isize"
526 | "f32"
527 | "f64"
528 | "String"
529 | "Self"
530 | "()"
531 | "Option"
532 | "Result"
533 | "Vec"
534 | "Box"
535 | "Rc"
536 | "Arc"
537 | "RefCell"
538 | "Cell"
539 | "Mutex"
540 | "RwLock"
541 ) {
542 return true;
543 }
544
545 if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
548 return true;
549 }
550
551 if type_name.starts_with("self") || type_name == "self" {
553 return true;
554 }
555
556 false
557 }
558}
559
560impl<'ast> Visit<'ast> for CouplingAnalyzer {
561 fn visit_item_use(&mut self, node: &'ast ItemUse) {
562 let paths = self.extract_use_paths(&node.tree, "");
563
564 for (path, kind) in paths {
565 if path == "self" || path.starts_with("self::") {
567 continue;
568 }
569
570 if let Some(type_name) = path.split("::").last() {
572 self.imported_types
573 .insert(type_name.to_string(), path.clone());
574 }
575
576 self.add_dependency(path.clone(), kind, UsageContext::Import);
577
578 if kind == DependencyKind::InternalUse {
580 if !self.metrics.internal_deps.contains(&path) {
581 self.metrics.internal_deps.push(path.clone());
582 }
583 } else if kind == DependencyKind::ExternalUse {
584 let crate_name = path.split("::").next().unwrap_or(&path).to_string();
586 if !self.metrics.external_deps.contains(&crate_name) {
587 self.metrics.external_deps.push(crate_name);
588 }
589 }
590 }
591
592 syn::visit::visit_item_use(self, node);
593 }
594
595 fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
596 if let Some((_, trait_path, _)) = &node.trait_ {
597 self.metrics.trait_impl_count += 1;
599
600 let trait_name: String = trait_path
602 .segments
603 .iter()
604 .map(|s| s.ident.to_string())
605 .collect::<Vec<_>>()
606 .join("::");
607
608 self.add_dependency(
609 trait_name,
610 DependencyKind::TraitImpl,
611 UsageContext::TraitBound,
612 );
613 self.usage_counts.trait_bounds += 1;
614 } else {
615 self.metrics.inherent_impl_count += 1;
617
618 if let Some(type_name) = self.extract_type_name(&node.self_ty)
620 && !self.defined_types.contains(&type_name)
621 {
622 self.add_dependency(
623 type_name,
624 DependencyKind::InherentImpl,
625 UsageContext::InherentImplBlock,
626 );
627 }
628 }
629 syn::visit::visit_item_impl(self, node);
630 }
631
632 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
633 let fn_name = node.sig.ident.to_string();
635 let visibility = convert_visibility(&node.vis);
636 self.defined_functions.insert(fn_name.clone(), visibility);
637
638 if has_test_attribute(&node.attrs) {
640 self.metrics.test_function_count += 1;
641 }
642
643 let mut param_count = 0;
645 let mut primitive_param_count = 0;
646 let mut param_types = Vec::new();
647
648 for arg in &node.sig.inputs {
649 if let FnArg::Typed(pat_type) = arg {
650 param_count += 1;
651 if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
652 param_types.push(type_name.clone());
653 if self.is_primitive_type(&type_name) {
654 primitive_param_count += 1;
655 }
656 }
657 }
658 }
659
660 self.metrics.add_function_definition_full(
662 fn_name.clone(),
663 visibility,
664 param_count,
665 primitive_param_count,
666 param_types,
667 );
668
669 let previous_item = self.current_item.take();
671 self.current_item = Some((fn_name, ItemKind::Function));
672
673 self.analyze_signature(&node.sig);
675 syn::visit::visit_item_fn(self, node);
676
677 self.current_item = previous_item;
679 }
680
681 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
682 let name = node.ident.to_string();
683 let visibility = convert_visibility(&node.vis);
684
685 self.defined_types.insert(name.clone());
686 self.type_visibility.insert(name.clone(), visibility);
687
688 let (is_newtype, inner_type) = match &node.fields {
690 syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
691 let inner = fields
692 .unnamed
693 .first()
694 .and_then(|f| self.extract_type_name(&f.ty));
695 (true, inner)
696 }
697 _ => (false, None),
698 };
699
700 let has_serde_derive = node.attrs.iter().any(|attr| {
702 if attr.path().is_ident("derive")
703 && let Ok(nested) = attr.parse_args_with(
704 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
705 )
706 {
707 return nested.iter().any(|path| {
708 let path_str = path
709 .segments
710 .iter()
711 .map(|s| s.ident.to_string())
712 .collect::<Vec<_>>()
713 .join("::");
714 path_str == "Serialize"
715 || path_str == "Deserialize"
716 || path_str == "serde::Serialize"
717 || path_str == "serde::Deserialize"
718 });
719 }
720 false
721 });
722
723 let (total_field_count, public_field_count) = match &node.fields {
725 syn::Fields::Named(fields) => {
726 let total = fields.named.len();
727 let public = fields
728 .named
729 .iter()
730 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
731 .count();
732 (total, public)
733 }
734 syn::Fields::Unnamed(fields) => {
735 let total = fields.unnamed.len();
736 let public = fields
737 .unnamed
738 .iter()
739 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
740 .count();
741 (total, public)
742 }
743 syn::Fields::Unit => (0, 0),
744 };
745
746 self.metrics.add_type_definition_full(
748 name,
749 visibility,
750 false, is_newtype,
752 inner_type,
753 has_serde_derive,
754 public_field_count,
755 total_field_count,
756 );
757
758 match &node.fields {
760 syn::Fields::Named(fields) => {
761 self.metrics.type_usage_count += fields.named.len();
762 for field in &fields.named {
763 if let Some(type_name) = self.extract_type_name(&field.ty)
764 && !self.is_primitive_type(&type_name)
765 {
766 self.add_dependency(
767 type_name,
768 DependencyKind::TypeRef,
769 UsageContext::TypeParameter,
770 );
771 self.usage_counts.type_parameters += 1;
772 }
773 }
774 }
775 syn::Fields::Unnamed(fields) => {
776 for field in &fields.unnamed {
777 if let Some(type_name) = self.extract_type_name(&field.ty)
778 && !self.is_primitive_type(&type_name)
779 {
780 self.add_dependency(
781 type_name,
782 DependencyKind::TypeRef,
783 UsageContext::TypeParameter,
784 );
785 }
786 }
787 }
788 syn::Fields::Unit => {}
789 }
790 syn::visit::visit_item_struct(self, node);
791 }
792
793 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
794 let name = node.ident.to_string();
795 let visibility = convert_visibility(&node.vis);
796
797 self.defined_types.insert(name.clone());
798 self.type_visibility.insert(name.clone(), visibility);
799
800 self.metrics.add_type_definition(name, visibility, false);
802
803 for variant in &node.variants {
805 match &variant.fields {
806 syn::Fields::Named(fields) => {
807 for field in &fields.named {
808 if let Some(type_name) = self.extract_type_name(&field.ty)
809 && !self.is_primitive_type(&type_name)
810 {
811 self.add_dependency(
812 type_name,
813 DependencyKind::TypeRef,
814 UsageContext::TypeParameter,
815 );
816 }
817 }
818 }
819 syn::Fields::Unnamed(fields) => {
820 for field in &fields.unnamed {
821 if let Some(type_name) = self.extract_type_name(&field.ty)
822 && !self.is_primitive_type(&type_name)
823 {
824 self.add_dependency(
825 type_name,
826 DependencyKind::TypeRef,
827 UsageContext::TypeParameter,
828 );
829 }
830 }
831 }
832 syn::Fields::Unit => {}
833 }
834 }
835 syn::visit::visit_item_enum(self, node);
836 }
837
838 fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
839 let name = node.ident.to_string();
840 let visibility = convert_visibility(&node.vis);
841
842 self.defined_traits.insert(name.clone());
843 self.type_visibility.insert(name.clone(), visibility);
844
845 self.metrics.add_type_definition(name, visibility, true);
847
848 self.metrics.trait_impl_count += 1;
849 syn::visit::visit_item_trait(self, node);
850 }
851
852 fn visit_item_mod(&mut self, node: &'ast ItemMod) {
853 if is_test_module(node) {
855 self.metrics.is_test_module = true;
856 }
857
858 if node.content.is_some() {
859 self.metrics.internal_deps.push(node.ident.to_string());
860 }
861 syn::visit::visit_item_mod(self, node);
862 }
863
864 fn visit_expr_field(&mut self, node: &'ast ExprField) {
866 let field_name = match &node.member {
867 syn::Member::Named(ident) => ident.to_string(),
868 syn::Member::Unnamed(idx) => format!("{}", idx.index),
869 };
870
871 if let Expr::Path(path_expr) = &*node.base {
873 let base_name = path_expr
874 .path
875 .segments
876 .iter()
877 .map(|s| s.ident.to_string())
878 .collect::<Vec<_>>()
879 .join("::");
880
881 let full_path = self
883 .imported_types
884 .get(&base_name)
885 .cloned()
886 .unwrap_or(base_name.clone());
887
888 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
889 self.add_dependency(
890 full_path.clone(),
891 DependencyKind::TypeRef,
892 UsageContext::FieldAccess,
893 );
894 self.usage_counts.field_accesses += 1;
895 }
896
897 let expr = format!("{}.{}", base_name, field_name);
899 self.add_item_dependency(
900 format!("{}.{}", full_path, field_name),
901 ItemDepType::FieldAccess,
902 0,
903 Some(expr),
904 );
905 }
906 syn::visit::visit_expr_field(self, node);
907 }
908
909 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
911 let method_name = node.method.to_string();
912
913 if let Expr::Path(path_expr) = &*node.receiver {
915 let receiver_name = path_expr
916 .path
917 .segments
918 .iter()
919 .map(|s| s.ident.to_string())
920 .collect::<Vec<_>>()
921 .join("::");
922
923 let full_path = self
924 .imported_types
925 .get(&receiver_name)
926 .cloned()
927 .unwrap_or(receiver_name.clone());
928
929 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
930 self.add_dependency(
931 full_path.clone(),
932 DependencyKind::TypeRef,
933 UsageContext::MethodCall,
934 );
935 self.usage_counts.method_calls += 1;
936 }
937
938 let expr = format!("{}.{}()", receiver_name, method_name);
940 self.add_item_dependency(
941 format!("{}::{}", full_path, method_name),
942 ItemDepType::MethodCall,
943 0, Some(expr),
945 );
946 }
947 syn::visit::visit_expr_method_call(self, node);
948 }
949
950 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
952 if let Expr::Path(path_expr) = &*node.func {
953 let path_str = path_expr
954 .path
955 .segments
956 .iter()
957 .map(|s| s.ident.to_string())
958 .collect::<Vec<_>>()
959 .join("::");
960
961 if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
963 {
964 let full_path = self
965 .imported_types
966 .get(&path_str)
967 .cloned()
968 .unwrap_or(path_str.clone());
969
970 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
971 self.add_dependency(
972 full_path.clone(),
973 DependencyKind::TypeRef,
974 UsageContext::FunctionCall,
975 );
976 self.usage_counts.function_calls += 1;
977 }
978
979 self.add_item_dependency(
981 full_path,
982 ItemDepType::FunctionCall,
983 0,
984 Some(format!("{}()", path_str)),
985 );
986 } else {
987 self.add_item_dependency(
989 path_str.clone(),
990 ItemDepType::FunctionCall,
991 0,
992 Some(format!("{}()", path_str)),
993 );
994 }
995 }
996 syn::visit::visit_expr_call(self, node);
997 }
998
999 fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
1001 let struct_name = node
1002 .path
1003 .segments
1004 .iter()
1005 .map(|s| s.ident.to_string())
1006 .collect::<Vec<_>>()
1007 .join("::");
1008
1009 if struct_name == "Self" || struct_name.starts_with("Self::") {
1011 syn::visit::visit_expr_struct(self, node);
1012 return;
1013 }
1014
1015 let full_path = self
1016 .imported_types
1017 .get(&struct_name)
1018 .cloned()
1019 .unwrap_or(struct_name.clone());
1020
1021 if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
1022 self.add_dependency(
1023 full_path,
1024 DependencyKind::TypeRef,
1025 UsageContext::StructConstruction,
1026 );
1027 self.usage_counts.struct_constructions += 1;
1028 }
1029 syn::visit::visit_expr_struct(self, node);
1030 }
1031}
1032
1033#[derive(Debug, Clone)]
1035struct AnalyzedFile {
1036 module_name: String,
1037 #[allow(dead_code)]
1038 file_path: PathBuf,
1039 metrics: ModuleMetrics,
1040 dependencies: Vec<Dependency>,
1041 type_visibility: HashMap<String, Visibility>,
1043 item_dependencies: Vec<ItemDependency>,
1045}
1046
1047pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1049 analyze_project_parallel(path)
1050}
1051
1052fn is_path_excluded(file_path: &Path, exclude_base: &Path, config: &CompiledConfig) -> bool {
1058 let normalized_file = normalize_exclude_path(file_path);
1059 let normalized_base = normalize_exclude_path(exclude_base);
1060 let relative = normalized_file
1061 .strip_prefix(&normalized_base)
1062 .unwrap_or(&normalized_file);
1063 let relative_str = relative.to_string_lossy().replace('\\', "/");
1064 config.should_exclude(&relative_str)
1065}
1066
1067fn normalize_exclude_path(path: &Path) -> PathBuf {
1072 let absolute = if path.is_absolute() {
1073 path.to_path_buf()
1074 } else {
1075 std::env::current_dir()
1076 .map(|cwd| cwd.join(path))
1077 .unwrap_or_else(|_| path.to_path_buf())
1078 };
1079
1080 let mut normalized = PathBuf::new();
1081 for component in absolute.components() {
1082 match component {
1083 Component::CurDir => {}
1084 Component::ParentDir => {
1085 normalized.pop();
1086 }
1087 other => normalized.push(other.as_os_str()),
1088 }
1089 }
1090
1091 normalized
1092}
1093
1094fn rs_files(dir: &Path) -> impl Iterator<Item = PathBuf> {
1100 WalkDir::new(dir)
1101 .follow_links(true)
1102 .into_iter()
1103 .filter_map(|e| e.ok())
1104 .filter(move |entry| {
1105 let file_path = entry.path();
1106 let file_path = file_path.strip_prefix(dir).unwrap_or(file_path);
1111
1112 !file_path.components().any(|c| {
1114 let s = c.as_os_str().to_string_lossy();
1115 s == "target" || s.starts_with('.')
1116 }) && file_path.extension() == Some(OsStr::new("rs"))
1117 })
1118 .map(|e| e.path().to_path_buf())
1119}
1120
1121pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1126 analyze_project_parallel_with_config(path, &CompiledConfig::empty())
1127}
1128
1129pub fn analyze_project_parallel_with_config(
1131 path: &Path,
1132 config: &CompiledConfig,
1133) -> Result<ProjectMetrics, AnalyzerError> {
1134 if !path.exists() {
1135 return Err(AnalyzerError::InvalidPath(path.display().to_string()));
1136 }
1137
1138 let exclude_base = config.config_root().unwrap_or(path);
1139
1140 let file_paths: Vec<PathBuf> = rs_files(path)
1142 .filter(|fp| !is_path_excluded(fp, exclude_base, config))
1143 .collect();
1144
1145 let num_threads = rayon::current_num_threads();
1149 let file_count = file_paths.len();
1150
1151 let chunk_size = if file_count < num_threads * 2 {
1154 1 } else {
1156 (file_count / (num_threads * 4)).max(1)
1159 };
1160
1161 let analyzed_results: Vec<_> = file_paths
1163 .par_chunks(chunk_size)
1164 .flat_map(|chunk| {
1165 chunk
1166 .iter()
1167 .filter_map(|file_path| match analyze_rust_file_full(file_path) {
1168 Ok(result) => {
1169 let module_path = file_path_to_module_path(file_path, path);
1171 let old_name = result.metrics.name.clone();
1172 let module_name = if module_path.is_empty() {
1173 old_name.clone()
1175 } else {
1176 module_path
1177 };
1178
1179 let item_dependencies = result
1181 .item_dependencies
1182 .into_iter()
1183 .map(|mut dep| {
1184 if dep.target_module.as_ref() == Some(&old_name) {
1185 dep.target_module = Some(module_name.clone());
1186 }
1187 dep
1188 })
1189 .collect();
1190
1191 Some(AnalyzedFile {
1192 module_name: module_name.clone(),
1193 file_path: file_path.clone(),
1194 metrics: {
1195 let mut m = result.metrics;
1196 m.name = module_name;
1197 m
1198 },
1199 dependencies: result.dependencies,
1200 type_visibility: result.type_visibility,
1201 item_dependencies,
1202 })
1203 }
1204 Err(e) => {
1205 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1206 None
1207 }
1208 })
1209 .collect::<Vec<_>>()
1210 })
1211 .collect();
1212
1213 let module_names: HashSet<String> = analyzed_results
1215 .iter()
1216 .map(|a| a.module_name.clone())
1217 .collect();
1218
1219 let mut project = ProjectMetrics::new();
1221 project.total_files = analyzed_results.len();
1222
1223 for analyzed in &analyzed_results {
1225 for (type_name, visibility) in &analyzed.type_visibility {
1226 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1227 }
1228 }
1229
1230 for analyzed in &analyzed_results {
1232 let mut metrics = analyzed.metrics.clone();
1234 metrics.item_dependencies = analyzed.item_dependencies.clone();
1235 project.add_module(metrics);
1236
1237 for dep in &analyzed.dependencies {
1238 if !is_valid_dependency_path(&dep.path) {
1240 continue;
1241 }
1242
1243 let target_module = extract_target_module(&dep.path);
1245
1246 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1248 continue;
1249 }
1250
1251 let distance = calculate_distance(&dep.path, &module_names);
1253
1254 let strength = dep.usage.to_strength();
1256
1257 let volatility = Volatility::Low;
1259
1260 let target_type = dep.path.split("::").last().unwrap_or(&dep.path);
1262 let visibility = project
1263 .get_type_visibility(target_type)
1264 .unwrap_or(Visibility::Public); let coupling = CouplingMetrics::with_location(
1268 analyzed.module_name.clone(),
1269 target_module.clone(),
1270 strength,
1271 distance,
1272 volatility,
1273 visibility,
1274 analyzed.file_path.clone(),
1275 dep.line,
1276 );
1277
1278 project.add_coupling(coupling);
1279 }
1280 }
1281
1282 project.update_coupling_visibility();
1284
1285 Ok(project)
1286}
1287
1288pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1290 analyze_workspace_with_config(path, &CompiledConfig::empty())
1291}
1292
1293pub fn analyze_workspace_with_config(
1295 path: &Path,
1296 config: &CompiledConfig,
1297) -> Result<ProjectMetrics, AnalyzerError> {
1298 let workspace = match WorkspaceInfo::from_path(path) {
1300 Ok(ws) => Some(ws),
1301 Err(e) => {
1302 eprintln!("Note: Could not load workspace metadata: {}", e);
1303 eprintln!("Falling back to basic analysis...");
1304 None
1305 }
1306 };
1307
1308 if let Some(ws) = workspace {
1309 analyze_with_workspace(path, &ws, config)
1310 } else {
1311 analyze_project_parallel_with_config(path, config)
1313 }
1314}
1315
1316fn analyze_with_workspace(
1318 _project_root: &Path,
1319 workspace: &WorkspaceInfo,
1320 config: &CompiledConfig,
1321) -> Result<ProjectMetrics, AnalyzerError> {
1322 let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1325
1326 let mut project = ProjectMetrics::new();
1327
1328 project.workspace_name = Some(
1330 workspace
1331 .root
1332 .file_name()
1333 .and_then(|n| n.to_str())
1334 .unwrap_or("workspace")
1335 .to_string(),
1336 );
1337 project.workspace_members = workspace.members.clone();
1338
1339 let mut file_crate_pairs: Vec<(PathBuf, String, PathBuf)> = Vec::new();
1342
1343 for member_name in &workspace.members {
1344 if let Some(crate_info) = workspace.get_crate(member_name) {
1345 if !crate_info.src_path.exists() {
1346 continue;
1347 }
1348
1349 let src_root = crate_info.src_path.clone();
1350 for file_path in rs_files(&crate_info.src_path) {
1351 if is_path_excluded(&file_path, exclude_base, config) {
1352 continue;
1353 }
1354 file_crate_pairs.push((
1355 file_path.to_path_buf(),
1356 member_name.clone(),
1357 src_root.clone(),
1358 ));
1359 }
1360 }
1361 }
1362
1363 let num_threads = rayon::current_num_threads();
1365 let file_count = file_crate_pairs.len();
1366 let chunk_size = if file_count < num_threads * 2 {
1367 1
1368 } else {
1369 (file_count / (num_threads * 4)).max(1)
1370 };
1371
1372 let analyzed_files: Vec<AnalyzedFileWithCrate> = file_crate_pairs
1374 .par_chunks(chunk_size)
1375 .flat_map(|chunk| {
1376 chunk
1377 .iter()
1378 .filter_map(|(file_path, crate_name, src_root)| {
1379 match analyze_rust_file_full(file_path) {
1380 Ok(result) => {
1381 let module_path = file_path_to_module_path(file_path, src_root);
1383 let old_name = result.metrics.name.clone();
1384 let module_name = if module_path.is_empty() {
1385 old_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(&old_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: crate_name.clone(),
1406 file_path: file_path.clone(),
1407 metrics: {
1408 let mut m = result.metrics;
1409 m.name = module_name;
1410 m
1411 },
1412 dependencies: result.dependencies,
1413 item_dependencies,
1414 })
1415 }
1416 Err(e) => {
1417 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1418 None
1419 }
1420 }
1421 })
1422 .collect::<Vec<_>>()
1423 })
1424 .collect();
1425
1426 project.total_files = analyzed_files.len();
1427
1428 let module_names: HashSet<String> = analyzed_files
1430 .iter()
1431 .map(|a| a.module_name.clone())
1432 .collect();
1433
1434 for analyzed in &analyzed_files {
1436 let mut metrics = analyzed.metrics.clone();
1438 metrics.item_dependencies = analyzed.item_dependencies.clone();
1439 project.add_module(metrics);
1440
1441 for dep in &analyzed.dependencies {
1442 if !is_valid_dependency_path(&dep.path) {
1444 continue;
1445 }
1446
1447 let resolved_crate =
1449 resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1450
1451 let target_module = extract_target_module(&dep.path);
1452
1453 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1455 continue;
1456 }
1457
1458 let distance =
1460 calculate_distance_with_workspace(&dep.path, &analyzed.crate_name, workspace);
1461
1462 let strength = dep.usage.to_strength();
1464
1465 let volatility = Volatility::Low;
1467
1468 let mut coupling = CouplingMetrics::with_location(
1470 format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1471 if let Some(ref crate_name) = resolved_crate {
1472 format!("{}::{}", crate_name, target_module)
1473 } else {
1474 target_module.clone()
1475 },
1476 strength,
1477 distance,
1478 volatility,
1479 Visibility::Public, analyzed.file_path.clone(),
1481 dep.line,
1482 );
1483
1484 coupling.source_crate = Some(analyzed.crate_name.clone());
1486 coupling.target_crate = resolved_crate;
1487
1488 project.add_coupling(coupling);
1489 }
1490 }
1491
1492 for (crate_name, deps) in &workspace.dependency_graph {
1494 if workspace.is_workspace_member(crate_name) {
1495 for dep in deps {
1496 project
1498 .crate_dependencies
1499 .entry(crate_name.clone())
1500 .or_default()
1501 .push(dep.clone());
1502 }
1503 }
1504 }
1505
1506 Ok(project)
1507}
1508
1509fn calculate_distance_with_workspace(
1511 dep_path: &str,
1512 current_crate: &str,
1513 workspace: &WorkspaceInfo,
1514) -> Distance {
1515 if dep_path.starts_with("crate::") || dep_path.starts_with("self::") {
1516 Distance::SameModule
1518 } else if dep_path.starts_with("super::") {
1519 Distance::DifferentModule
1521 } else {
1522 if let Some(target_crate) = resolve_crate_from_path(dep_path, current_crate, workspace) {
1524 if target_crate == current_crate {
1525 Distance::SameModule
1526 } else if workspace.is_workspace_member(&target_crate) {
1527 Distance::DifferentModule
1529 } else {
1530 Distance::DifferentCrate
1532 }
1533 } else {
1534 Distance::DifferentCrate
1535 }
1536 }
1537}
1538
1539#[derive(Debug, Clone)]
1541struct AnalyzedFileWithCrate {
1542 module_name: String,
1543 crate_name: String,
1544 #[allow(dead_code)]
1545 file_path: PathBuf,
1546 metrics: ModuleMetrics,
1547 dependencies: Vec<Dependency>,
1548 item_dependencies: Vec<ItemDependency>,
1550}
1551
1552fn extract_target_module(path: &str) -> String {
1554 let cleaned = path
1556 .trim_start_matches("crate::")
1557 .trim_start_matches("super::")
1558 .trim_start_matches("::");
1559
1560 cleaned.split("::").next().unwrap_or(path).to_string()
1562}
1563
1564fn is_valid_dependency_path(path: &str) -> bool {
1566 if path.is_empty() {
1568 return false;
1569 }
1570
1571 if path == "Self" || path.starts_with("Self::") {
1573 return false;
1574 }
1575
1576 let segments: Vec<&str> = path.split("::").collect();
1577
1578 if segments.len() == 1 {
1580 let name = segments[0];
1581 if name.len() <= 8 && name.chars().all(|c| c.is_lowercase() || c == '_') {
1582 return false;
1583 }
1584 }
1585
1586 if segments.len() >= 2 {
1588 let last = segments.last().unwrap();
1589 let second_last = segments.get(segments.len() - 2).unwrap();
1590 if last == second_last {
1591 return false;
1592 }
1593 }
1594
1595 let last_segment = segments.last().unwrap_or(&path);
1597 let common_locals = [
1598 "request",
1599 "response",
1600 "result",
1601 "content",
1602 "config",
1603 "proto",
1604 "domain",
1605 "info",
1606 "data",
1607 "item",
1608 "value",
1609 "error",
1610 "message",
1611 "expected",
1612 "actual",
1613 "status",
1614 "state",
1615 "context",
1616 "params",
1617 "args",
1618 "options",
1619 "settings",
1620 "violation",
1621 "page_token",
1622 ];
1623 if common_locals.contains(last_segment) && segments.len() <= 2 {
1624 return false;
1625 }
1626
1627 true
1628}
1629
1630fn calculate_distance(dep_path: &str, _known_modules: &HashSet<String>) -> Distance {
1632 if dep_path.starts_with("crate::") || dep_path.starts_with("super::") {
1633 Distance::DifferentModule
1635 } else if dep_path.starts_with("self::") {
1636 Distance::SameModule
1637 } else {
1638 Distance::DifferentCrate
1640 }
1641}
1642
1643pub struct AnalyzedFileResult {
1646 pub metrics: ModuleMetrics,
1647 pub dependencies: Vec<Dependency>,
1648 pub type_visibility: HashMap<String, Visibility>,
1649 pub item_dependencies: Vec<ItemDependency>,
1650}
1651
1652pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1653 let result = analyze_rust_file_full(path)?;
1654 Ok((result.metrics, result.dependencies))
1655}
1656
1657pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1659 let content = fs::read_to_string(path)?;
1660
1661 let module_name = path
1662 .file_stem()
1663 .and_then(|s| s.to_str())
1664 .unwrap_or("unknown")
1665 .to_string();
1666
1667 let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1668 analyzer.analyze_file(&content)?;
1669
1670 Ok(AnalyzedFileResult {
1671 metrics: analyzer.metrics,
1672 dependencies: analyzer.dependencies,
1673 type_visibility: analyzer.type_visibility,
1674 item_dependencies: analyzer.item_dependencies,
1675 })
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use super::*;
1681
1682 #[test]
1683 fn test_analyzer_creation() {
1684 let analyzer = CouplingAnalyzer::new(
1685 "test_module".to_string(),
1686 std::path::PathBuf::from("test.rs"),
1687 );
1688 assert_eq!(analyzer.current_module, "test_module");
1689 }
1690
1691 #[test]
1692 fn test_analyze_simple_file() {
1693 let mut analyzer =
1694 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1695
1696 let code = r#"
1697 pub struct User {
1698 name: String,
1699 email: String,
1700 }
1701
1702 impl User {
1703 pub fn new(name: String, email: String) -> Self {
1704 Self { name, email }
1705 }
1706 }
1707 "#;
1708
1709 let result = analyzer.analyze_file(code);
1710 assert!(result.is_ok());
1711 assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1712 }
1713
1714 #[test]
1715 fn test_item_dependencies() {
1716 let mut analyzer =
1717 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1718
1719 let code = r#"
1720 pub struct Config {
1721 pub value: i32,
1722 }
1723
1724 pub fn process(config: Config) -> i32 {
1725 let x = config.value;
1726 helper(x)
1727 }
1728
1729 fn helper(n: i32) -> i32 {
1730 n * 2
1731 }
1732 "#;
1733
1734 let result = analyzer.analyze_file(code);
1735 assert!(result.is_ok());
1736
1737 assert!(analyzer.defined_functions.contains_key("process"));
1739 assert!(analyzer.defined_functions.contains_key("helper"));
1740
1741 println!(
1743 "Item dependencies count: {}",
1744 analyzer.item_dependencies.len()
1745 );
1746 for dep in &analyzer.item_dependencies {
1747 println!(
1748 " {} -> {} ({:?})",
1749 dep.source_item, dep.target, dep.dep_type
1750 );
1751 }
1752
1753 let process_deps: Vec<_> = analyzer
1755 .item_dependencies
1756 .iter()
1757 .filter(|d| d.source_item == "process")
1758 .collect();
1759
1760 assert!(
1761 !process_deps.is_empty(),
1762 "process function should have item dependencies"
1763 );
1764 }
1765
1766 #[test]
1767 fn test_analyze_trait_impl() {
1768 let mut analyzer =
1769 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1770
1771 let code = r#"
1772 trait Printable {
1773 fn print(&self);
1774 }
1775
1776 struct Document;
1777
1778 impl Printable for Document {
1779 fn print(&self) {}
1780 }
1781 "#;
1782
1783 let result = analyzer.analyze_file(code);
1784 assert!(result.is_ok());
1785 assert!(analyzer.metrics.trait_impl_count >= 1);
1786 }
1787
1788 #[test]
1789 fn test_analyze_use_statements() {
1790 let mut analyzer =
1791 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1792
1793 let code = r#"
1794 use std::collections::HashMap;
1795 use serde::Serialize;
1796 use crate::utils;
1797 use crate::models::{User, Post};
1798 "#;
1799
1800 let result = analyzer.analyze_file(code);
1801 assert!(result.is_ok());
1802 assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1803 assert!(
1804 analyzer
1805 .metrics
1806 .external_deps
1807 .contains(&"serde".to_string())
1808 );
1809 assert!(!analyzer.dependencies.is_empty());
1810
1811 let internal_deps: Vec<_> = analyzer
1813 .dependencies
1814 .iter()
1815 .filter(|d| d.kind == DependencyKind::InternalUse)
1816 .collect();
1817 assert!(!internal_deps.is_empty());
1818 }
1819
1820 #[test]
1821 fn test_extract_use_paths() {
1822 let analyzer =
1823 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1824
1825 let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1827 let paths = analyzer.extract_use_paths(&tree, "");
1828 assert_eq!(paths.len(), 1);
1829 assert_eq!(paths[0].0, "std::collections::HashMap");
1830
1831 let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1833 let paths = analyzer.extract_use_paths(&tree, "");
1834 assert_eq!(paths.len(), 2);
1835 }
1836
1837 #[test]
1838 fn test_extract_target_module() {
1839 assert_eq!(extract_target_module("crate::models::user"), "models");
1840 assert_eq!(extract_target_module("super::utils"), "utils");
1841 assert_eq!(extract_target_module("std::collections"), "std");
1842 }
1843
1844 #[test]
1845 fn test_field_access_detection() {
1846 let mut analyzer =
1847 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1848
1849 let code = r#"
1850 use crate::models::User;
1851
1852 fn get_name(user: &User) -> String {
1853 user.name.clone()
1854 }
1855 "#;
1856
1857 let result = analyzer.analyze_file(code);
1858 assert!(result.is_ok());
1859
1860 let _field_deps: Vec<_> = analyzer
1862 .dependencies
1863 .iter()
1864 .filter(|d| d.usage == UsageContext::FieldAccess)
1865 .collect();
1866 }
1869
1870 #[test]
1871 fn test_method_call_detection() {
1872 let mut analyzer =
1873 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1874
1875 let code = r#"
1876 fn process() {
1877 let data = String::new();
1878 data.push_str("hello");
1879 }
1880 "#;
1881
1882 let result = analyzer.analyze_file(code);
1883 assert!(result.is_ok());
1884 }
1886
1887 #[test]
1888 fn test_struct_construction_detection() {
1889 let mut analyzer =
1890 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1891
1892 let code = r#"
1893 use crate::config::Config;
1894
1895 fn create_config() {
1896 let c = Config { value: 42 };
1897 }
1898 "#;
1899
1900 let result = analyzer.analyze_file(code);
1901 assert!(result.is_ok());
1902
1903 let struct_deps: Vec<_> = analyzer
1905 .dependencies
1906 .iter()
1907 .filter(|d| d.usage == UsageContext::StructConstruction)
1908 .collect();
1909 assert!(!struct_deps.is_empty());
1910 }
1911
1912 #[test]
1913 fn test_usage_context_to_strength() {
1914 assert_eq!(
1915 UsageContext::FieldAccess.to_strength(),
1916 IntegrationStrength::Intrusive
1917 );
1918 assert_eq!(
1919 UsageContext::MethodCall.to_strength(),
1920 IntegrationStrength::Functional
1921 );
1922 assert_eq!(
1923 UsageContext::TypeParameter.to_strength(),
1924 IntegrationStrength::Model
1925 );
1926 assert_eq!(
1927 UsageContext::TraitBound.to_strength(),
1928 IntegrationStrength::Contract
1929 );
1930 }
1931
1932 #[test]
1935 fn test_rs_files_with_hidden_parent_directory() {
1936 use std::fs;
1937 use tempfile::TempDir;
1938
1939 let temp = TempDir::new().unwrap();
1942 let hidden_parent = temp.path().join(".hidden-parent");
1943 let project_dir = hidden_parent.join("myproject").join("src");
1944 fs::create_dir_all(&project_dir).unwrap();
1945
1946 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1948 fs::write(project_dir.join("main.rs"), "fn main() {}").unwrap();
1949
1950 let files: Vec<_> = rs_files(&project_dir).collect();
1952 assert_eq!(
1953 files.len(),
1954 2,
1955 "Should find 2 .rs files in hidden parent path"
1956 );
1957
1958 let file_names: Vec<_> = files
1960 .iter()
1961 .filter_map(|p| p.file_name())
1962 .filter_map(|n| n.to_str())
1963 .collect();
1964 assert!(file_names.contains(&"lib.rs"));
1965 assert!(file_names.contains(&"main.rs"));
1966 }
1967
1968 #[test]
1970 fn test_rs_files_excludes_hidden_dirs_in_project() {
1971 use std::fs;
1972 use tempfile::TempDir;
1973
1974 let temp = TempDir::new().unwrap();
1975 let project_dir = temp.path().join("myproject").join("src");
1976 let hidden_dir = project_dir.join(".hidden");
1977 fs::create_dir_all(&hidden_dir).unwrap();
1978
1979 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1981 fs::write(hidden_dir.join("secret.rs"), "fn secret() {}").unwrap();
1982
1983 let files: Vec<_> = rs_files(&project_dir).collect();
1985 assert_eq!(
1986 files.len(),
1987 1,
1988 "Should find only 1 .rs file (excluding .hidden/)"
1989 );
1990
1991 let file_names: Vec<_> = files
1992 .iter()
1993 .filter_map(|p| p.file_name())
1994 .filter_map(|n| n.to_str())
1995 .collect();
1996 assert!(file_names.contains(&"lib.rs"));
1997 assert!(!file_names.contains(&"secret.rs"));
1998 }
1999
2000 #[test]
2002 fn test_rs_files_excludes_target_directory() {
2003 use std::fs;
2004 use tempfile::TempDir;
2005
2006 let temp = TempDir::new().unwrap();
2007 let project_dir = temp.path().join("myproject");
2008 let src_dir = project_dir.join("src");
2009 let target_dir = project_dir.join("target").join("debug");
2010 fs::create_dir_all(&src_dir).unwrap();
2011 fs::create_dir_all(&target_dir).unwrap();
2012
2013 fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
2015 fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
2016
2017 let files: Vec<_> = rs_files(&project_dir).collect();
2019 assert_eq!(
2020 files.len(),
2021 1,
2022 "Should find only 1 .rs file (excluding target/)"
2023 );
2024
2025 let file_names: Vec<_> = files
2026 .iter()
2027 .filter_map(|p| p.file_name())
2028 .filter_map(|n| n.to_str())
2029 .collect();
2030 assert!(file_names.contains(&"lib.rs"));
2031 assert!(!file_names.contains(&"generated.rs"));
2032 }
2033
2034 #[test]
2035 fn test_file_path_to_module_path_nested() {
2036 let src_root = Path::new("/project/src");
2038 let file_path = Path::new("/project/src/level/enemy/spawner.rs");
2039 assert_eq!(
2040 file_path_to_module_path(file_path, src_root),
2041 "level::enemy::spawner"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_file_path_to_module_path_lib() {
2047 let src_root = Path::new("/project/src");
2049 let file_path = Path::new("/project/src/lib.rs");
2050 assert_eq!(file_path_to_module_path(file_path, src_root), "");
2051 }
2052
2053 #[test]
2054 fn test_file_path_to_module_path_main() {
2055 let src_root = Path::new("/project/src");
2057 let file_path = Path::new("/project/src/main.rs");
2058 assert_eq!(file_path_to_module_path(file_path, src_root), "");
2059 }
2060
2061 #[test]
2062 fn test_file_path_to_module_path_mod() {
2063 let src_root = Path::new("/project/src");
2065 let file_path = Path::new("/project/src/level/mod.rs");
2066 assert_eq!(file_path_to_module_path(file_path, src_root), "level");
2067 }
2068
2069 #[test]
2070 fn test_file_path_to_module_path_deeply_nested_mod() {
2071 let src_root = Path::new("/project/src");
2073 let file_path = Path::new("/project/src/a/b/c/mod.rs");
2074 assert_eq!(file_path_to_module_path(file_path, src_root), "a::b::c");
2075 }
2076
2077 #[test]
2078 fn test_file_path_to_module_path_simple() {
2079 let src_root = Path::new("/project/src");
2081 let file_path = Path::new("/project/src/utils.rs");
2082 assert_eq!(file_path_to_module_path(file_path, src_root), "utils");
2083 }
2084
2085 #[test]
2086 fn test_file_path_to_module_path_two_levels() {
2087 let src_root = Path::new("/project/src");
2089 let file_path = Path::new("/project/src/foo/bar.rs");
2090 assert_eq!(file_path_to_module_path(file_path, src_root), "foo::bar");
2091 }
2092
2093 #[test]
2094 fn test_file_path_to_module_path_bin() {
2095 let src_root = Path::new("/project/src");
2097 let file_path = Path::new("/project/src/bin/cli.rs");
2098 assert_eq!(file_path_to_module_path(file_path, src_root), "bin::cli");
2099 }
2100
2101 #[test]
2102 fn test_file_path_to_module_path_mismatched_root() {
2103 let src_root = Path::new("/other/src");
2106 let file_path = Path::new("/project/src/utils.rs");
2107 let result = file_path_to_module_path(file_path, src_root);
2109 assert!(result.contains("utils"));
2111 }
2112
2113 #[test]
2114 fn test_has_test_attribute_with_test() {
2115 let code = r#"
2116 #[test]
2117 fn my_test() {}
2118 "#;
2119 let syntax: syn::File = syn::parse_str(code).unwrap();
2120 if let syn::Item::Fn(func) = &syntax.items[0] {
2121 assert!(has_test_attribute(&func.attrs));
2122 } else {
2123 panic!("Expected function");
2124 }
2125 }
2126
2127 #[test]
2128 fn test_has_test_attribute_without_test() {
2129 let code = r#"
2130 fn regular_fn() {}
2131 "#;
2132 let syntax: syn::File = syn::parse_str(code).unwrap();
2133 if let syn::Item::Fn(func) = &syntax.items[0] {
2134 assert!(!has_test_attribute(&func.attrs));
2135 } else {
2136 panic!("Expected function");
2137 }
2138 }
2139
2140 #[test]
2141 fn test_has_cfg_test_attribute_with_cfg_test() {
2142 let code = r#"
2143 #[cfg(test)]
2144 mod tests {}
2145 "#;
2146 let syntax: syn::File = syn::parse_str(code).unwrap();
2147 if let syn::Item::Mod(module) = &syntax.items[0] {
2148 assert!(has_cfg_test_attribute(&module.attrs));
2149 } else {
2150 panic!("Expected module");
2151 }
2152 }
2153
2154 #[test]
2155 fn test_has_cfg_test_attribute_without_cfg_test() {
2156 let code = r#"
2157 mod regular_mod {}
2158 "#;
2159 let syntax: syn::File = syn::parse_str(code).unwrap();
2160 if let syn::Item::Mod(module) = &syntax.items[0] {
2161 assert!(!has_cfg_test_attribute(&module.attrs));
2162 } else {
2163 panic!("Expected module");
2164 }
2165 }
2166
2167 #[test]
2168 fn test_has_cfg_test_attribute_with_other_cfg() {
2169 let code = r#"
2170 #[cfg(feature = "foo")]
2171 mod feature_mod {}
2172 "#;
2173 let syntax: syn::File = syn::parse_str(code).unwrap();
2174 if let syn::Item::Mod(module) = &syntax.items[0] {
2175 assert!(!has_cfg_test_attribute(&module.attrs));
2176 } else {
2177 panic!("Expected module");
2178 }
2179 }
2180
2181 #[test]
2182 fn test_is_test_module_named_tests() {
2183 let code = r#"
2184 mod tests {}
2185 "#;
2186 let syntax: syn::File = syn::parse_str(code).unwrap();
2187 if let syn::Item::Mod(module) = &syntax.items[0] {
2188 assert!(is_test_module(module));
2189 } else {
2190 panic!("Expected module");
2191 }
2192 }
2193
2194 #[test]
2195 fn test_is_test_module_with_cfg_test() {
2196 let code = r#"
2197 #[cfg(test)]
2198 mod my_tests {}
2199 "#;
2200 let syntax: syn::File = syn::parse_str(code).unwrap();
2201 if let syn::Item::Mod(module) = &syntax.items[0] {
2202 assert!(is_test_module(module));
2203 } else {
2204 panic!("Expected module");
2205 }
2206 }
2207
2208 #[test]
2209 fn test_is_test_module_regular_module() {
2210 let code = r#"
2211 mod utils {}
2212 "#;
2213 let syntax: syn::File = syn::parse_str(code).unwrap();
2214 if let syn::Item::Mod(module) = &syntax.items[0] {
2215 assert!(!is_test_module(module));
2216 } else {
2217 panic!("Expected module");
2218 }
2219 }
2220
2221 #[test]
2227 fn test_analyze_project_parallel_applies_exclude_patterns() {
2228 use crate::config::{CompiledConfig, CouplingConfig};
2229
2230 let tmp = tempfile::tempdir().expect("create tempdir");
2231 let root = tmp.path();
2232 let src = root.join("src");
2233 let generated = src.join("generated");
2234 std::fs::create_dir_all(&generated).expect("create generated dir");
2235 std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2236 .expect("write lib.rs");
2237 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2238 .expect("write generated/mod.rs");
2239
2240 let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2242 .expect("baseline analysis");
2243 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2244 assert!(
2245 baseline.modules.keys().any(|k| k.contains("generated")),
2246 "baseline must include the generated module; saw {:?}",
2247 baseline.modules.keys().collect::<Vec<_>>()
2248 );
2249
2250 let toml = r#"
2252 [analysis]
2253 exclude = ["src/generated/*", "src/generated/**"]
2254 "#;
2255 let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2256 let compiled = CompiledConfig::from_config(config).expect("compile config");
2257 let filtered =
2258 analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2259 assert_eq!(
2260 filtered.total_files, 1,
2261 "generated file should be excluded from analysis"
2262 );
2263 assert!(
2264 !filtered.modules.keys().any(|k| k.contains("generated")),
2265 "no generated module should remain; saw {:?}",
2266 filtered.modules.keys().collect::<Vec<_>>()
2267 );
2268 }
2269
2270 #[test]
2273 fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2274 use crate::config::{CompiledConfig, load_compiled_config};
2275
2276 let current_dir = std::env::current_dir().expect("get current dir");
2277 let target_dir = current_dir.join("target");
2278 let tmp = tempfile::Builder::new()
2279 .prefix("issue39-workspace-")
2280 .tempdir_in(&target_dir)
2281 .expect("create tempdir in target");
2282 let root = tmp.path();
2283 let src = root.join("src");
2284 let generated = src.join("generated");
2285 std::fs::create_dir_all(&generated).expect("create generated dir");
2286 std::fs::write(
2287 root.join("Cargo.toml"),
2288 r#"[package]
2289name = "coupling-fixture-exclude"
2290version = "0.1.0"
2291edition = "2024"
2292"#,
2293 )
2294 .expect("write Cargo.toml");
2295 std::fs::write(
2296 root.join(".coupling.toml"),
2297 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2298 )
2299 .expect("write .coupling.toml");
2300 std::fs::write(
2301 src.join("lib.rs"),
2302 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2303 )
2304 .expect("write lib.rs");
2305 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2306 .expect("write generated/mod.rs");
2307
2308 let relative_src = src
2309 .strip_prefix(¤t_dir)
2310 .expect("temp crate should be under current dir");
2311
2312 let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2313 .expect("baseline workspace analysis");
2314 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2315 assert!(
2316 baseline.modules.keys().any(|k| k.contains("generated")),
2317 "baseline must include the generated module; saw {:?}",
2318 baseline.modules.keys().collect::<Vec<_>>()
2319 );
2320
2321 let compiled = load_compiled_config(relative_src).expect("load compiled config");
2322 let filtered = analyze_workspace_with_config(relative_src, &compiled)
2323 .expect("filtered workspace analysis");
2324 assert_eq!(
2325 filtered.total_files, 1,
2326 "generated file should be excluded from workspace analysis"
2327 );
2328 assert!(
2329 !filtered.modules.keys().any(|k| k.contains("generated")),
2330 "no generated module should remain; saw {:?}",
2331 filtered.modules.keys().collect::<Vec<_>>()
2332 );
2333 }
2334
2335 #[test]
2338 fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2339 use crate::config::{CompiledConfig, load_compiled_config};
2340
2341 let tmp = tempfile::tempdir().expect("create tempdir");
2342 let root = tmp.path();
2343 let src = root.join("src");
2344 let generated = src.join("generated");
2345 std::fs::create_dir_all(&generated).expect("create generated dir");
2346 std::fs::write(
2347 root.join(".coupling.toml"),
2348 "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2349 )
2350 .expect("write .coupling.toml");
2351 std::fs::write(
2352 src.join("lib.rs"),
2353 "pub mod generated;\npub fn call() { generated::helper(); }\n",
2354 )
2355 .expect("write lib.rs");
2356 std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2357 .expect("write generated/mod.rs");
2358
2359 let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
2360 .expect("baseline analysis");
2361 assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2362 assert!(
2363 baseline.modules.keys().any(|k| k.contains("generated")),
2364 "baseline must include the generated module; saw {:?}",
2365 baseline.modules.keys().collect::<Vec<_>>()
2366 );
2367
2368 let compiled = load_compiled_config(&src).expect("load compiled config");
2369 let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2370 assert_eq!(
2371 filtered.total_files, 1,
2372 "generated file should be excluded from fallback analysis"
2373 );
2374 assert!(
2375 !filtered.modules.keys().any(|k| k.contains("generated")),
2376 "no generated module should remain; saw {:?}",
2377 filtered.modules.keys().collect::<Vec<_>>()
2378 );
2379 }
2380}