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