Skip to main content

cargo_coupling/
analyzer.rs

1//! Rust AST analysis for coupling detection.
2//!
3//! This module converts source files and workspace metadata into `ProjectMetrics`,
4//! giving the balance layer structural evidence about imports, type usage,
5//! calls, visibility, and item-level dependencies.
6
7use std::collections::{HashMap, HashSet};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use rayon::prelude::*;
12use syn::visit::Visit;
13use syn::{
14    Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
15    ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
16};
17use thiserror::Error;
18
19use crate::config::CompiledConfig;
20use crate::discovery::{
21    DiscoveredWorkspaceFile, canonical_file_key, discover_module_tree, file_path_to_module_path,
22    normalize_exclude_path, rs_files, rs_files_excluding_nested_packages,
23};
24use crate::metrics::coupling::CouplingMetrics;
25use crate::metrics::dimensions::{IntegrationStrength, Visibility};
26use crate::metrics::module::ModuleMetrics;
27use crate::metrics::project::ProjectMetrics;
28use crate::volatility::Volatility;
29use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
30
31// ===== Syntax Helpers =====
32
33/// Convert syn's Visibility to our Visibility enum
34fn convert_visibility(vis: &syn::Visibility) -> Visibility {
35    match vis {
36        syn::Visibility::Public(_) => Visibility::Public,
37        syn::Visibility::Restricted(restricted) => {
38            // Check the path to determine the restriction type
39            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, // pub(self) is effectively private
51                _ => Visibility::PubIn,        // pub(in path)
52            }
53        }
54        syn::Visibility::Inherited => Visibility::Private,
55    }
56}
57
58/// Check if an item has the #[test] attribute
59fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
60    attrs.iter().any(|attr| attr.path().is_ident("test"))
61}
62
63/// Check if an item has #[cfg(test)] attribute
64fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
65    attrs.iter().any(|attr| {
66        if attr.path().is_ident("cfg") {
67            // Try to parse the attribute content
68            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
77/// Check if a module is a test module (named "tests" or has #[cfg(test)])
78fn is_test_module(item: &ItemMod) -> bool {
79    item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
80}
81
82// ===== Public Analysis Model =====
83
84/// Errors that can occur during analysis
85#[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/// Represents a detected dependency
101#[derive(Debug, Clone)]
102pub struct Dependency {
103    /// Full path of the dependency (e.g., "crate::models::user")
104    pub path: String,
105    /// Type of dependency
106    pub kind: DependencyKind,
107    /// Line number where the dependency is declared
108    pub line: usize,
109    /// Usage context for more accurate strength determination
110    pub usage: UsageContext,
111}
112
113/// Kind of dependency
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum DependencyKind {
116    /// use crate::xxx or use super::xxx
117    InternalUse,
118    /// use external_crate::xxx
119    ExternalUse,
120    /// impl Trait for Type
121    TraitImpl,
122    /// impl Type
123    InherentImpl,
124    /// Type reference in struct fields, function params, etc.
125    TypeRef,
126}
127
128/// Context of how a dependency is used - determines Integration Strength
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
130pub enum UsageContext {
131    /// Just imported, usage unknown
132    Import,
133    /// Used as a trait bound or trait impl
134    TraitBound,
135    /// Field access: `foo.bar`
136    FieldAccess,
137    /// Method call: `foo.method()`
138    MethodCall,
139    /// Function call: `Foo::new()` or `foo()`
140    FunctionCall,
141    /// Struct construction: `Foo { field: value }`
142    StructConstruction,
143    /// Type parameter: `Vec<Foo>`
144    TypeParameter,
145    /// Function parameter type
146    FunctionParameter,
147    /// Return type
148    ReturnType,
149    /// Inherent impl block
150    InherentImplBlock,
151}
152
153impl UsageContext {
154    /// Convert usage context to integration strength
155    pub fn to_strength(&self) -> IntegrationStrength {
156        match self {
157            // Intrusive: implementation-level access, or unknown data access.
158            UsageContext::FieldAccess => IntegrationStrength::Intrusive,
159            UsageContext::StructConstruction => IntegrationStrength::Intrusive,
160            UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
161
162            // Functional: Depends on function signatures
163            UsageContext::MethodCall => IntegrationStrength::Functional,
164            UsageContext::FunctionCall => IntegrationStrength::Functional,
165            UsageContext::FunctionParameter => IntegrationStrength::Functional,
166            UsageContext::ReturnType => IntegrationStrength::Functional,
167
168            // Model: Uses data types
169            UsageContext::TypeParameter => IntegrationStrength::Model,
170            UsageContext::Import => IntegrationStrength::Model,
171
172            // Contract: Uses traits/interfaces
173            UsageContext::TraitBound => IntegrationStrength::Contract,
174        }
175    }
176}
177
178impl DependencyKind {
179    /// Convert coarse dependency kind to its default integration strength.
180    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/// AST visitor for coupling analysis
192#[derive(Debug)]
193pub struct CouplingAnalyzer {
194    /// Current module being analyzed
195    pub current_module: String,
196    /// File path
197    pub file_path: std::path::PathBuf,
198    /// Collected metrics
199    pub metrics: ModuleMetrics,
200    /// Detected dependencies
201    pub dependencies: Vec<Dependency>,
202    /// Defined types in this module
203    pub defined_types: HashSet<String>,
204    /// Defined traits in this module
205    pub defined_traits: HashSet<String>,
206    /// Defined functions in this module (name -> visibility)
207    pub defined_functions: HashMap<String, Visibility>,
208    /// Imported types (name -> full path)
209    imported_types: HashMap<String, String>,
210    /// Track unique dependencies to avoid duplicates
211    seen_dependencies: HashSet<(String, UsageContext)>,
212    /// Counts of each usage type for statistics
213    pub usage_counts: UsageCounts,
214    /// Type visibility map: type name -> visibility
215    pub type_visibility: HashMap<String, Visibility>,
216    /// Current item being analyzed (function name, struct name, etc.)
217    current_item: Option<(String, ItemKind)>,
218    /// Item-level dependencies (detailed tracking)
219    pub item_dependencies: Vec<ItemDependency>,
220}
221
222/// Statistics about usage patterns
223#[derive(Debug, Default, Clone)]
224pub struct UsageCounts {
225    /// Number of detected field access expressions.
226    pub field_accesses: usize,
227    /// Number of detected method calls.
228    pub method_calls: usize,
229    /// Number of detected associated or free function calls.
230    pub function_calls: usize,
231    /// Number of detected struct construction expressions.
232    pub struct_constructions: usize,
233    /// Number of trait bounds or trait implementations.
234    pub trait_bounds: usize,
235    /// Number of type parameter or field type usages.
236    pub type_parameters: usize,
237}
238
239/// Detailed dependency at the item level (function, struct, etc.)
240#[derive(Debug, Clone)]
241pub struct ItemDependency {
242    /// Source item (e.g., "fn analyze_project")
243    pub source_item: String,
244    /// Source item kind
245    pub source_kind: ItemKind,
246    /// Target (e.g., "ProjectMetrics" or "analyze_file")
247    pub target: String,
248    /// Target module (if known)
249    pub target_module: Option<String>,
250    /// Type of dependency
251    pub dep_type: ItemDepType,
252    /// Line number in source
253    pub line: usize,
254    /// The actual expression/code (e.g., "config.thresholds" or "self.couplings")
255    pub expression: Option<String>,
256}
257
258/// Kind of source item
259#[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/// Type of item-level dependency
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum ItemDepType {
273    /// Calls a function: foo()
274    FunctionCall,
275    /// Calls a method: x.foo()
276    MethodCall,
277    /// Uses a type: Vec<Foo>
278    TypeUsage,
279    /// Accesses a field: x.field
280    FieldAccess,
281    /// Constructs a struct: Foo { ... }
282    StructConstruction,
283    /// Implements a trait: impl Trait for Type
284    TraitImpl,
285    /// Uses a trait bound: T: Trait
286    TraitBound,
287    /// Imports: use foo::Bar
288    Import,
289}
290
291// ===== AST Visitor =====
292
293impl CouplingAnalyzer {
294    /// Create a new analyzer for a module
295    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    /// Analyze a Rust source file
314    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    /// Add a dependency with deduplication
324    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    /// Record an item-level dependency with detailed tracking
340    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            // Determine target module
349            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    /// Extract full path from UseTree recursively
372    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    /// Extract type name from a Type
430    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    /// Analyze function signature for dependencies
452    fn analyze_signature(&mut self, sig: &Signature) {
453        // Analyze parameters
454        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        // Analyze return type
468        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    /// Check if a type should be ignored (primitives, self, or short variable names)
477    fn is_primitive_type(&self, type_name: &str) -> bool {
478        // Primitive types
479        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        // Short variable names (likely local variables, not types)
516        // Type names in Rust are typically PascalCase and longer
517        if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
518            return true;
519        }
520
521        // Self-references or obviously local
522        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            // Skip self references
536            if path == "self" || path.starts_with("self::") {
537                continue;
538            }
539
540            // Track imported types for later resolution
541            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            // Update metrics
549            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                // Extract crate name
555                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            // Trait implementation = Contract coupling
568            self.metrics.trait_impl_count += 1;
569
570            // Extract trait path
571            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            // Inherent implementation of another module's type is implementation-level coupling.
586            self.metrics.inherent_impl_count += 1;
587
588            // Get the type being implemented
589            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        // Record function definition
604        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        // Check if this is a test function
609        if has_test_attribute(&node.attrs) {
610            self.metrics.test_function_count += 1;
611        }
612
613        // Analyze parameters for primitive obsession detection
614        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        // Register in module metrics with full details
631        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        // Set current item context for dependency tracking
640        let previous_item = self.current_item.take();
641        self.current_item = Some((fn_name, ItemKind::Function));
642
643        // Analyze function signature
644        self.analyze_signature(&node.sig);
645        syn::visit::visit_item_fn(self, node);
646
647        // Restore previous context
648        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        // Detect newtype pattern: single-field tuple struct
659        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        // Check for serde derives
671        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        // Count fields and public fields
694        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        // Register in module metrics with full details
717        self.metrics.add_type_definition_full(
718            name,
719            visibility,
720            false, // is_trait
721            is_newtype,
722            inner_type,
723            has_serde_derive,
724            public_field_count,
725            total_field_count,
726        );
727
728        // Analyze struct fields for type dependencies
729        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        // Register in module metrics with visibility
771        self.metrics.add_type_definition(name, visibility, false);
772
773        // Analyze enum variants for type dependencies
774        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        // Register in module metrics with visibility (is_trait = true)
816        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        // Check if this is a test module (named "tests" or has #[cfg(test)])
824        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    // Detect field access: `foo.bar`
835    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        // Cross-module field access is classified later using target type visibility.
842        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            // Resolve to full path if imported
852            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            // Record item-level dependency with field name
868            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    // Detect method calls: `foo.method()`
880    fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
881        let method_name = node.method.to_string();
882
883        // This is a method call - Functional coupling
884        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            // Record item-level dependency
909            let expr = format!("{}.{}()", receiver_name, method_name);
910            self.add_item_dependency(
911                format!("{}::{}", full_path, method_name),
912                ItemDepType::MethodCall,
913                0, // TODO: get line number from span
914                Some(expr),
915            );
916        }
917        syn::visit::visit_expr_method_call(self, node);
918    }
919
920    // Detect function calls: `Foo::new()` or `foo()`
921    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            // Check if this is a constructor or associated function call
932            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                // Record item-level dependency
950                self.add_item_dependency(
951                    full_path,
952                    ItemDepType::FunctionCall,
953                    0,
954                    Some(format!("{}()", path_str)),
955                );
956            } else {
957                // Simple function call like foo()
958                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    // Detect struct construction: `Foo { field: value }`
970    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        // Skip Self and self constructions
980        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// ===== Project Analysis Pipeline =====
1004
1005/// Analyzed file data
1006#[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 information from this file
1014    type_visibility: HashMap<String, Visibility>,
1015    /// Item-level dependencies (function calls, field access, etc.)
1016    item_dependencies: Vec<ItemDependency>,
1017}
1018
1019/// Analyze an entire project (parallel version)
1020pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1021    analyze_project_parallel(path)
1022}
1023
1024/// Check whether a file path should be excluded according to `[analysis].exclude` patterns.
1025///
1026/// Patterns are evaluated relative to the directory that contained `.coupling.toml`
1027/// when known; otherwise they fall back to the analysis root. Paths are normalized
1028/// to forward slashes for consistent glob matching on Windows.
1029fn 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
1039/// Convert a source path into the same normalized, config-root-relative form used by glob config.
1040fn 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
1056/// Analyze a project using parallel processing with Rayon
1057///
1058/// Automatically scales to available CPU cores. The parallel processing
1059/// uses work-stealing for optimal load balancing across cores.
1060pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1061    analyze_project_parallel_with_config(path, &CompiledConfig::empty())
1062}
1063
1064/// Analyze a project in parallel, honoring `[analysis].exclude` patterns from config.
1065pub 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    // Collect all .rs file paths first (sequential, but fast), applying exclude patterns.
1076    let file_paths: Vec<PathBuf> = rs_files(path)
1077        .filter(|fp| !is_path_excluded(fp, exclude_base, config))
1078        .collect();
1079
1080    // Calculate optimal chunk size based on file count and available parallelism
1081    // Smaller chunks = better load balancing, but more overhead
1082    // Larger chunks = less overhead, but potential load imbalance
1083    let num_threads = rayon::current_num_threads();
1084    let file_count = file_paths.len();
1085
1086    // Use smaller chunks for better load balancing with work-stealing
1087    // Minimum chunk size of 1, maximum of file_count / (num_threads * 4)
1088    let chunk_size = if file_count < num_threads * 2 {
1089        1 // Small projects: process one file at a time
1090    } else {
1091        // Larger projects: balance between parallelism and overhead
1092        // Use ~4 chunks per thread for good work-stealing behavior
1093        (file_count / (num_threads * 4)).max(1)
1094    };
1095
1096    // Parallel file analysis with optimized chunking
1097    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                        // Use full module path instead of just file stem (Issue #14)
1105                        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                            // Crate root (lib.rs/main.rs) - use the original name
1109                            original_module_name.clone()
1110                        } else {
1111                            module_path
1112                        };
1113
1114                        // Update target_module in item_dependencies if it referenced the old name
1115                        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    // Build module names set
1149    let module_names: HashSet<String> = analyzed_results
1150        .iter()
1151        .map(|a| a.module_name.clone())
1152        .collect();
1153
1154    // Build project metrics (sequential, but fast)
1155    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    // Discovered (pre-parse) files: a pattern matching only a parse-failing file is
1159    // covered by the parse-failure note, not drift.
1160    let candidate_config_paths = file_paths
1161        .iter()
1162        .map(|file_path| path_for_config_matching(file_path, config))
1163        .collect::<Vec<_>>();
1164
1165    // First pass: register all types with their visibility
1166    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    // Second pass: add modules and couplings
1173    for analyzed in &analyzed_results {
1174        // Clone metrics and add item_dependencies
1175        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            // Skip invalid dependency paths (local variables, Self, etc.)
1183            if !is_valid_dependency_path(&dep.path) {
1184                continue;
1185            }
1186
1187            // Determine if this is an internal coupling
1188            let target_module =
1189                resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
1190            let target_is_known_internal_module = module_names.contains(&target_module);
1191
1192            // Skip if target module looks invalid (but allow known module names)
1193            if !target_is_known_internal_module && !is_valid_dependency_path(&target_module) {
1194                continue;
1195            }
1196
1197            // Calculate structural distance after resolving the target module.
1198            let distance = calculate_distance(
1199                &analyzed.module_name,
1200                &target_module,
1201                target_is_known_internal_module,
1202            );
1203
1204            let target_type = target_type_name(&dep.path);
1205            let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
1206            let strength = strength_for_dependency(dep, target_visibility);
1207
1208            // Default volatility
1209            let volatility = Volatility::Low;
1210
1211            let visibility = visibility_for_dependency(dep, target_visibility);
1212
1213            // Create coupling metric with location
1214            let coupling = CouplingMetrics::with_location(
1215                analyzed.module_name.clone(),
1216                target_module.clone(),
1217                strength,
1218                distance,
1219                volatility,
1220                visibility,
1221                analyzed.file_path.clone(),
1222                dep.line,
1223            );
1224
1225            project.add_coupling(coupling);
1226        }
1227    }
1228
1229    // Update any remaining coupling visibility information
1230    project.update_coupling_visibility();
1231    project.dead_config_patterns =
1232        format_dead_config_patterns(config, &candidate_config_paths, path);
1233
1234    Ok(project)
1235}
1236
1237/// Analyze a workspace using cargo metadata for better accuracy
1238pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1239    analyze_workspace_with_config(path, &CompiledConfig::empty())
1240}
1241
1242/// Analyze a workspace, honoring `[analysis].exclude` patterns from config.
1243pub fn analyze_workspace_with_config(
1244    path: &Path,
1245    config: &CompiledConfig,
1246) -> Result<ProjectMetrics, AnalyzerError> {
1247    // Try to get workspace info
1248    let workspace = match WorkspaceInfo::from_path(path) {
1249        Ok(ws) => Some(ws),
1250        Err(e) => {
1251            eprintln!("Note: Could not load workspace metadata: {}", e);
1252            eprintln!("Falling back to basic analysis...");
1253            None
1254        }
1255    };
1256
1257    if let Some(ws) = workspace {
1258        analyze_with_workspace(path, &ws, config)
1259    } else {
1260        // Fall back to basic analysis
1261        analyze_project_parallel_with_config(path, config)
1262    }
1263}
1264
1265/// Analyze project with workspace information (parallel version)
1266fn analyze_with_workspace(
1267    _project_root: &Path,
1268    workspace: &WorkspaceInfo,
1269    config: &CompiledConfig,
1270) -> Result<ProjectMetrics, AnalyzerError> {
1271    // Exclude patterns are rooted at the config file when known. Otherwise fall back
1272    // to the workspace root returned by `cargo metadata`.
1273    let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1274
1275    let mut project = ProjectMetrics::new();
1276
1277    // Store workspace info for the report
1278    project.workspace_name = Some(
1279        workspace
1280            .root
1281            .file_name()
1282            .and_then(|n| n.to_str())
1283            .unwrap_or("workspace")
1284            .to_string(),
1285    );
1286    project.workspace_members = workspace.members.clone();
1287
1288    // Collect file paths and names; module-tree parsing only runs for members using `#[path]`.
1289    let mut discovered_files: Vec<DiscoveredWorkspaceFile> = Vec::new();
1290
1291    for member_name in &workspace.members {
1292        if let Some(crate_info) = workspace.get_crate(member_name) {
1293            let mut member_files: HashMap<PathBuf, DiscoveredWorkspaceFile> = HashMap::new();
1294            let mut source_contents = HashMap::new();
1295
1296            for source_root in &crate_info.source_roots {
1297                if !source_root.exists() {
1298                    continue;
1299                }
1300
1301                for file_path in
1302                    rs_files_excluding_nested_packages(source_root, &crate_info.manifest_path)
1303                {
1304                    if is_path_excluded(&file_path, exclude_base, config) {
1305                        continue;
1306                    }
1307                    let file_key = canonical_file_key(&file_path);
1308                    member_files
1309                        .entry(file_key)
1310                        .or_insert_with(|| DiscoveredWorkspaceFile {
1311                            file_path: file_path.to_path_buf(),
1312                            crate_name: member_name.clone(),
1313                            source_root: source_root.clone(),
1314                            module_name: None,
1315                        });
1316                }
1317            }
1318
1319            let has_path_attribute = member_files
1320                .keys()
1321                .any(|file_key| cache_file_and_scan_path_attribute(file_key, &mut source_contents));
1322
1323            if has_path_attribute {
1324                let mut visited = HashSet::new();
1325
1326                // Module-tree resolution only ADDS files the directory walk could not see
1327                // (e.g. `#[path]` modules outside the source roots). Files the walk already
1328                // found keep their walk-based names so existing layouts are named as before.
1329                for crate_root in &crate_info.crate_roots {
1330                    let discovery = discover_module_tree(
1331                        crate_root,
1332                        &workspace.root,
1333                        &crate_info.manifest_path,
1334                        &mut visited,
1335                        &mut source_contents,
1336                    );
1337                    project.boundary_skipped_files += discovery.boundary_skipped_files;
1338
1339                    for module_file in discovery.files {
1340                        if is_path_excluded(&module_file.file_path, exclude_base, config) {
1341                            continue;
1342                        }
1343                        let file_key = canonical_file_key(&module_file.file_path);
1344                        member_files
1345                            .entry(file_key)
1346                            .or_insert_with(|| DiscoveredWorkspaceFile {
1347                                file_path: module_file.file_path.clone(),
1348                                crate_name: member_name.clone(),
1349                                source_root: module_file
1350                                    .file_path
1351                                    .parent()
1352                                    .map(Path::to_path_buf)
1353                                    .unwrap_or_default(),
1354                                module_name: Some(module_file.module_name),
1355                            });
1356                    }
1357                }
1358            }
1359
1360            if member_files.is_empty() {
1361                project.skipped_crates.push(member_name.clone());
1362            } else {
1363                discovered_files.extend(member_files.into_values());
1364            }
1365        }
1366    }
1367
1368    // Calculate optimal chunk size for parallel processing
1369    let num_threads = rayon::current_num_threads();
1370    let file_count = discovered_files.len();
1371    let chunk_size = if file_count < num_threads * 2 {
1372        1
1373    } else {
1374        (file_count / (num_threads * 4)).max(1)
1375    };
1376
1377    // Parallel file analysis with optimized chunking
1378    let analyzed_files: Vec<AnalyzedFileWithCrate> = discovered_files
1379        .par_chunks(chunk_size)
1380        .flat_map(|chunk| {
1381            chunk
1382                .iter()
1383                .filter_map(|discovered| {
1384                    match analyze_rust_file_full(&discovered.file_path) {
1385                        Ok(result) => {
1386                            // Use full module path instead of just file stem (Issue #14)
1387                            let module_path = discovered.module_name.clone().unwrap_or_else(|| {
1388                                file_path_to_module_path(
1389                                    &discovered.file_path,
1390                                    &discovered.source_root,
1391                                )
1392                            });
1393                            let original_module_name = result.metrics.name.clone();
1394                            let module_name = if module_path.is_empty() {
1395                                // Crate root (lib.rs/main.rs) - use the original name
1396                                original_module_name.clone()
1397                            } else {
1398                                module_path
1399                            };
1400
1401                            // Update target_module in item_dependencies if it referenced the old name
1402                            let item_dependencies = result
1403                                .item_dependencies
1404                                .into_iter()
1405                                .map(|mut dep| {
1406                                    if dep.target_module.as_ref() == Some(&original_module_name) {
1407                                        dep.target_module = Some(module_name.clone());
1408                                    }
1409                                    dep
1410                                })
1411                                .collect();
1412
1413                            Some(AnalyzedFileWithCrate {
1414                                module_name: module_name.clone(),
1415                                crate_name: discovered.crate_name.clone(),
1416                                file_path: discovered.file_path.clone(),
1417                                metrics: {
1418                                    let mut module_metrics = result.metrics;
1419                                    module_metrics.name = module_name;
1420                                    module_metrics
1421                                },
1422                                dependencies: result.dependencies,
1423                                type_visibility: result.type_visibility,
1424                                item_dependencies,
1425                            })
1426                        }
1427                        Err(e) => {
1428                            eprintln!(
1429                                "Warning: Failed to analyze {}: {}",
1430                                discovered.file_path.display(),
1431                                e
1432                            );
1433                            None
1434                        }
1435                    }
1436                })
1437                .collect::<Vec<_>>()
1438        })
1439        .collect();
1440
1441    project.total_files = analyzed_files.len();
1442    project.parse_failures = discovered_files.len().saturating_sub(analyzed_files.len());
1443    // Discovered (pre-parse) files: a pattern matching only a parse-failing file is
1444    // covered by the parse-failure note, not drift.
1445    let candidate_config_paths = discovered_files
1446        .iter()
1447        .map(|discovered| path_for_config_matching(&discovered.file_path, config))
1448        .collect::<Vec<_>>();
1449
1450    // Build set of known module names for validation
1451    let module_names: HashSet<String> = analyzed_files
1452        .iter()
1453        .map(|a| a.module_name.clone())
1454        .collect();
1455
1456    // First pass: register all types with their visibility before resolving dependencies.
1457    for analyzed in &analyzed_files {
1458        for (type_name, visibility) in &analyzed.type_visibility {
1459            project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1460        }
1461    }
1462
1463    // Second pass: build coupling relationships with workspace context
1464    for analyzed in &analyzed_files {
1465        // Clone metrics and add item_dependencies
1466        let mut metrics = analyzed.metrics.clone();
1467        metrics.item_dependencies = analyzed.item_dependencies.clone();
1468        metrics.subdomain =
1469            config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
1470        project.add_module(metrics);
1471
1472        for dep in &analyzed.dependencies {
1473            // Skip invalid dependency paths (local variables, Self, etc.)
1474            if !is_valid_dependency_path(&dep.path) {
1475                continue;
1476            }
1477
1478            // Resolve the target crate using workspace info
1479            let resolved_crate =
1480                resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1481
1482            let target_module =
1483                resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
1484            let target_is_known_internal_module = module_names.contains(&target_module);
1485            let resolved_crate = if resolved_crate.is_none() && target_is_known_internal_module {
1486                Some(analyzed.crate_name.clone())
1487            } else {
1488                resolved_crate
1489            };
1490
1491            // Skip if target module looks invalid (but allow known module names)
1492            if !target_is_known_internal_module && !is_valid_dependency_path(&target_module) {
1493                continue;
1494            }
1495
1496            // Calculate structural distance with workspace awareness.
1497            let distance = calculate_distance_with_workspace(
1498                &analyzed.module_name,
1499                &target_module,
1500                target_is_known_internal_module,
1501                &analyzed.crate_name,
1502                resolved_crate.as_deref(),
1503                workspace,
1504            );
1505
1506            let target_type = target_type_name(&dep.path);
1507            let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
1508            let strength = strength_for_dependency(dep, target_visibility);
1509
1510            // Default volatility
1511            let volatility = Volatility::Low;
1512
1513            // Create coupling metric with location info
1514            let mut coupling = CouplingMetrics::with_location(
1515                format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1516                if let Some(ref crate_name) = resolved_crate {
1517                    format!("{}::{}", crate_name, target_module)
1518                } else {
1519                    target_module.clone()
1520                },
1521                strength,
1522                distance,
1523                volatility,
1524                visibility_for_dependency(dep, target_visibility),
1525                analyzed.file_path.clone(),
1526                dep.line,
1527            );
1528
1529            // Add crate-level info
1530            coupling.source_crate = Some(analyzed.crate_name.clone());
1531            coupling.target_crate = resolved_crate;
1532
1533            project.add_coupling(coupling);
1534        }
1535    }
1536
1537    // Add crate-level dependency information
1538    for (crate_name, deps) in &workspace.dependency_graph {
1539        if workspace.is_workspace_member(crate_name) {
1540            for dep in deps {
1541                // Track crate-level dependencies
1542                project
1543                    .crate_dependencies
1544                    .entry(crate_name.clone())
1545                    .or_default()
1546                    .push(dep.clone());
1547            }
1548        }
1549    }
1550
1551    project.dead_config_patterns =
1552        format_dead_config_patterns(config, &candidate_config_paths, &workspace.root);
1553
1554    Ok(project)
1555}
1556
1557/// Dead scoring-affecting config patterns for this run, as "section: pattern" strings.
1558///
1559/// Precision guards (a false "config is rotted" note erodes trust):
1560/// - no loaded config file (`config_root` unknown) → no drift claims;
1561/// - candidates must be the DISCOVERED (post-exclusion, pre-parse) files, so a pattern
1562///   whose only match fails to parse stays attributed to the parse-failure note;
1563/// - patterns whose literal prefix is not fully covered by the analyzed scope are
1564///   skipped: a partial-scope run (one crate of a monorepo, a subdirectory) says
1565///   nothing about patterns aimed at sibling trees.
1566fn format_dead_config_patterns(
1567    config: &CompiledConfig,
1568    candidate_paths: &[String],
1569    analysis_scope: &Path,
1570) -> Vec<String> {
1571    let Some(config_root) = config.config_root() else {
1572        return Vec::new();
1573    };
1574    if !config.has_subdomain_config() && !config.has_volatility_overrides() {
1575        return Vec::new();
1576    }
1577
1578    let normalized_scope = normalize_exclude_path(analysis_scope);
1579    let normalized_root = normalize_exclude_path(config_root);
1580    let scope = match normalized_scope.strip_prefix(&normalized_root) {
1581        Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
1582        // The scope contains the config root (or a lookup quirk): everything the
1583        // config describes is in scope.
1584        Err(_) if normalized_root.starts_with(&normalized_scope) => String::new(),
1585        // Disjoint trees: this run cannot judge the config at all.
1586        Err(_) => return Vec::new(),
1587    };
1588
1589    config
1590        .dead_patterns(candidate_paths)
1591        .into_iter()
1592        .filter(|dead| pattern_within_scope(&dead.pattern, &scope))
1593        .map(|dead| format!("{}: {}", dead.section, dead.pattern))
1594        .collect()
1595}
1596
1597/// Whether everything a glob pattern could match lies inside the analyzed scope
1598/// (both config-root-relative). Judged via the pattern's literal (meta-free) prefix;
1599/// when the scope only partially covers the pattern, this run cannot prove drift.
1600fn pattern_within_scope(pattern: &str, scope: &str) -> bool {
1601    if scope.is_empty() {
1602        return true;
1603    }
1604    let (literal, had_meta) = match pattern.find(['*', '?', '[']) {
1605        Some(idx) => (&pattern[..idx], true),
1606        None => (pattern, false),
1607    };
1608    // A metacharacter can extend the final component ("src/bal*"), so only fully
1609    // literal components count.
1610    let literal = if had_meta {
1611        match literal.rfind('/') {
1612            Some(idx) => &literal[..idx],
1613            None => "",
1614        }
1615    } else {
1616        literal
1617    };
1618    let literal_components: Vec<&str> = literal.split('/').filter(|c| !c.is_empty()).collect();
1619    let scope_components: Vec<&str> = scope.split('/').filter(|c| !c.is_empty()).collect();
1620    literal_components.len() >= scope_components.len()
1621        && literal_components[..scope_components.len()] == scope_components[..]
1622}
1623
1624fn cache_file_and_scan_path_attribute(
1625    file_key: &Path,
1626    source_contents: &mut HashMap<PathBuf, String>,
1627) -> bool {
1628    if let Some(content) = source_contents.get(file_key) {
1629        return content.contains("#[path");
1630    }
1631
1632    let Ok(bytes) = fs::read(file_key) else {
1633        return false;
1634    };
1635    let has_path_attribute = bytes
1636        .windows(b"#[path".len())
1637        .any(|window| window == b"#[path");
1638
1639    if let Ok(content) = String::from_utf8(bytes) {
1640        source_contents.insert(file_key.to_path_buf(), content);
1641    }
1642
1643    has_path_attribute
1644}
1645
1646// ===== Dependency Resolution (extracted to `classification`) =====
1647pub(crate) use crate::classification::{
1648    calculate_distance, calculate_distance_with_workspace, is_valid_dependency_path,
1649    resolve_target_module, strength_for_dependency, target_type_name, visibility_for_dependency,
1650};
1651
1652/// Analyzed file with crate information
1653#[derive(Debug, Clone)]
1654struct AnalyzedFileWithCrate {
1655    module_name: String,
1656    crate_name: String,
1657    #[allow(dead_code)]
1658    file_path: PathBuf,
1659    metrics: ModuleMetrics,
1660    dependencies: Vec<Dependency>,
1661    /// Type visibility information from this file
1662    type_visibility: HashMap<String, Visibility>,
1663    /// Item-level dependencies (function calls, field access, etc.)
1664    item_dependencies: Vec<ItemDependency>,
1665}
1666
1667/// Full result of analyzing a single Rust file.
1668pub struct AnalyzedFileResult {
1669    /// Module metrics collected from definitions and usage patterns.
1670    pub metrics: ModuleMetrics,
1671    /// File-level dependencies detected by the AST visitor.
1672    pub dependencies: Vec<Dependency>,
1673    /// Type name to visibility map collected from this file.
1674    pub type_visibility: HashMap<String, Visibility>,
1675    /// Item-level dependency edges collected within function/type contexts.
1676    pub item_dependencies: Vec<ItemDependency>,
1677}
1678
1679/// Analyze one Rust file and return module metrics plus file-level dependencies.
1680pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1681    let result = analyze_rust_file_full(path)?;
1682    Ok((result.metrics, result.dependencies))
1683}
1684
1685/// Analyze a Rust file and return full results including visibility
1686pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1687    let content = fs::read_to_string(path)?;
1688
1689    let module_name = path
1690        .file_stem()
1691        .and_then(|s| s.to_str())
1692        .unwrap_or("unknown")
1693        .to_string();
1694
1695    let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1696    analyzer.analyze_file(&content)?;
1697
1698    Ok(AnalyzedFileResult {
1699        metrics: analyzer.metrics,
1700        dependencies: analyzer.dependencies,
1701        type_visibility: analyzer.type_visibility,
1702        item_dependencies: analyzer.item_dependencies,
1703    })
1704}
1705
1706#[cfg(test)]
1707mod tests {
1708    use super::*;
1709    use crate::classification::extract_target_module;
1710    use crate::metrics::dimensions::Distance;
1711
1712    fn resolve_target_module_for_test(
1713        path: &str,
1714        source_module: &str,
1715        known_modules: &HashSet<String>,
1716    ) -> String {
1717        resolve_target_module(path, source_module, known_modules, &ProjectMetrics::new())
1718    }
1719
1720    #[test]
1721    fn pattern_within_scope_requires_full_coverage() {
1722        // Empty scope (analysis covers the whole config tree) judges everything.
1723        assert!(pattern_within_scope("other/**", ""));
1724        // Pattern inside the scope: judged.
1725        assert!(pattern_within_scope("src/balance/**", "src"));
1726        assert!(pattern_within_scope("src/broken.rs", "src"));
1727        // Sibling trees: not judged.
1728        assert!(!pattern_within_scope("other/**", "src"));
1729        assert!(!pattern_within_scope("src/broken.rs", "src/web"));
1730        // Pattern broader than the scope: this run cannot prove drift.
1731        assert!(!pattern_within_scope("src/**", "src/web"));
1732        // Metacharacter can extend the final literal component.
1733        assert!(!pattern_within_scope("src/bal*", "src/balance"));
1734        assert!(pattern_within_scope("src/balance/mod*", "src/balance"));
1735        // Leading-glob patterns can match anywhere: never judged under a narrowed scope.
1736        assert!(!pattern_within_scope("**/generated/**", "src"));
1737    }
1738
1739    #[test]
1740    fn test_analyzer_creation() {
1741        let analyzer = CouplingAnalyzer::new(
1742            "test_module".to_string(),
1743            std::path::PathBuf::from("test.rs"),
1744        );
1745        assert_eq!(analyzer.current_module, "test_module");
1746    }
1747
1748    #[test]
1749    fn test_analyze_simple_file() {
1750        let mut analyzer =
1751            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1752
1753        let code = r#"
1754            pub struct User {
1755                name: String,
1756                email: String,
1757            }
1758
1759            impl User {
1760                pub fn new(name: String, email: String) -> Self {
1761                    Self { name, email }
1762                }
1763            }
1764        "#;
1765
1766        let result = analyzer.analyze_file(code);
1767        assert!(result.is_ok());
1768        assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1769    }
1770
1771    #[test]
1772    fn test_item_dependencies() {
1773        let mut analyzer =
1774            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1775
1776        let code = r#"
1777            pub struct Config {
1778                pub value: i32,
1779            }
1780
1781            pub fn process(config: Config) -> i32 {
1782                let x = config.value;
1783                helper(x)
1784            }
1785
1786            fn helper(n: i32) -> i32 {
1787                n * 2
1788            }
1789        "#;
1790
1791        let result = analyzer.analyze_file(code);
1792        assert!(result.is_ok());
1793
1794        // Check that functions are recorded
1795        assert!(analyzer.defined_functions.contains_key("process"));
1796        assert!(analyzer.defined_functions.contains_key("helper"));
1797
1798        // Check item dependencies - process should have deps
1799        println!(
1800            "Item dependencies count: {}",
1801            analyzer.item_dependencies.len()
1802        );
1803        for dep in &analyzer.item_dependencies {
1804            println!(
1805                "  {} -> {} ({:?})",
1806                dep.source_item, dep.target, dep.dep_type
1807            );
1808        }
1809
1810        // process function should have dependencies
1811        let process_deps: Vec<_> = analyzer
1812            .item_dependencies
1813            .iter()
1814            .filter(|d| d.source_item == "process")
1815            .collect();
1816
1817        assert!(
1818            !process_deps.is_empty(),
1819            "process function should have item dependencies"
1820        );
1821    }
1822
1823    #[test]
1824    fn test_analyze_trait_impl() {
1825        let mut analyzer =
1826            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1827
1828        let code = r#"
1829            trait Printable {
1830                fn print(&self);
1831            }
1832
1833            struct Document;
1834
1835            impl Printable for Document {
1836                fn print(&self) {}
1837            }
1838        "#;
1839
1840        let result = analyzer.analyze_file(code);
1841        assert!(result.is_ok());
1842        assert!(analyzer.metrics.trait_impl_count >= 1);
1843    }
1844
1845    #[test]
1846    fn test_analyze_use_statements() {
1847        let mut analyzer =
1848            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1849
1850        let code = r#"
1851            use std::collections::HashMap;
1852            use serde::Serialize;
1853            use crate::utils;
1854            use crate::models::{User, Post};
1855        "#;
1856
1857        let result = analyzer.analyze_file(code);
1858        assert!(result.is_ok());
1859        assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1860        assert!(
1861            analyzer
1862                .metrics
1863                .external_deps
1864                .contains(&"serde".to_string())
1865        );
1866        assert!(!analyzer.dependencies.is_empty());
1867
1868        // Check internal dependencies
1869        let internal_deps: Vec<_> = analyzer
1870            .dependencies
1871            .iter()
1872            .filter(|d| d.kind == DependencyKind::InternalUse)
1873            .collect();
1874        assert!(!internal_deps.is_empty());
1875    }
1876
1877    #[test]
1878    fn test_extract_use_paths() {
1879        let analyzer =
1880            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1881
1882        // Test simple path
1883        let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1884        let paths = analyzer.extract_use_paths(&tree, "");
1885        assert_eq!(paths.len(), 1);
1886        assert_eq!(paths[0].0, "std::collections::HashMap");
1887
1888        // Test grouped path
1889        let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1890        let paths = analyzer.extract_use_paths(&tree, "");
1891        assert_eq!(paths.len(), 2);
1892    }
1893
1894    #[test]
1895    fn test_extract_target_module() {
1896        assert_eq!(extract_target_module("crate::models::user"), "models");
1897        assert_eq!(extract_target_module("super::utils"), "utils");
1898        assert_eq!(extract_target_module("std::collections"), "std");
1899    }
1900
1901    #[test]
1902    fn test_resolve_target_module_prefers_longest_known_module() {
1903        let known = HashSet::from([
1904            "balance".to_string(),
1905            "balance::issues".to_string(),
1906            "balance::score".to_string(),
1907        ]);
1908
1909        assert_eq!(
1910            resolve_target_module_for_test(
1911                "crate::balance::issues::CouplingIssue",
1912                "report",
1913                &known
1914            ),
1915            "balance::issues"
1916        );
1917        assert_eq!(
1918            resolve_target_module_for_test("super::issues::IssueType", "balance::coupling", &known),
1919            "balance::issues"
1920        );
1921        assert_eq!(
1922            resolve_target_module_for_test(
1923                "std::collections::HashMap",
1924                "balance::coupling",
1925                &known
1926            ),
1927            "std"
1928        );
1929    }
1930
1931    #[test]
1932    fn test_reexported_type_name_resolves_to_defining_module() {
1933        let known = HashSet::from(["a".to_string(), "a::b".to_string(), "consumer".to_string()]);
1934        let mut project = ProjectMetrics::new();
1935        project.register_type(
1936            "SomeType".to_string(),
1937            "a::b".to_string(),
1938            Visibility::Public,
1939        );
1940
1941        let target = resolve_target_module("crate::SomeType", "a", &known, &project);
1942
1943        assert_eq!(target, "a::b");
1944        assert_eq!(
1945            calculate_distance("a", &target, known.contains(&target)),
1946            Distance::SameModule
1947        );
1948    }
1949
1950    #[test]
1951    fn test_unknown_reexported_type_name_keeps_external_fallback() {
1952        let known = HashSet::from(["a::b".to_string(), "consumer".to_string()]);
1953        let project = ProjectMetrics::new();
1954
1955        let target = resolve_target_module("crate::UnknownType", "consumer", &known, &project);
1956
1957        assert_eq!(target, "UnknownType");
1958        assert_eq!(
1959            calculate_distance("consumer", &target, known.contains(&target)),
1960            Distance::DifferentCrate
1961        );
1962    }
1963
1964    #[test]
1965    fn test_type_registry_ambiguity_prefers_public_then_lexicographic_module() {
1966        let mut project = ProjectMetrics::new();
1967        project.register_type(
1968            "Shared".to_string(),
1969            "z::private".to_string(),
1970            Visibility::Private,
1971        );
1972        project.register_type(
1973            "Shared".to_string(),
1974            "m::public".to_string(),
1975            Visibility::Public,
1976        );
1977        project.register_type(
1978            "Shared".to_string(),
1979            "a::public".to_string(),
1980            Visibility::Public,
1981        );
1982
1983        assert_eq!(project.get_type_module("Shared"), Some("a::public"));
1984        assert_eq!(
1985            project.get_type_visibility("Shared"),
1986            Some(Visibility::Public)
1987        );
1988    }
1989
1990    #[test]
1991    fn test_strength_mapping_uses_public_model_for_data_access_only() {
1992        let public_field = Dependency {
1993            path: "crate::PublicType".to_string(),
1994            kind: DependencyKind::TypeRef,
1995            line: 0,
1996            usage: UsageContext::FieldAccess,
1997        };
1998        let public_struct = Dependency {
1999            path: "crate::PublicType".to_string(),
2000            kind: DependencyKind::TypeRef,
2001            line: 0,
2002            usage: UsageContext::StructConstruction,
2003        };
2004        let crate_field = Dependency {
2005            path: "crate::CrateType".to_string(),
2006            kind: DependencyKind::TypeRef,
2007            line: 0,
2008            usage: UsageContext::FieldAccess,
2009        };
2010        let unknown_struct = Dependency {
2011            path: "crate::UnknownType".to_string(),
2012            kind: DependencyKind::TypeRef,
2013            line: 0,
2014            usage: UsageContext::StructConstruction,
2015        };
2016        let inherent_impl = Dependency {
2017            path: "crate::PublicType".to_string(),
2018            kind: DependencyKind::InherentImpl,
2019            line: 0,
2020            usage: UsageContext::InherentImplBlock,
2021        };
2022
2023        assert_eq!(
2024            strength_for_dependency(&public_field, Some(Visibility::Public)),
2025            IntegrationStrength::Model
2026        );
2027        assert_eq!(
2028            strength_for_dependency(&public_struct, Some(Visibility::Public)),
2029            IntegrationStrength::Model
2030        );
2031        assert_eq!(
2032            strength_for_dependency(&crate_field, Some(Visibility::PubCrate)),
2033            IntegrationStrength::Intrusive
2034        );
2035        assert_eq!(
2036            strength_for_dependency(&unknown_struct, None),
2037            IntegrationStrength::Intrusive
2038        );
2039        assert_eq!(
2040            strength_for_dependency(&inherent_impl, Some(Visibility::Public)),
2041            IntegrationStrength::Intrusive
2042        );
2043    }
2044
2045    #[test]
2046    fn test_structural_distance_same_file_is_same_module() {
2047        assert_eq!(
2048            calculate_distance("balance::grade", "balance::grade", true),
2049            Distance::SameModule
2050        );
2051    }
2052
2053    #[test]
2054    fn test_structural_distance_siblings_are_same_module_when_parent_is_not_root() {
2055        let known = HashSet::from([
2056            "balance::grade".to_string(),
2057            "balance::rationale".to_string(),
2058        ]);
2059
2060        let super_target = resolve_target_module_for_test(
2061            "super::rationale::GradeRationale",
2062            "balance::grade",
2063            &known,
2064        );
2065        let crate_target = resolve_target_module(
2066            "crate::balance::rationale::GradeRationale",
2067            "balance::grade",
2068            &known,
2069            &ProjectMetrics::new(),
2070        );
2071
2072        assert_eq!(super_target, "balance::rationale");
2073        assert_eq!(crate_target, "balance::rationale");
2074        assert_eq!(
2075            calculate_distance(
2076                "balance::grade",
2077                &super_target,
2078                known.contains(&super_target)
2079            ),
2080            Distance::SameModule
2081        );
2082        assert_eq!(
2083            calculate_distance(
2084                "balance::grade",
2085                &crate_target,
2086                known.contains(&crate_target)
2087            ),
2088            Distance::SameModule
2089        );
2090    }
2091
2092    #[test]
2093    fn test_structural_distance_parent_child_is_same_module() {
2094        assert_eq!(
2095            calculate_distance("balance", "balance::grade", true),
2096            Distance::SameModule
2097        );
2098        assert_eq!(
2099            calculate_distance("balance::grade", "balance", true),
2100            Distance::SameModule
2101        );
2102        assert_eq!(
2103            calculate_distance("web", "web::server::internal", true),
2104            Distance::SameModule
2105        );
2106        assert_eq!(
2107            calculate_distance("web::server::internal", "web", true),
2108            Distance::SameModule
2109        );
2110    }
2111
2112    #[test]
2113    fn test_structural_distance_cousins_are_different_module() {
2114        assert_eq!(
2115            calculate_distance("web::a", "web::b::c", true),
2116            Distance::DifferentModule
2117        );
2118    }
2119
2120    #[test]
2121    fn test_structural_distance_root_level_siblings_are_different_module() {
2122        assert_eq!(
2123            calculate_distance("diff", "analyzer", true),
2124            Distance::DifferentModule
2125        );
2126        assert_eq!(
2127            calculate_distance("diff", "balance::issue", true),
2128            Distance::DifferentModule
2129        );
2130    }
2131
2132    #[test]
2133    fn test_structural_distance_crate_syntax_does_not_make_far_module_close() {
2134        let known = HashSet::from(["far::away".to_string()]);
2135        let target = resolve_target_module_for_test("crate::far::away::X", "diff", &known);
2136
2137        assert_eq!(target, "far::away");
2138        assert_eq!(
2139            calculate_distance("diff", &target, known.contains(&target)),
2140            Distance::DifferentModule
2141        );
2142    }
2143
2144    #[test]
2145    fn test_structural_distance_workspace_member_and_external_crate() {
2146        let workspace = WorkspaceInfo {
2147            root: PathBuf::new(),
2148            crates: HashMap::new(),
2149            members: vec!["app".to_string(), "domain".to_string()],
2150            dependency_graph: HashMap::new(),
2151            reverse_deps: HashMap::new(),
2152        };
2153
2154        assert_eq!(
2155            calculate_distance_with_workspace(
2156                "app_module",
2157                "serde",
2158                false,
2159                "app",
2160                Some("serde"),
2161                &workspace,
2162            ),
2163            Distance::DifferentCrate
2164        );
2165        assert_eq!(
2166            calculate_distance_with_workspace(
2167                "app_module",
2168                "domain_module",
2169                false,
2170                "app",
2171                Some("domain"),
2172                &workspace,
2173            ),
2174            Distance::DifferentModule
2175        );
2176    }
2177
2178    #[test]
2179    fn test_field_access_detection() {
2180        let mut analyzer =
2181            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2182
2183        let code = r#"
2184            use crate::models::User;
2185
2186            fn get_name(user: &User) -> String {
2187                user.name.clone()
2188            }
2189        "#;
2190
2191        let result = analyzer.analyze_file(code);
2192        assert!(result.is_ok());
2193
2194        // Should detect User as a dependency with field access
2195        let _field_deps: Vec<_> = analyzer
2196            .dependencies
2197            .iter()
2198            .filter(|d| d.usage == UsageContext::FieldAccess)
2199            .collect();
2200        // Note: This may not detect field access on function parameters
2201        // as the type info isn't fully available without type inference
2202    }
2203
2204    #[test]
2205    fn test_method_call_detection() {
2206        let mut analyzer =
2207            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2208
2209        let code = r#"
2210            fn process() {
2211                let data = String::new();
2212                data.push_str("hello");
2213            }
2214        "#;
2215
2216        let result = analyzer.analyze_file(code);
2217        assert!(result.is_ok());
2218        // Method calls on local variables are detected
2219    }
2220
2221    #[test]
2222    fn test_struct_construction_detection() {
2223        let mut analyzer =
2224            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
2225
2226        let code = r#"
2227            use crate::config::Config;
2228
2229            fn create_config() {
2230                let c = Config { value: 42 };
2231            }
2232        "#;
2233
2234        let result = analyzer.analyze_file(code);
2235        assert!(result.is_ok());
2236
2237        // Should detect Config struct construction
2238        let struct_deps: Vec<_> = analyzer
2239            .dependencies
2240            .iter()
2241            .filter(|d| d.usage == UsageContext::StructConstruction)
2242            .collect();
2243        assert!(!struct_deps.is_empty());
2244    }
2245
2246    #[test]
2247    fn test_usage_context_to_strength() {
2248        assert_eq!(
2249            UsageContext::FieldAccess.to_strength(),
2250            IntegrationStrength::Intrusive
2251        );
2252        assert_eq!(
2253            UsageContext::MethodCall.to_strength(),
2254            IntegrationStrength::Functional
2255        );
2256        assert_eq!(
2257            UsageContext::TypeParameter.to_strength(),
2258            IntegrationStrength::Model
2259        );
2260        assert_eq!(
2261            UsageContext::TraitBound.to_strength(),
2262            IntegrationStrength::Contract
2263        );
2264    }
2265
2266    #[test]
2267    fn test_has_test_attribute_with_test() {
2268        let code = r#"
2269            #[test]
2270            fn my_test() {}
2271        "#;
2272        let syntax: syn::File = syn::parse_str(code).unwrap();
2273        if let syn::Item::Fn(func) = &syntax.items[0] {
2274            assert!(has_test_attribute(&func.attrs));
2275        } else {
2276            panic!("Expected function");
2277        }
2278    }
2279
2280    #[test]
2281    fn test_has_test_attribute_without_test() {
2282        let code = r#"
2283            fn regular_fn() {}
2284        "#;
2285        let syntax: syn::File = syn::parse_str(code).unwrap();
2286        if let syn::Item::Fn(func) = &syntax.items[0] {
2287            assert!(!has_test_attribute(&func.attrs));
2288        } else {
2289            panic!("Expected function");
2290        }
2291    }
2292
2293    #[test]
2294    fn test_has_cfg_test_attribute_with_cfg_test() {
2295        let code = r#"
2296            #[cfg(test)]
2297            mod tests {}
2298        "#;
2299        let syntax: syn::File = syn::parse_str(code).unwrap();
2300        if let syn::Item::Mod(module) = &syntax.items[0] {
2301            assert!(has_cfg_test_attribute(&module.attrs));
2302        } else {
2303            panic!("Expected module");
2304        }
2305    }
2306
2307    #[test]
2308    fn test_has_cfg_test_attribute_without_cfg_test() {
2309        let code = r#"
2310            mod regular_mod {}
2311        "#;
2312        let syntax: syn::File = syn::parse_str(code).unwrap();
2313        if let syn::Item::Mod(module) = &syntax.items[0] {
2314            assert!(!has_cfg_test_attribute(&module.attrs));
2315        } else {
2316            panic!("Expected module");
2317        }
2318    }
2319
2320    #[test]
2321    fn test_has_cfg_test_attribute_with_other_cfg() {
2322        let code = r#"
2323            #[cfg(feature = "foo")]
2324            mod feature_mod {}
2325        "#;
2326        let syntax: syn::File = syn::parse_str(code).unwrap();
2327        if let syn::Item::Mod(module) = &syntax.items[0] {
2328            assert!(!has_cfg_test_attribute(&module.attrs));
2329        } else {
2330            panic!("Expected module");
2331        }
2332    }
2333
2334    #[test]
2335    fn test_is_test_module_named_tests() {
2336        let code = r#"
2337            mod tests {}
2338        "#;
2339        let syntax: syn::File = syn::parse_str(code).unwrap();
2340        if let syn::Item::Mod(module) = &syntax.items[0] {
2341            assert!(is_test_module(module));
2342        } else {
2343            panic!("Expected module");
2344        }
2345    }
2346
2347    #[test]
2348    fn test_is_test_module_with_cfg_test() {
2349        let code = r#"
2350            #[cfg(test)]
2351            mod my_tests {}
2352        "#;
2353        let syntax: syn::File = syn::parse_str(code).unwrap();
2354        if let syn::Item::Mod(module) = &syntax.items[0] {
2355            assert!(is_test_module(module));
2356        } else {
2357            panic!("Expected module");
2358        }
2359    }
2360
2361    #[test]
2362    fn test_is_test_module_regular_module() {
2363        let code = r#"
2364            mod utils {}
2365        "#;
2366        let syntax: syn::File = syn::parse_str(code).unwrap();
2367        if let syn::Item::Mod(module) = &syntax.items[0] {
2368            assert!(!is_test_module(module));
2369        } else {
2370            panic!("Expected module");
2371        }
2372    }
2373
2374    /// Regression test for Issue #39: `[analysis].exclude` patterns must be applied during analysis.
2375    ///
2376    /// We assert on module names (not just `total_files`) so the test distinguishes
2377    /// "excluded by config" from "silently dropped due to parse failure".
2378    /// Both `src/generated/*` and `src/generated/**` are kept to mirror the reporter's repro.
2379    #[test]
2380    fn test_analyze_project_parallel_applies_exclude_patterns() {
2381        use crate::config::{CompiledConfig, CouplingConfig};
2382
2383        let tmp = tempfile::tempdir().expect("create tempdir");
2384        let root = tmp.path();
2385        let src = root.join("src");
2386        let generated = src.join("generated");
2387        std::fs::create_dir_all(&generated).expect("create generated dir");
2388        std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2389            .expect("write lib.rs");
2390        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2391            .expect("write generated/mod.rs");
2392
2393        // Baseline: with empty config both files are analyzed, including the generated module.
2394        let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2395            .expect("baseline analysis");
2396        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2397        assert!(
2398            baseline.modules.keys().any(|k| k.contains("generated")),
2399            "baseline must include the generated module; saw {:?}",
2400            baseline.modules.keys().collect::<Vec<_>>()
2401        );
2402
2403        // With exclude patterns the generated file is filtered out by config.
2404        let toml = r#"
2405            [analysis]
2406            exclude = ["src/generated/*", "src/generated/**"]
2407        "#;
2408        let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2409        let compiled = CompiledConfig::from_config(config).expect("compile config");
2410        let filtered =
2411            analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2412        assert_eq!(
2413            filtered.total_files, 1,
2414            "generated file should be excluded from analysis"
2415        );
2416        assert!(
2417            !filtered.modules.keys().any(|k| k.contains("generated")),
2418            "no generated module should remain; saw {:?}",
2419            filtered.modules.keys().collect::<Vec<_>>()
2420        );
2421    }
2422
2423    /// Regression test for Issue #39 on the CLI/workspace path:
2424    /// a relative `./src`-style path must still apply `[analysis].exclude`.
2425    #[test]
2426    fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2427        use crate::config::{CompiledConfig, load_compiled_config};
2428
2429        let current_dir = std::env::current_dir().expect("get current dir");
2430        let target_dir = current_dir.join("target");
2431        let tmp = tempfile::Builder::new()
2432            .prefix("issue39-workspace-")
2433            .tempdir_in(&target_dir)
2434            .expect("create tempdir in target");
2435        let root = tmp.path();
2436        let src = root.join("src");
2437        let generated = src.join("generated");
2438        std::fs::create_dir_all(&generated).expect("create generated dir");
2439        std::fs::write(
2440            root.join("Cargo.toml"),
2441            r#"[package]
2442name = "coupling-fixture-exclude"
2443version = "0.1.0"
2444edition = "2024"
2445"#,
2446        )
2447        .expect("write Cargo.toml");
2448        std::fs::write(
2449            root.join(".coupling.toml"),
2450            "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2451        )
2452        .expect("write .coupling.toml");
2453        std::fs::write(
2454            src.join("lib.rs"),
2455            "pub mod generated;\npub fn call() { generated::helper(); }\n",
2456        )
2457        .expect("write lib.rs");
2458        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2459            .expect("write generated/mod.rs");
2460
2461        let relative_src = src
2462            .strip_prefix(&current_dir)
2463            .expect("temp crate should be under current dir");
2464
2465        let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2466            .expect("baseline workspace analysis");
2467        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2468        assert!(
2469            baseline.modules.keys().any(|k| k.contains("generated")),
2470            "baseline must include the generated module; saw {:?}",
2471            baseline.modules.keys().collect::<Vec<_>>()
2472        );
2473
2474        let compiled = load_compiled_config(relative_src).expect("load compiled config");
2475        let filtered = analyze_workspace_with_config(relative_src, &compiled)
2476            .expect("filtered workspace analysis");
2477        assert_eq!(
2478            filtered.total_files, 1,
2479            "generated file should be excluded from workspace analysis"
2480        );
2481        assert!(
2482            !filtered.modules.keys().any(|k| k.contains("generated")),
2483            "no generated module should remain; saw {:?}",
2484            filtered.modules.keys().collect::<Vec<_>>()
2485        );
2486    }
2487
2488    /// Regression test for the non-workspace fallback path:
2489    /// when analyzing `./src`, exclude patterns must still be rooted at the config file.
2490    #[test]
2491    fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2492        use crate::config::{CompiledConfig, load_compiled_config};
2493
2494        let tmp = tempfile::tempdir().expect("create tempdir");
2495        let root = tmp.path();
2496        let src = root.join("src");
2497        let generated = src.join("generated");
2498        std::fs::create_dir_all(&generated).expect("create generated dir");
2499        std::fs::write(
2500            root.join(".coupling.toml"),
2501            "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2502        )
2503        .expect("write .coupling.toml");
2504        std::fs::write(
2505            src.join("lib.rs"),
2506            "pub mod generated;\npub fn call() { generated::helper(); }\n",
2507        )
2508        .expect("write lib.rs");
2509        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2510            .expect("write generated/mod.rs");
2511
2512        let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
2513            .expect("baseline analysis");
2514        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2515        assert!(
2516            baseline.modules.keys().any(|k| k.contains("generated")),
2517            "baseline must include the generated module; saw {:?}",
2518            baseline.modules.keys().collect::<Vec<_>>()
2519        );
2520
2521        let compiled = load_compiled_config(&src).expect("load compiled config");
2522        let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2523        assert_eq!(
2524            filtered.total_files, 1,
2525            "generated file should be excluded from fallback analysis"
2526        );
2527        assert!(
2528            !filtered.modules.keys().any(|k| k.contains("generated")),
2529            "no generated module should remain; saw {:?}",
2530            filtered.modules.keys().collect::<Vec<_>>()
2531        );
2532    }
2533}