Skip to main content

cargo_coupling/
analyzer.rs

1//! AST analysis for coupling detection
2//!
3//! Uses `syn` to parse Rust source code and detect coupling patterns.
4//! Optionally uses `cargo metadata` for accurate workspace analysis.
5//! Supports parallel processing via Rayon for large projects.
6
7use std::collections::{HashMap, HashSet};
8use std::ffi::OsStr;
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use rayon::prelude::*;
13use syn::visit::Visit;
14use syn::{
15    Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
16    ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
17};
18use thiserror::Error;
19use walkdir::WalkDir;
20
21use crate::config::CompiledConfig;
22use crate::metrics::{
23    CouplingMetrics, Distance, IntegrationStrength, ModuleMetrics, ProjectMetrics, Visibility,
24    Volatility,
25};
26use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
27
28/// Convert syn's Visibility to our Visibility enum
29fn convert_visibility(vis: &syn::Visibility) -> Visibility {
30    match vis {
31        syn::Visibility::Public(_) => Visibility::Public,
32        syn::Visibility::Restricted(restricted) => {
33            // Check the path to determine the restriction type
34            let path_str = restricted
35                .path
36                .segments
37                .iter()
38                .map(|s| s.ident.to_string())
39                .collect::<Vec<_>>()
40                .join("::");
41
42            match path_str.as_str() {
43                "crate" => Visibility::PubCrate,
44                "super" => Visibility::PubSuper,
45                "self" => Visibility::Private, // pub(self) is effectively private
46                _ => Visibility::PubIn,        // pub(in path)
47            }
48        }
49        syn::Visibility::Inherited => Visibility::Private,
50    }
51}
52
53/// Check if an item has the #[test] attribute
54fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
55    attrs.iter().any(|attr| attr.path().is_ident("test"))
56}
57
58/// Check if an item has #[cfg(test)] attribute
59fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
60    attrs.iter().any(|attr| {
61        if attr.path().is_ident("cfg") {
62            // Try to parse the attribute content
63            if let Ok(meta) = attr.meta.require_list() {
64                let tokens = meta.tokens.to_string();
65                return tokens.contains("test");
66            }
67        }
68        false
69    })
70}
71
72/// Check if a module is a test module (named "tests" or has #[cfg(test)])
73fn is_test_module(item: &ItemMod) -> bool {
74    item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
75}
76
77/// Convert file path to module path relative to the source root.
78///
79/// Examples:
80/// - `src/level/enemy/spawner.rs` with root `src` → `level::enemy::spawner`
81/// - `src/lib.rs` with root `src` → `` (empty, crate root)
82/// - `src/main.rs` with root `src` → `` (empty, crate root)
83/// - `src/level/mod.rs` with root `src` → `level`
84/// - `src/utils.rs` with root `src` → `utils`
85///
86/// See: https://github.com/nwiizo/cargo-coupling/issues/14
87fn file_path_to_module_path(file_path: &Path, src_root: &Path) -> String {
88    // Get the relative path from src root
89    let relative = file_path.strip_prefix(src_root).unwrap_or(file_path);
90
91    let mut parts: Vec<String> = Vec::new();
92
93    for component in relative.components() {
94        if let Some(s) = component.as_os_str().to_str() {
95            parts.push(s.to_string());
96        }
97    }
98
99    // Handle the last component (filename)
100    if let Some(last) = parts.last().cloned() {
101        parts.pop();
102        match last.as_str() {
103            "lib.rs" | "main.rs" => {
104                // Crate root - don't add anything
105            }
106            "mod.rs" => {
107                // mod.rs represents its parent directory, already in parts
108            }
109            _ => {
110                // Regular file - remove .rs extension and add to path
111                if let Some(stem) = last.strip_suffix(".rs") {
112                    parts.push(stem.to_string());
113                } else {
114                    parts.push(last);
115                }
116            }
117        }
118    }
119
120    parts.join("::")
121}
122
123/// Errors that can occur during analysis
124#[derive(Error, Debug)]
125pub enum AnalyzerError {
126    #[error("Failed to read file: {0}")]
127    IoError(#[from] std::io::Error),
128
129    #[error("Failed to parse Rust file: {0}")]
130    ParseError(String),
131
132    #[error("Invalid path: {0}")]
133    InvalidPath(String),
134
135    #[error("Workspace error: {0}")]
136    WorkspaceError(#[from] WorkspaceError),
137}
138
139/// Represents a detected dependency
140#[derive(Debug, Clone)]
141pub struct Dependency {
142    /// Full path of the dependency (e.g., "crate::models::user")
143    pub path: String,
144    /// Type of dependency
145    pub kind: DependencyKind,
146    /// Line number where the dependency is declared
147    pub line: usize,
148    /// Usage context for more accurate strength determination
149    pub usage: UsageContext,
150}
151
152/// Kind of dependency
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum DependencyKind {
155    /// use crate::xxx or use super::xxx
156    InternalUse,
157    /// use external_crate::xxx
158    ExternalUse,
159    /// impl Trait for Type
160    TraitImpl,
161    /// impl Type
162    InherentImpl,
163    /// Type reference in struct fields, function params, etc.
164    TypeRef,
165}
166
167/// Context of how a dependency is used - determines Integration Strength
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
169pub enum UsageContext {
170    /// Just imported, usage unknown
171    Import,
172    /// Used as a trait bound or trait impl
173    TraitBound,
174    /// Field access: `foo.bar`
175    FieldAccess,
176    /// Method call: `foo.method()`
177    MethodCall,
178    /// Function call: `Foo::new()` or `foo()`
179    FunctionCall,
180    /// Struct construction: `Foo { field: value }`
181    StructConstruction,
182    /// Type parameter: `Vec<Foo>`
183    TypeParameter,
184    /// Function parameter type
185    FunctionParameter,
186    /// Return type
187    ReturnType,
188    /// Inherent impl block
189    InherentImplBlock,
190}
191
192impl UsageContext {
193    /// Convert usage context to integration strength
194    pub fn to_strength(&self) -> IntegrationStrength {
195        match self {
196            // Intrusive: Direct access to internals
197            UsageContext::FieldAccess => IntegrationStrength::Intrusive,
198            UsageContext::StructConstruction => IntegrationStrength::Intrusive,
199            UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
200
201            // Functional: Depends on function signatures
202            UsageContext::MethodCall => IntegrationStrength::Functional,
203            UsageContext::FunctionCall => IntegrationStrength::Functional,
204            UsageContext::FunctionParameter => IntegrationStrength::Functional,
205            UsageContext::ReturnType => IntegrationStrength::Functional,
206
207            // Model: Uses data types
208            UsageContext::TypeParameter => IntegrationStrength::Model,
209            UsageContext::Import => IntegrationStrength::Model,
210
211            // Contract: Uses traits/interfaces
212            UsageContext::TraitBound => IntegrationStrength::Contract,
213        }
214    }
215}
216
217impl DependencyKind {
218    pub fn to_strength(&self) -> IntegrationStrength {
219        match self {
220            DependencyKind::TraitImpl => IntegrationStrength::Contract,
221            DependencyKind::InternalUse => IntegrationStrength::Model,
222            DependencyKind::ExternalUse => IntegrationStrength::Model,
223            DependencyKind::TypeRef => IntegrationStrength::Model,
224            DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
225        }
226    }
227}
228
229/// AST visitor for coupling analysis
230#[derive(Debug)]
231pub struct CouplingAnalyzer {
232    /// Current module being analyzed
233    pub current_module: String,
234    /// File path
235    pub file_path: std::path::PathBuf,
236    /// Collected metrics
237    pub metrics: ModuleMetrics,
238    /// Detected dependencies
239    pub dependencies: Vec<Dependency>,
240    /// Defined types in this module
241    pub defined_types: HashSet<String>,
242    /// Defined traits in this module
243    pub defined_traits: HashSet<String>,
244    /// Defined functions in this module (name -> visibility)
245    pub defined_functions: HashMap<String, Visibility>,
246    /// Imported types (name -> full path)
247    imported_types: HashMap<String, String>,
248    /// Track unique dependencies to avoid duplicates
249    seen_dependencies: HashSet<(String, UsageContext)>,
250    /// Counts of each usage type for statistics
251    pub usage_counts: UsageCounts,
252    /// Type visibility map: type name -> visibility
253    pub type_visibility: HashMap<String, Visibility>,
254    /// Current item being analyzed (function name, struct name, etc.)
255    current_item: Option<(String, ItemKind)>,
256    /// Item-level dependencies (detailed tracking)
257    pub item_dependencies: Vec<ItemDependency>,
258}
259
260/// Statistics about usage patterns
261#[derive(Debug, Default, Clone)]
262pub struct UsageCounts {
263    pub field_accesses: usize,
264    pub method_calls: usize,
265    pub function_calls: usize,
266    pub struct_constructions: usize,
267    pub trait_bounds: usize,
268    pub type_parameters: usize,
269}
270
271/// Detailed dependency at the item level (function, struct, etc.)
272#[derive(Debug, Clone)]
273pub struct ItemDependency {
274    /// Source item (e.g., "fn analyze_project")
275    pub source_item: String,
276    /// Source item kind
277    pub source_kind: ItemKind,
278    /// Target (e.g., "ProjectMetrics" or "analyze_file")
279    pub target: String,
280    /// Target module (if known)
281    pub target_module: Option<String>,
282    /// Type of dependency
283    pub dep_type: ItemDepType,
284    /// Line number in source
285    pub line: usize,
286    /// The actual expression/code (e.g., "config.thresholds" or "self.couplings")
287    pub expression: Option<String>,
288}
289
290/// Kind of source item
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum ItemKind {
293    Function,
294    Method,
295    Struct,
296    Enum,
297    Trait,
298    Impl,
299    Module,
300}
301
302/// Type of item-level dependency
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum ItemDepType {
305    /// Calls a function: foo()
306    FunctionCall,
307    /// Calls a method: x.foo()
308    MethodCall,
309    /// Uses a type: Vec<Foo>
310    TypeUsage,
311    /// Accesses a field: x.field
312    FieldAccess,
313    /// Constructs a struct: Foo { ... }
314    StructConstruction,
315    /// Implements a trait: impl Trait for Type
316    TraitImpl,
317    /// Uses a trait bound: T: Trait
318    TraitBound,
319    /// Imports: use foo::Bar
320    Import,
321}
322
323impl CouplingAnalyzer {
324    /// Create a new analyzer for a module
325    pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
326        Self {
327            current_module: module_name.clone(),
328            file_path: path.clone(),
329            metrics: ModuleMetrics::new(path, module_name),
330            dependencies: Vec::new(),
331            defined_types: HashSet::new(),
332            defined_traits: HashSet::new(),
333            defined_functions: HashMap::new(),
334            imported_types: HashMap::new(),
335            seen_dependencies: HashSet::new(),
336            usage_counts: UsageCounts::default(),
337            type_visibility: HashMap::new(),
338            current_item: None,
339            item_dependencies: Vec::new(),
340        }
341    }
342
343    /// Analyze a Rust source file
344    pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
345        let syntax: File =
346            syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
347
348        self.visit_file(&syntax);
349
350        Ok(())
351    }
352
353    /// Add a dependency with deduplication
354    fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
355        let key = (path.clone(), usage);
356        if self.seen_dependencies.contains(&key) {
357            return;
358        }
359        self.seen_dependencies.insert(key);
360
361        self.dependencies.push(Dependency {
362            path,
363            kind,
364            line: 0,
365            usage,
366        });
367    }
368
369    /// Record an item-level dependency with detailed tracking
370    fn add_item_dependency(
371        &mut self,
372        target: String,
373        dep_type: ItemDepType,
374        line: usize,
375        expression: Option<String>,
376    ) {
377        if let Some((ref source_item, source_kind)) = self.current_item {
378            // Determine target module
379            let target_module = self.imported_types.get(&target).cloned().or_else(|| {
380                if self.defined_types.contains(&target)
381                    || self.defined_functions.contains_key(&target)
382                {
383                    Some(self.current_module.clone())
384                } else {
385                    None
386                }
387            });
388
389            self.item_dependencies.push(ItemDependency {
390                source_item: source_item.clone(),
391                source_kind,
392                target,
393                target_module,
394                dep_type,
395                line,
396                expression,
397            });
398        }
399    }
400
401    /// Extract full path from UseTree recursively
402    fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
403        let mut paths = Vec::new();
404
405        match tree {
406            UseTree::Path(path) => {
407                let new_prefix = if prefix.is_empty() {
408                    path.ident.to_string()
409                } else {
410                    format!("{}::{}", prefix, path.ident)
411                };
412                paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
413            }
414            UseTree::Name(name) => {
415                let full_path = if prefix.is_empty() {
416                    name.ident.to_string()
417                } else {
418                    format!("{}::{}", prefix, name.ident)
419                };
420                let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
421                    DependencyKind::InternalUse
422                } else {
423                    DependencyKind::ExternalUse
424                };
425                paths.push((full_path, kind));
426            }
427            UseTree::Rename(rename) => {
428                let full_path = if prefix.is_empty() {
429                    rename.ident.to_string()
430                } else {
431                    format!("{}::{}", prefix, rename.ident)
432                };
433                let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
434                    DependencyKind::InternalUse
435                } else {
436                    DependencyKind::ExternalUse
437                };
438                paths.push((full_path, kind));
439            }
440            UseTree::Glob(_) => {
441                let full_path = format!("{}::*", prefix);
442                let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
443                    DependencyKind::InternalUse
444                } else {
445                    DependencyKind::ExternalUse
446                };
447                paths.push((full_path, kind));
448            }
449            UseTree::Group(group) => {
450                for item in &group.items {
451                    paths.extend(self.extract_use_paths(item, prefix));
452                }
453            }
454        }
455
456        paths
457    }
458
459    /// Extract type name from a Type
460    fn extract_type_name(&self, ty: &Type) -> Option<String> {
461        match ty {
462            Type::Path(type_path) => {
463                let segments: Vec<_> = type_path
464                    .path
465                    .segments
466                    .iter()
467                    .map(|s| s.ident.to_string())
468                    .collect();
469                Some(segments.join("::"))
470            }
471            Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
472            Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
473            Type::Array(array_type) => self.extract_type_name(&array_type.elem),
474            Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
475            Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
476            Type::Group(group_type) => self.extract_type_name(&group_type.elem),
477            _ => None,
478        }
479    }
480
481    /// Analyze function signature for dependencies
482    fn analyze_signature(&mut self, sig: &Signature) {
483        // Analyze parameters
484        for arg in &sig.inputs {
485            if let FnArg::Typed(pat_type) = arg
486                && let Some(type_name) = self.extract_type_name(&pat_type.ty)
487                && !self.is_primitive_type(&type_name)
488            {
489                self.add_dependency(
490                    type_name,
491                    DependencyKind::TypeRef,
492                    UsageContext::FunctionParameter,
493                );
494            }
495        }
496
497        // Analyze return type
498        if let ReturnType::Type(_, ty) = &sig.output
499            && let Some(type_name) = self.extract_type_name(ty)
500            && !self.is_primitive_type(&type_name)
501        {
502            self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
503        }
504    }
505
506    /// Check if a type should be ignored (primitives, self, or short variable names)
507    fn is_primitive_type(&self, type_name: &str) -> bool {
508        // Primitive types
509        if matches!(
510            type_name,
511            "bool"
512                | "char"
513                | "str"
514                | "u8"
515                | "u16"
516                | "u32"
517                | "u64"
518                | "u128"
519                | "usize"
520                | "i8"
521                | "i16"
522                | "i32"
523                | "i64"
524                | "i128"
525                | "isize"
526                | "f32"
527                | "f64"
528                | "String"
529                | "Self"
530                | "()"
531                | "Option"
532                | "Result"
533                | "Vec"
534                | "Box"
535                | "Rc"
536                | "Arc"
537                | "RefCell"
538                | "Cell"
539                | "Mutex"
540                | "RwLock"
541        ) {
542            return true;
543        }
544
545        // Short variable names (likely local variables, not types)
546        // Type names in Rust are typically PascalCase and longer
547        if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
548            return true;
549        }
550
551        // Self-references or obviously local
552        if type_name.starts_with("self") || type_name == "self" {
553            return true;
554        }
555
556        false
557    }
558}
559
560impl<'ast> Visit<'ast> for CouplingAnalyzer {
561    fn visit_item_use(&mut self, node: &'ast ItemUse) {
562        let paths = self.extract_use_paths(&node.tree, "");
563
564        for (path, kind) in paths {
565            // Skip self references
566            if path == "self" || path.starts_with("self::") {
567                continue;
568            }
569
570            // Track imported types for later resolution
571            if let Some(type_name) = path.split("::").last() {
572                self.imported_types
573                    .insert(type_name.to_string(), path.clone());
574            }
575
576            self.add_dependency(path.clone(), kind, UsageContext::Import);
577
578            // Update metrics
579            if kind == DependencyKind::InternalUse {
580                if !self.metrics.internal_deps.contains(&path) {
581                    self.metrics.internal_deps.push(path.clone());
582                }
583            } else if kind == DependencyKind::ExternalUse {
584                // Extract crate name
585                let crate_name = path.split("::").next().unwrap_or(&path).to_string();
586                if !self.metrics.external_deps.contains(&crate_name) {
587                    self.metrics.external_deps.push(crate_name);
588                }
589            }
590        }
591
592        syn::visit::visit_item_use(self, node);
593    }
594
595    fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
596        if let Some((_, trait_path, _)) = &node.trait_ {
597            // Trait implementation = Contract coupling
598            self.metrics.trait_impl_count += 1;
599
600            // Extract trait path
601            let trait_name: String = trait_path
602                .segments
603                .iter()
604                .map(|s| s.ident.to_string())
605                .collect::<Vec<_>>()
606                .join("::");
607
608            self.add_dependency(
609                trait_name,
610                DependencyKind::TraitImpl,
611                UsageContext::TraitBound,
612            );
613            self.usage_counts.trait_bounds += 1;
614        } else {
615            // Inherent implementation = Intrusive coupling
616            self.metrics.inherent_impl_count += 1;
617
618            // Get the type being implemented
619            if let Some(type_name) = self.extract_type_name(&node.self_ty)
620                && !self.defined_types.contains(&type_name)
621            {
622                self.add_dependency(
623                    type_name,
624                    DependencyKind::InherentImpl,
625                    UsageContext::InherentImplBlock,
626                );
627            }
628        }
629        syn::visit::visit_item_impl(self, node);
630    }
631
632    fn visit_item_fn(&mut self, node: &'ast ItemFn) {
633        // Record function definition
634        let fn_name = node.sig.ident.to_string();
635        let visibility = convert_visibility(&node.vis);
636        self.defined_functions.insert(fn_name.clone(), visibility);
637
638        // Check if this is a test function
639        if has_test_attribute(&node.attrs) {
640            self.metrics.test_function_count += 1;
641        }
642
643        // Analyze parameters for primitive obsession detection
644        let mut param_count = 0;
645        let mut primitive_param_count = 0;
646        let mut param_types = Vec::new();
647
648        for arg in &node.sig.inputs {
649            if let FnArg::Typed(pat_type) = arg {
650                param_count += 1;
651                if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
652                    param_types.push(type_name.clone());
653                    if self.is_primitive_type(&type_name) {
654                        primitive_param_count += 1;
655                    }
656                }
657            }
658        }
659
660        // Register in module metrics with full details
661        self.metrics.add_function_definition_full(
662            fn_name.clone(),
663            visibility,
664            param_count,
665            primitive_param_count,
666            param_types,
667        );
668
669        // Set current item context for dependency tracking
670        let previous_item = self.current_item.take();
671        self.current_item = Some((fn_name, ItemKind::Function));
672
673        // Analyze function signature
674        self.analyze_signature(&node.sig);
675        syn::visit::visit_item_fn(self, node);
676
677        // Restore previous context
678        self.current_item = previous_item;
679    }
680
681    fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
682        let name = node.ident.to_string();
683        let visibility = convert_visibility(&node.vis);
684
685        self.defined_types.insert(name.clone());
686        self.type_visibility.insert(name.clone(), visibility);
687
688        // Detect newtype pattern: single-field tuple struct
689        let (is_newtype, inner_type) = match &node.fields {
690            syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
691                let inner = fields
692                    .unnamed
693                    .first()
694                    .and_then(|f| self.extract_type_name(&f.ty));
695                (true, inner)
696            }
697            _ => (false, None),
698        };
699
700        // Check for serde derives
701        let has_serde_derive = node.attrs.iter().any(|attr| {
702            if attr.path().is_ident("derive")
703                && let Ok(nested) = attr.parse_args_with(
704                    syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
705                )
706            {
707                return nested.iter().any(|path| {
708                    let path_str = path
709                        .segments
710                        .iter()
711                        .map(|s| s.ident.to_string())
712                        .collect::<Vec<_>>()
713                        .join("::");
714                    path_str == "Serialize"
715                        || path_str == "Deserialize"
716                        || path_str == "serde::Serialize"
717                        || path_str == "serde::Deserialize"
718                });
719            }
720            false
721        });
722
723        // Count fields and public fields
724        let (total_field_count, public_field_count) = match &node.fields {
725            syn::Fields::Named(fields) => {
726                let total = fields.named.len();
727                let public = fields
728                    .named
729                    .iter()
730                    .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
731                    .count();
732                (total, public)
733            }
734            syn::Fields::Unnamed(fields) => {
735                let total = fields.unnamed.len();
736                let public = fields
737                    .unnamed
738                    .iter()
739                    .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
740                    .count();
741                (total, public)
742            }
743            syn::Fields::Unit => (0, 0),
744        };
745
746        // Register in module metrics with full details
747        self.metrics.add_type_definition_full(
748            name,
749            visibility,
750            false, // is_trait
751            is_newtype,
752            inner_type,
753            has_serde_derive,
754            public_field_count,
755            total_field_count,
756        );
757
758        // Analyze struct fields for type dependencies
759        match &node.fields {
760            syn::Fields::Named(fields) => {
761                self.metrics.type_usage_count += fields.named.len();
762                for field in &fields.named {
763                    if let Some(type_name) = self.extract_type_name(&field.ty)
764                        && !self.is_primitive_type(&type_name)
765                    {
766                        self.add_dependency(
767                            type_name,
768                            DependencyKind::TypeRef,
769                            UsageContext::TypeParameter,
770                        );
771                        self.usage_counts.type_parameters += 1;
772                    }
773                }
774            }
775            syn::Fields::Unnamed(fields) => {
776                for field in &fields.unnamed {
777                    if let Some(type_name) = self.extract_type_name(&field.ty)
778                        && !self.is_primitive_type(&type_name)
779                    {
780                        self.add_dependency(
781                            type_name,
782                            DependencyKind::TypeRef,
783                            UsageContext::TypeParameter,
784                        );
785                    }
786                }
787            }
788            syn::Fields::Unit => {}
789        }
790        syn::visit::visit_item_struct(self, node);
791    }
792
793    fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
794        let name = node.ident.to_string();
795        let visibility = convert_visibility(&node.vis);
796
797        self.defined_types.insert(name.clone());
798        self.type_visibility.insert(name.clone(), visibility);
799
800        // Register in module metrics with visibility
801        self.metrics.add_type_definition(name, visibility, false);
802
803        // Analyze enum variants for type dependencies
804        for variant in &node.variants {
805            match &variant.fields {
806                syn::Fields::Named(fields) => {
807                    for field in &fields.named {
808                        if let Some(type_name) = self.extract_type_name(&field.ty)
809                            && !self.is_primitive_type(&type_name)
810                        {
811                            self.add_dependency(
812                                type_name,
813                                DependencyKind::TypeRef,
814                                UsageContext::TypeParameter,
815                            );
816                        }
817                    }
818                }
819                syn::Fields::Unnamed(fields) => {
820                    for field in &fields.unnamed {
821                        if let Some(type_name) = self.extract_type_name(&field.ty)
822                            && !self.is_primitive_type(&type_name)
823                        {
824                            self.add_dependency(
825                                type_name,
826                                DependencyKind::TypeRef,
827                                UsageContext::TypeParameter,
828                            );
829                        }
830                    }
831                }
832                syn::Fields::Unit => {}
833            }
834        }
835        syn::visit::visit_item_enum(self, node);
836    }
837
838    fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
839        let name = node.ident.to_string();
840        let visibility = convert_visibility(&node.vis);
841
842        self.defined_traits.insert(name.clone());
843        self.type_visibility.insert(name.clone(), visibility);
844
845        // Register in module metrics with visibility (is_trait = true)
846        self.metrics.add_type_definition(name, visibility, true);
847
848        self.metrics.trait_impl_count += 1;
849        syn::visit::visit_item_trait(self, node);
850    }
851
852    fn visit_item_mod(&mut self, node: &'ast ItemMod) {
853        // Check if this is a test module (named "tests" or has #[cfg(test)])
854        if is_test_module(node) {
855            self.metrics.is_test_module = true;
856        }
857
858        if node.content.is_some() {
859            self.metrics.internal_deps.push(node.ident.to_string());
860        }
861        syn::visit::visit_item_mod(self, node);
862    }
863
864    // Detect field access: `foo.bar`
865    fn visit_expr_field(&mut self, node: &'ast ExprField) {
866        let field_name = match &node.member {
867            syn::Member::Named(ident) => ident.to_string(),
868            syn::Member::Unnamed(idx) => format!("{}", idx.index),
869        };
870
871        // This is a field access - Intrusive coupling
872        if let Expr::Path(path_expr) = &*node.base {
873            let base_name = path_expr
874                .path
875                .segments
876                .iter()
877                .map(|s| s.ident.to_string())
878                .collect::<Vec<_>>()
879                .join("::");
880
881            // Resolve to full path if imported
882            let full_path = self
883                .imported_types
884                .get(&base_name)
885                .cloned()
886                .unwrap_or(base_name.clone());
887
888            if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
889                self.add_dependency(
890                    full_path.clone(),
891                    DependencyKind::TypeRef,
892                    UsageContext::FieldAccess,
893                );
894                self.usage_counts.field_accesses += 1;
895            }
896
897            // Record item-level dependency with field name
898            let expr = format!("{}.{}", base_name, field_name);
899            self.add_item_dependency(
900                format!("{}.{}", full_path, field_name),
901                ItemDepType::FieldAccess,
902                0,
903                Some(expr),
904            );
905        }
906        syn::visit::visit_expr_field(self, node);
907    }
908
909    // Detect method calls: `foo.method()`
910    fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
911        let method_name = node.method.to_string();
912
913        // This is a method call - Functional coupling
914        if let Expr::Path(path_expr) = &*node.receiver {
915            let receiver_name = path_expr
916                .path
917                .segments
918                .iter()
919                .map(|s| s.ident.to_string())
920                .collect::<Vec<_>>()
921                .join("::");
922
923            let full_path = self
924                .imported_types
925                .get(&receiver_name)
926                .cloned()
927                .unwrap_or(receiver_name.clone());
928
929            if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
930                self.add_dependency(
931                    full_path.clone(),
932                    DependencyKind::TypeRef,
933                    UsageContext::MethodCall,
934                );
935                self.usage_counts.method_calls += 1;
936            }
937
938            // Record item-level dependency
939            let expr = format!("{}.{}()", receiver_name, method_name);
940            self.add_item_dependency(
941                format!("{}::{}", full_path, method_name),
942                ItemDepType::MethodCall,
943                0, // TODO: get line number from span
944                Some(expr),
945            );
946        }
947        syn::visit::visit_expr_method_call(self, node);
948    }
949
950    // Detect function calls: `Foo::new()` or `foo()`
951    fn visit_expr_call(&mut self, node: &'ast ExprCall) {
952        if let Expr::Path(path_expr) = &*node.func {
953            let path_str = path_expr
954                .path
955                .segments
956                .iter()
957                .map(|s| s.ident.to_string())
958                .collect::<Vec<_>>()
959                .join("::");
960
961            // Check if this is a constructor or associated function call
962            if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
963            {
964                let full_path = self
965                    .imported_types
966                    .get(&path_str)
967                    .cloned()
968                    .unwrap_or(path_str.clone());
969
970                if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
971                    self.add_dependency(
972                        full_path.clone(),
973                        DependencyKind::TypeRef,
974                        UsageContext::FunctionCall,
975                    );
976                    self.usage_counts.function_calls += 1;
977                }
978
979                // Record item-level dependency
980                self.add_item_dependency(
981                    full_path,
982                    ItemDepType::FunctionCall,
983                    0,
984                    Some(format!("{}()", path_str)),
985                );
986            } else {
987                // Simple function call like foo()
988                self.add_item_dependency(
989                    path_str.clone(),
990                    ItemDepType::FunctionCall,
991                    0,
992                    Some(format!("{}()", path_str)),
993                );
994            }
995        }
996        syn::visit::visit_expr_call(self, node);
997    }
998
999    // Detect struct construction: `Foo { field: value }`
1000    fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
1001        let struct_name = node
1002            .path
1003            .segments
1004            .iter()
1005            .map(|s| s.ident.to_string())
1006            .collect::<Vec<_>>()
1007            .join("::");
1008
1009        // Skip Self and self constructions
1010        if struct_name == "Self" || struct_name.starts_with("Self::") {
1011            syn::visit::visit_expr_struct(self, node);
1012            return;
1013        }
1014
1015        let full_path = self
1016            .imported_types
1017            .get(&struct_name)
1018            .cloned()
1019            .unwrap_or(struct_name.clone());
1020
1021        if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
1022            self.add_dependency(
1023                full_path,
1024                DependencyKind::TypeRef,
1025                UsageContext::StructConstruction,
1026            );
1027            self.usage_counts.struct_constructions += 1;
1028        }
1029        syn::visit::visit_expr_struct(self, node);
1030    }
1031}
1032
1033/// Analyzed file data
1034#[derive(Debug, Clone)]
1035struct AnalyzedFile {
1036    module_name: String,
1037    #[allow(dead_code)]
1038    file_path: PathBuf,
1039    metrics: ModuleMetrics,
1040    dependencies: Vec<Dependency>,
1041    /// Type visibility information from this file
1042    type_visibility: HashMap<String, Visibility>,
1043    /// Item-level dependencies (function calls, field access, etc.)
1044    item_dependencies: Vec<ItemDependency>,
1045}
1046
1047/// Analyze an entire project (parallel version)
1048pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1049    analyze_project_parallel(path)
1050}
1051
1052/// Check whether a file path should be excluded according to `[analysis].exclude` patterns.
1053///
1054/// Patterns are evaluated relative to the directory that contained `.coupling.toml`
1055/// when known; otherwise they fall back to the analysis root. Paths are normalized
1056/// to forward slashes for consistent glob matching on Windows.
1057fn is_path_excluded(file_path: &Path, exclude_base: &Path, config: &CompiledConfig) -> bool {
1058    let normalized_file = normalize_exclude_path(file_path);
1059    let normalized_base = normalize_exclude_path(exclude_base);
1060    let relative = normalized_file
1061        .strip_prefix(&normalized_base)
1062        .unwrap_or(&normalized_file);
1063    let relative_str = relative.to_string_lossy().replace('\\', "/");
1064    config.should_exclude(&relative_str)
1065}
1066
1067/// Normalize a path for exclude matching without resolving symlinks.
1068///
1069/// This keeps `./src`, `/tmp/foo`, and other caller-provided forms comparable
1070/// by making them absolute and removing `.` / `..` components lexically.
1071fn normalize_exclude_path(path: &Path) -> PathBuf {
1072    let absolute = if path.is_absolute() {
1073        path.to_path_buf()
1074    } else {
1075        std::env::current_dir()
1076            .map(|cwd| cwd.join(path))
1077            .unwrap_or_else(|_| path.to_path_buf())
1078    };
1079
1080    let mut normalized = PathBuf::new();
1081    for component in absolute.components() {
1082        match component {
1083            Component::CurDir => {}
1084            Component::ParentDir => {
1085                normalized.pop();
1086            }
1087            other => normalized.push(other.as_os_str()),
1088        }
1089    }
1090
1091    normalized
1092}
1093
1094/// Get an iterator over all Rust source files in `dir`, excluding hidden directories and `target/`.
1095///
1096/// Uses relative paths for filtering to avoid false positives when the project
1097/// is located in a path containing hidden directories (e.g., `/home/user/.local/projects/`).
1098/// See: https://github.com/nwiizo/cargo-coupling/issues/7
1099fn rs_files(dir: &Path) -> impl Iterator<Item = PathBuf> {
1100    WalkDir::new(dir)
1101        .follow_links(true)
1102        .into_iter()
1103        .filter_map(|e| e.ok())
1104        .filter(move |entry| {
1105            let file_path = entry.path();
1106            // Use relative path from the search root to check for hidden/target directories.
1107            // This prevents false positives when parent directories contain `.` or `target`.
1108            // Example: `/home/user/.config/myproject/src/lib.rs` should not be skipped
1109            // just because `.config` is in the parent path.
1110            let file_path = file_path.strip_prefix(dir).unwrap_or(file_path);
1111
1112            // Skip target directory and hidden directories
1113            !file_path.components().any(|c| {
1114                let s = c.as_os_str().to_string_lossy();
1115                s == "target" || s.starts_with('.')
1116            }) && file_path.extension() == Some(OsStr::new("rs"))
1117        })
1118        .map(|e| e.path().to_path_buf())
1119}
1120
1121/// Analyze a project using parallel processing with Rayon
1122///
1123/// Automatically scales to available CPU cores. The parallel processing
1124/// uses work-stealing for optimal load balancing across cores.
1125pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1126    analyze_project_parallel_with_config(path, &CompiledConfig::empty())
1127}
1128
1129/// Analyze a project in parallel, honoring `[analysis].exclude` patterns from config.
1130pub fn analyze_project_parallel_with_config(
1131    path: &Path,
1132    config: &CompiledConfig,
1133) -> Result<ProjectMetrics, AnalyzerError> {
1134    if !path.exists() {
1135        return Err(AnalyzerError::InvalidPath(path.display().to_string()));
1136    }
1137
1138    let exclude_base = config.config_root().unwrap_or(path);
1139
1140    // Collect all .rs file paths first (sequential, but fast), applying exclude patterns.
1141    let file_paths: Vec<PathBuf> = rs_files(path)
1142        .filter(|fp| !is_path_excluded(fp, exclude_base, config))
1143        .collect();
1144
1145    // Calculate optimal chunk size based on file count and available parallelism
1146    // Smaller chunks = better load balancing, but more overhead
1147    // Larger chunks = less overhead, but potential load imbalance
1148    let num_threads = rayon::current_num_threads();
1149    let file_count = file_paths.len();
1150
1151    // Use smaller chunks for better load balancing with work-stealing
1152    // Minimum chunk size of 1, maximum of file_count / (num_threads * 4)
1153    let chunk_size = if file_count < num_threads * 2 {
1154        1 // Small projects: process one file at a time
1155    } else {
1156        // Larger projects: balance between parallelism and overhead
1157        // Use ~4 chunks per thread for good work-stealing behavior
1158        (file_count / (num_threads * 4)).max(1)
1159    };
1160
1161    // Parallel file analysis with optimized chunking
1162    let analyzed_results: Vec<_> = file_paths
1163        .par_chunks(chunk_size)
1164        .flat_map(|chunk| {
1165            chunk
1166                .iter()
1167                .filter_map(|file_path| match analyze_rust_file_full(file_path) {
1168                    Ok(result) => {
1169                        // Use full module path instead of just file stem (Issue #14)
1170                        let module_path = file_path_to_module_path(file_path, path);
1171                        let old_name = result.metrics.name.clone();
1172                        let module_name = if module_path.is_empty() {
1173                            // Crate root (lib.rs/main.rs) - use the original name
1174                            old_name.clone()
1175                        } else {
1176                            module_path
1177                        };
1178
1179                        // Update target_module in item_dependencies if it referenced the old name
1180                        let item_dependencies = result
1181                            .item_dependencies
1182                            .into_iter()
1183                            .map(|mut dep| {
1184                                if dep.target_module.as_ref() == Some(&old_name) {
1185                                    dep.target_module = Some(module_name.clone());
1186                                }
1187                                dep
1188                            })
1189                            .collect();
1190
1191                        Some(AnalyzedFile {
1192                            module_name: module_name.clone(),
1193                            file_path: file_path.clone(),
1194                            metrics: {
1195                                let mut m = result.metrics;
1196                                m.name = module_name;
1197                                m
1198                            },
1199                            dependencies: result.dependencies,
1200                            type_visibility: result.type_visibility,
1201                            item_dependencies,
1202                        })
1203                    }
1204                    Err(e) => {
1205                        eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1206                        None
1207                    }
1208                })
1209                .collect::<Vec<_>>()
1210        })
1211        .collect();
1212
1213    // Build module names set
1214    let module_names: HashSet<String> = analyzed_results
1215        .iter()
1216        .map(|a| a.module_name.clone())
1217        .collect();
1218
1219    // Build project metrics (sequential, but fast)
1220    let mut project = ProjectMetrics::new();
1221    project.total_files = analyzed_results.len();
1222
1223    // First pass: register all types with their visibility
1224    for analyzed in &analyzed_results {
1225        for (type_name, visibility) in &analyzed.type_visibility {
1226            project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1227        }
1228    }
1229
1230    // Second pass: add modules and couplings
1231    for analyzed in &analyzed_results {
1232        // Clone metrics and add item_dependencies
1233        let mut metrics = analyzed.metrics.clone();
1234        metrics.item_dependencies = analyzed.item_dependencies.clone();
1235        project.add_module(metrics);
1236
1237        for dep in &analyzed.dependencies {
1238            // Skip invalid dependency paths (local variables, Self, etc.)
1239            if !is_valid_dependency_path(&dep.path) {
1240                continue;
1241            }
1242
1243            // Determine if this is an internal coupling
1244            let target_module = extract_target_module(&dep.path);
1245
1246            // Skip if target module looks invalid (but allow known module names)
1247            if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1248                continue;
1249            }
1250
1251            // Calculate distance
1252            let distance = calculate_distance(&dep.path, &module_names);
1253
1254            // Determine strength from usage context
1255            let strength = dep.usage.to_strength();
1256
1257            // Default volatility
1258            let volatility = Volatility::Low;
1259
1260            // Look up target visibility from the type registry
1261            let target_type = dep.path.split("::").last().unwrap_or(&dep.path);
1262            let visibility = project
1263                .get_type_visibility(target_type)
1264                .unwrap_or(Visibility::Public); // Default to public if unknown
1265
1266            // Create coupling metric with location
1267            let coupling = CouplingMetrics::with_location(
1268                analyzed.module_name.clone(),
1269                target_module.clone(),
1270                strength,
1271                distance,
1272                volatility,
1273                visibility,
1274                analyzed.file_path.clone(),
1275                dep.line,
1276            );
1277
1278            project.add_coupling(coupling);
1279        }
1280    }
1281
1282    // Update any remaining coupling visibility information
1283    project.update_coupling_visibility();
1284
1285    Ok(project)
1286}
1287
1288/// Analyze a workspace using cargo metadata for better accuracy
1289pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1290    analyze_workspace_with_config(path, &CompiledConfig::empty())
1291}
1292
1293/// Analyze a workspace, honoring `[analysis].exclude` patterns from config.
1294pub fn analyze_workspace_with_config(
1295    path: &Path,
1296    config: &CompiledConfig,
1297) -> Result<ProjectMetrics, AnalyzerError> {
1298    // Try to get workspace info
1299    let workspace = match WorkspaceInfo::from_path(path) {
1300        Ok(ws) => Some(ws),
1301        Err(e) => {
1302            eprintln!("Note: Could not load workspace metadata: {}", e);
1303            eprintln!("Falling back to basic analysis...");
1304            None
1305        }
1306    };
1307
1308    if let Some(ws) = workspace {
1309        analyze_with_workspace(path, &ws, config)
1310    } else {
1311        // Fall back to basic analysis
1312        analyze_project_parallel_with_config(path, config)
1313    }
1314}
1315
1316/// Analyze project with workspace information (parallel version)
1317fn analyze_with_workspace(
1318    _project_root: &Path,
1319    workspace: &WorkspaceInfo,
1320    config: &CompiledConfig,
1321) -> Result<ProjectMetrics, AnalyzerError> {
1322    // Exclude patterns are rooted at the config file when known. Otherwise fall back
1323    // to the workspace root returned by `cargo metadata`.
1324    let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
1325
1326    let mut project = ProjectMetrics::new();
1327
1328    // Store workspace info for the report
1329    project.workspace_name = Some(
1330        workspace
1331            .root
1332            .file_name()
1333            .and_then(|n| n.to_str())
1334            .unwrap_or("workspace")
1335            .to_string(),
1336    );
1337    project.workspace_members = workspace.members.clone();
1338
1339    // Collect all file paths with their crate names and src roots (sequential, fast)
1340    // Tuple: (file_path, crate_name, src_root)
1341    let mut file_crate_pairs: Vec<(PathBuf, String, PathBuf)> = Vec::new();
1342
1343    for member_name in &workspace.members {
1344        if let Some(crate_info) = workspace.get_crate(member_name) {
1345            if !crate_info.src_path.exists() {
1346                continue;
1347            }
1348
1349            let src_root = crate_info.src_path.clone();
1350            for file_path in rs_files(&crate_info.src_path) {
1351                if is_path_excluded(&file_path, exclude_base, config) {
1352                    continue;
1353                }
1354                file_crate_pairs.push((
1355                    file_path.to_path_buf(),
1356                    member_name.clone(),
1357                    src_root.clone(),
1358                ));
1359            }
1360        }
1361    }
1362
1363    // Calculate optimal chunk size for parallel processing
1364    let num_threads = rayon::current_num_threads();
1365    let file_count = file_crate_pairs.len();
1366    let chunk_size = if file_count < num_threads * 2 {
1367        1
1368    } else {
1369        (file_count / (num_threads * 4)).max(1)
1370    };
1371
1372    // Parallel file analysis with optimized chunking
1373    let analyzed_files: Vec<AnalyzedFileWithCrate> = file_crate_pairs
1374        .par_chunks(chunk_size)
1375        .flat_map(|chunk| {
1376            chunk
1377                .iter()
1378                .filter_map(|(file_path, crate_name, src_root)| {
1379                    match analyze_rust_file_full(file_path) {
1380                        Ok(result) => {
1381                            // Use full module path instead of just file stem (Issue #14)
1382                            let module_path = file_path_to_module_path(file_path, src_root);
1383                            let old_name = result.metrics.name.clone();
1384                            let module_name = if module_path.is_empty() {
1385                                // Crate root (lib.rs/main.rs) - use the original name
1386                                old_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(&old_name) {
1397                                        dep.target_module = Some(module_name.clone());
1398                                    }
1399                                    dep
1400                                })
1401                                .collect();
1402
1403                            Some(AnalyzedFileWithCrate {
1404                                module_name: module_name.clone(),
1405                                crate_name: crate_name.clone(),
1406                                file_path: file_path.clone(),
1407                                metrics: {
1408                                    let mut m = result.metrics;
1409                                    m.name = module_name;
1410                                    m
1411                                },
1412                                dependencies: result.dependencies,
1413                                item_dependencies,
1414                            })
1415                        }
1416                        Err(e) => {
1417                            eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1418                            None
1419                        }
1420                    }
1421                })
1422                .collect::<Vec<_>>()
1423        })
1424        .collect();
1425
1426    project.total_files = analyzed_files.len();
1427
1428    // Build set of known module names for validation
1429    let module_names: HashSet<String> = analyzed_files
1430        .iter()
1431        .map(|a| a.module_name.clone())
1432        .collect();
1433
1434    // Second pass: build coupling relationships with workspace context
1435    for analyzed in &analyzed_files {
1436        // Clone metrics and add item_dependencies
1437        let mut metrics = analyzed.metrics.clone();
1438        metrics.item_dependencies = analyzed.item_dependencies.clone();
1439        project.add_module(metrics);
1440
1441        for dep in &analyzed.dependencies {
1442            // Skip invalid dependency paths (local variables, Self, etc.)
1443            if !is_valid_dependency_path(&dep.path) {
1444                continue;
1445            }
1446
1447            // Resolve the target crate using workspace info
1448            let resolved_crate =
1449                resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1450
1451            let target_module = extract_target_module(&dep.path);
1452
1453            // Skip if target module looks invalid (but allow known module names)
1454            if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1455                continue;
1456            }
1457
1458            // Calculate distance with workspace awareness
1459            let distance =
1460                calculate_distance_with_workspace(&dep.path, &analyzed.crate_name, workspace);
1461
1462            // Determine strength from usage context (more accurate)
1463            let strength = dep.usage.to_strength();
1464
1465            // Default volatility
1466            let volatility = Volatility::Low;
1467
1468            // Create coupling metric with location info
1469            let mut coupling = CouplingMetrics::with_location(
1470                format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1471                if let Some(ref crate_name) = resolved_crate {
1472                    format!("{}::{}", crate_name, target_module)
1473                } else {
1474                    target_module.clone()
1475                },
1476                strength,
1477                distance,
1478                volatility,
1479                Visibility::Public, // Default visibility for workspace analysis
1480                analyzed.file_path.clone(),
1481                dep.line,
1482            );
1483
1484            // Add crate-level info
1485            coupling.source_crate = Some(analyzed.crate_name.clone());
1486            coupling.target_crate = resolved_crate;
1487
1488            project.add_coupling(coupling);
1489        }
1490    }
1491
1492    // Add crate-level dependency information
1493    for (crate_name, deps) in &workspace.dependency_graph {
1494        if workspace.is_workspace_member(crate_name) {
1495            for dep in deps {
1496                // Track crate-level dependencies
1497                project
1498                    .crate_dependencies
1499                    .entry(crate_name.clone())
1500                    .or_default()
1501                    .push(dep.clone());
1502            }
1503        }
1504    }
1505
1506    Ok(project)
1507}
1508
1509/// Calculate distance using workspace information
1510fn calculate_distance_with_workspace(
1511    dep_path: &str,
1512    current_crate: &str,
1513    workspace: &WorkspaceInfo,
1514) -> Distance {
1515    if dep_path.starts_with("crate::") || dep_path.starts_with("self::") {
1516        // Same crate
1517        Distance::SameModule
1518    } else if dep_path.starts_with("super::") {
1519        // Could be same crate or parent module
1520        Distance::DifferentModule
1521    } else {
1522        // Resolve the target crate
1523        if let Some(target_crate) = resolve_crate_from_path(dep_path, current_crate, workspace) {
1524            if target_crate == current_crate {
1525                Distance::SameModule
1526            } else if workspace.is_workspace_member(&target_crate) {
1527                // Another workspace member
1528                Distance::DifferentModule
1529            } else {
1530                // External crate
1531                Distance::DifferentCrate
1532            }
1533        } else {
1534            Distance::DifferentCrate
1535        }
1536    }
1537}
1538
1539/// Analyzed file with crate information
1540#[derive(Debug, Clone)]
1541struct AnalyzedFileWithCrate {
1542    module_name: String,
1543    crate_name: String,
1544    #[allow(dead_code)]
1545    file_path: PathBuf,
1546    metrics: ModuleMetrics,
1547    dependencies: Vec<Dependency>,
1548    /// Item-level dependencies (function calls, field access, etc.)
1549    item_dependencies: Vec<ItemDependency>,
1550}
1551
1552/// Extract target module name from a path
1553fn extract_target_module(path: &str) -> String {
1554    // Remove common prefixes and get the module name
1555    let cleaned = path
1556        .trim_start_matches("crate::")
1557        .trim_start_matches("super::")
1558        .trim_start_matches("::");
1559
1560    // Get first significant segment
1561    cleaned.split("::").next().unwrap_or(path).to_string()
1562}
1563
1564/// Check if a path looks like a valid module/type reference (not a local variable)
1565fn is_valid_dependency_path(path: &str) -> bool {
1566    // Skip empty paths
1567    if path.is_empty() {
1568        return false;
1569    }
1570
1571    // Skip Self references
1572    if path == "Self" || path.starts_with("Self::") {
1573        return false;
1574    }
1575
1576    let segments: Vec<&str> = path.split("::").collect();
1577
1578    // Skip short single-segment lowercase names (likely local variables)
1579    if segments.len() == 1 {
1580        let name = segments[0];
1581        if name.len() <= 8 && name.chars().all(|c| c.is_lowercase() || c == '_') {
1582            return false;
1583        }
1584    }
1585
1586    // Skip patterns where last two segments are the same (likely module::type patterns from variables)
1587    if segments.len() >= 2 {
1588        let last = segments.last().unwrap();
1589        let second_last = segments.get(segments.len() - 2).unwrap();
1590        if last == second_last {
1591            return false;
1592        }
1593    }
1594
1595    // Skip common patterns that look like local variable accesses
1596    let last_segment = segments.last().unwrap_or(&path);
1597    let common_locals = [
1598        "request",
1599        "response",
1600        "result",
1601        "content",
1602        "config",
1603        "proto",
1604        "domain",
1605        "info",
1606        "data",
1607        "item",
1608        "value",
1609        "error",
1610        "message",
1611        "expected",
1612        "actual",
1613        "status",
1614        "state",
1615        "context",
1616        "params",
1617        "args",
1618        "options",
1619        "settings",
1620        "violation",
1621        "page_token",
1622    ];
1623    if common_locals.contains(last_segment) && segments.len() <= 2 {
1624        return false;
1625    }
1626
1627    true
1628}
1629
1630/// Calculate distance based on dependency path
1631fn calculate_distance(dep_path: &str, _known_modules: &HashSet<String>) -> Distance {
1632    if dep_path.starts_with("crate::") || dep_path.starts_with("super::") {
1633        // Internal dependency
1634        Distance::DifferentModule
1635    } else if dep_path.starts_with("self::") {
1636        Distance::SameModule
1637    } else {
1638        // External crate
1639        Distance::DifferentCrate
1640    }
1641}
1642
1643/// Analyze a single Rust file
1644/// Result of analyzing a single Rust file
1645pub struct AnalyzedFileResult {
1646    pub metrics: ModuleMetrics,
1647    pub dependencies: Vec<Dependency>,
1648    pub type_visibility: HashMap<String, Visibility>,
1649    pub item_dependencies: Vec<ItemDependency>,
1650}
1651
1652pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1653    let result = analyze_rust_file_full(path)?;
1654    Ok((result.metrics, result.dependencies))
1655}
1656
1657/// Analyze a Rust file and return full results including visibility
1658pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1659    let content = fs::read_to_string(path)?;
1660
1661    let module_name = path
1662        .file_stem()
1663        .and_then(|s| s.to_str())
1664        .unwrap_or("unknown")
1665        .to_string();
1666
1667    let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1668    analyzer.analyze_file(&content)?;
1669
1670    Ok(AnalyzedFileResult {
1671        metrics: analyzer.metrics,
1672        dependencies: analyzer.dependencies,
1673        type_visibility: analyzer.type_visibility,
1674        item_dependencies: analyzer.item_dependencies,
1675    })
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680    use super::*;
1681
1682    #[test]
1683    fn test_analyzer_creation() {
1684        let analyzer = CouplingAnalyzer::new(
1685            "test_module".to_string(),
1686            std::path::PathBuf::from("test.rs"),
1687        );
1688        assert_eq!(analyzer.current_module, "test_module");
1689    }
1690
1691    #[test]
1692    fn test_analyze_simple_file() {
1693        let mut analyzer =
1694            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1695
1696        let code = r#"
1697            pub struct User {
1698                name: String,
1699                email: String,
1700            }
1701
1702            impl User {
1703                pub fn new(name: String, email: String) -> Self {
1704                    Self { name, email }
1705                }
1706            }
1707        "#;
1708
1709        let result = analyzer.analyze_file(code);
1710        assert!(result.is_ok());
1711        assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1712    }
1713
1714    #[test]
1715    fn test_item_dependencies() {
1716        let mut analyzer =
1717            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1718
1719        let code = r#"
1720            pub struct Config {
1721                pub value: i32,
1722            }
1723
1724            pub fn process(config: Config) -> i32 {
1725                let x = config.value;
1726                helper(x)
1727            }
1728
1729            fn helper(n: i32) -> i32 {
1730                n * 2
1731            }
1732        "#;
1733
1734        let result = analyzer.analyze_file(code);
1735        assert!(result.is_ok());
1736
1737        // Check that functions are recorded
1738        assert!(analyzer.defined_functions.contains_key("process"));
1739        assert!(analyzer.defined_functions.contains_key("helper"));
1740
1741        // Check item dependencies - process should have deps
1742        println!(
1743            "Item dependencies count: {}",
1744            analyzer.item_dependencies.len()
1745        );
1746        for dep in &analyzer.item_dependencies {
1747            println!(
1748                "  {} -> {} ({:?})",
1749                dep.source_item, dep.target, dep.dep_type
1750            );
1751        }
1752
1753        // process function should have dependencies
1754        let process_deps: Vec<_> = analyzer
1755            .item_dependencies
1756            .iter()
1757            .filter(|d| d.source_item == "process")
1758            .collect();
1759
1760        assert!(
1761            !process_deps.is_empty(),
1762            "process function should have item dependencies"
1763        );
1764    }
1765
1766    #[test]
1767    fn test_analyze_trait_impl() {
1768        let mut analyzer =
1769            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1770
1771        let code = r#"
1772            trait Printable {
1773                fn print(&self);
1774            }
1775
1776            struct Document;
1777
1778            impl Printable for Document {
1779                fn print(&self) {}
1780            }
1781        "#;
1782
1783        let result = analyzer.analyze_file(code);
1784        assert!(result.is_ok());
1785        assert!(analyzer.metrics.trait_impl_count >= 1);
1786    }
1787
1788    #[test]
1789    fn test_analyze_use_statements() {
1790        let mut analyzer =
1791            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1792
1793        let code = r#"
1794            use std::collections::HashMap;
1795            use serde::Serialize;
1796            use crate::utils;
1797            use crate::models::{User, Post};
1798        "#;
1799
1800        let result = analyzer.analyze_file(code);
1801        assert!(result.is_ok());
1802        assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1803        assert!(
1804            analyzer
1805                .metrics
1806                .external_deps
1807                .contains(&"serde".to_string())
1808        );
1809        assert!(!analyzer.dependencies.is_empty());
1810
1811        // Check internal dependencies
1812        let internal_deps: Vec<_> = analyzer
1813            .dependencies
1814            .iter()
1815            .filter(|d| d.kind == DependencyKind::InternalUse)
1816            .collect();
1817        assert!(!internal_deps.is_empty());
1818    }
1819
1820    #[test]
1821    fn test_extract_use_paths() {
1822        let analyzer =
1823            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1824
1825        // Test simple path
1826        let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1827        let paths = analyzer.extract_use_paths(&tree, "");
1828        assert_eq!(paths.len(), 1);
1829        assert_eq!(paths[0].0, "std::collections::HashMap");
1830
1831        // Test grouped path
1832        let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1833        let paths = analyzer.extract_use_paths(&tree, "");
1834        assert_eq!(paths.len(), 2);
1835    }
1836
1837    #[test]
1838    fn test_extract_target_module() {
1839        assert_eq!(extract_target_module("crate::models::user"), "models");
1840        assert_eq!(extract_target_module("super::utils"), "utils");
1841        assert_eq!(extract_target_module("std::collections"), "std");
1842    }
1843
1844    #[test]
1845    fn test_field_access_detection() {
1846        let mut analyzer =
1847            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1848
1849        let code = r#"
1850            use crate::models::User;
1851
1852            fn get_name(user: &User) -> String {
1853                user.name.clone()
1854            }
1855        "#;
1856
1857        let result = analyzer.analyze_file(code);
1858        assert!(result.is_ok());
1859
1860        // Should detect User as a dependency with field access
1861        let _field_deps: Vec<_> = analyzer
1862            .dependencies
1863            .iter()
1864            .filter(|d| d.usage == UsageContext::FieldAccess)
1865            .collect();
1866        // Note: This may not detect field access on function parameters
1867        // as the type info isn't fully available without type inference
1868    }
1869
1870    #[test]
1871    fn test_method_call_detection() {
1872        let mut analyzer =
1873            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1874
1875        let code = r#"
1876            fn process() {
1877                let data = String::new();
1878                data.push_str("hello");
1879            }
1880        "#;
1881
1882        let result = analyzer.analyze_file(code);
1883        assert!(result.is_ok());
1884        // Method calls on local variables are detected
1885    }
1886
1887    #[test]
1888    fn test_struct_construction_detection() {
1889        let mut analyzer =
1890            CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1891
1892        let code = r#"
1893            use crate::config::Config;
1894
1895            fn create_config() {
1896                let c = Config { value: 42 };
1897            }
1898        "#;
1899
1900        let result = analyzer.analyze_file(code);
1901        assert!(result.is_ok());
1902
1903        // Should detect Config struct construction
1904        let struct_deps: Vec<_> = analyzer
1905            .dependencies
1906            .iter()
1907            .filter(|d| d.usage == UsageContext::StructConstruction)
1908            .collect();
1909        assert!(!struct_deps.is_empty());
1910    }
1911
1912    #[test]
1913    fn test_usage_context_to_strength() {
1914        assert_eq!(
1915            UsageContext::FieldAccess.to_strength(),
1916            IntegrationStrength::Intrusive
1917        );
1918        assert_eq!(
1919            UsageContext::MethodCall.to_strength(),
1920            IntegrationStrength::Functional
1921        );
1922        assert_eq!(
1923            UsageContext::TypeParameter.to_strength(),
1924            IntegrationStrength::Model
1925        );
1926        assert_eq!(
1927            UsageContext::TraitBound.to_strength(),
1928            IntegrationStrength::Contract
1929        );
1930    }
1931
1932    /// Test that rs_files correctly handles paths with hidden parent directories.
1933    /// Regression test for https://github.com/nwiizo/cargo-coupling/issues/7
1934    #[test]
1935    fn test_rs_files_with_hidden_parent_directory() {
1936        use std::fs;
1937        use tempfile::TempDir;
1938
1939        // Create a temporary directory structure that simulates a project
1940        // inside a hidden parent directory (e.g., /home/user/.local/projects/myproject)
1941        let temp = TempDir::new().unwrap();
1942        let hidden_parent = temp.path().join(".hidden-parent");
1943        let project_dir = hidden_parent.join("myproject").join("src");
1944        fs::create_dir_all(&project_dir).unwrap();
1945
1946        // Create some Rust files
1947        fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1948        fs::write(project_dir.join("main.rs"), "fn main() {}").unwrap();
1949
1950        // rs_files should find both files even though there's a hidden parent
1951        let files: Vec<_> = rs_files(&project_dir).collect();
1952        assert_eq!(
1953            files.len(),
1954            2,
1955            "Should find 2 .rs files in hidden parent path"
1956        );
1957
1958        // Verify the files are the ones we created
1959        let file_names: Vec<_> = files
1960            .iter()
1961            .filter_map(|p| p.file_name())
1962            .filter_map(|n| n.to_str())
1963            .collect();
1964        assert!(file_names.contains(&"lib.rs"));
1965        assert!(file_names.contains(&"main.rs"));
1966    }
1967
1968    /// Test that rs_files correctly excludes hidden directories within the project.
1969    #[test]
1970    fn test_rs_files_excludes_hidden_dirs_in_project() {
1971        use std::fs;
1972        use tempfile::TempDir;
1973
1974        let temp = TempDir::new().unwrap();
1975        let project_dir = temp.path().join("myproject").join("src");
1976        let hidden_dir = project_dir.join(".hidden");
1977        fs::create_dir_all(&hidden_dir).unwrap();
1978
1979        // Create files in both regular and hidden directories
1980        fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1981        fs::write(hidden_dir.join("secret.rs"), "fn secret() {}").unwrap();
1982
1983        // rs_files should only find lib.rs, not the file in .hidden
1984        let files: Vec<_> = rs_files(&project_dir).collect();
1985        assert_eq!(
1986            files.len(),
1987            1,
1988            "Should find only 1 .rs file (excluding .hidden/)"
1989        );
1990
1991        let file_names: Vec<_> = files
1992            .iter()
1993            .filter_map(|p| p.file_name())
1994            .filter_map(|n| n.to_str())
1995            .collect();
1996        assert!(file_names.contains(&"lib.rs"));
1997        assert!(!file_names.contains(&"secret.rs"));
1998    }
1999
2000    /// Test that rs_files correctly excludes the target directory.
2001    #[test]
2002    fn test_rs_files_excludes_target_directory() {
2003        use std::fs;
2004        use tempfile::TempDir;
2005
2006        let temp = TempDir::new().unwrap();
2007        let project_dir = temp.path().join("myproject");
2008        let src_dir = project_dir.join("src");
2009        let target_dir = project_dir.join("target").join("debug");
2010        fs::create_dir_all(&src_dir).unwrap();
2011        fs::create_dir_all(&target_dir).unwrap();
2012
2013        // Create files in both src and target directories
2014        fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
2015        fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
2016
2017        // rs_files should only find lib.rs, not the file in target/
2018        let files: Vec<_> = rs_files(&project_dir).collect();
2019        assert_eq!(
2020            files.len(),
2021            1,
2022            "Should find only 1 .rs file (excluding target/)"
2023        );
2024
2025        let file_names: Vec<_> = files
2026            .iter()
2027            .filter_map(|p| p.file_name())
2028            .filter_map(|n| n.to_str())
2029            .collect();
2030        assert!(file_names.contains(&"lib.rs"));
2031        assert!(!file_names.contains(&"generated.rs"));
2032    }
2033
2034    #[test]
2035    fn test_file_path_to_module_path_nested() {
2036        // Test: src/level/enemy/spawner.rs -> level::enemy::spawner
2037        let src_root = Path::new("/project/src");
2038        let file_path = Path::new("/project/src/level/enemy/spawner.rs");
2039        assert_eq!(
2040            file_path_to_module_path(file_path, src_root),
2041            "level::enemy::spawner"
2042        );
2043    }
2044
2045    #[test]
2046    fn test_file_path_to_module_path_lib() {
2047        // Test: src/lib.rs -> "" (crate root)
2048        let src_root = Path::new("/project/src");
2049        let file_path = Path::new("/project/src/lib.rs");
2050        assert_eq!(file_path_to_module_path(file_path, src_root), "");
2051    }
2052
2053    #[test]
2054    fn test_file_path_to_module_path_main() {
2055        // Test: src/main.rs -> "" (crate root)
2056        let src_root = Path::new("/project/src");
2057        let file_path = Path::new("/project/src/main.rs");
2058        assert_eq!(file_path_to_module_path(file_path, src_root), "");
2059    }
2060
2061    #[test]
2062    fn test_file_path_to_module_path_mod() {
2063        // Test: src/level/mod.rs -> level
2064        let src_root = Path::new("/project/src");
2065        let file_path = Path::new("/project/src/level/mod.rs");
2066        assert_eq!(file_path_to_module_path(file_path, src_root), "level");
2067    }
2068
2069    #[test]
2070    fn test_file_path_to_module_path_deeply_nested_mod() {
2071        // Test: src/a/b/c/mod.rs -> a::b::c
2072        let src_root = Path::new("/project/src");
2073        let file_path = Path::new("/project/src/a/b/c/mod.rs");
2074        assert_eq!(file_path_to_module_path(file_path, src_root), "a::b::c");
2075    }
2076
2077    #[test]
2078    fn test_file_path_to_module_path_simple() {
2079        // Test: src/utils.rs -> utils
2080        let src_root = Path::new("/project/src");
2081        let file_path = Path::new("/project/src/utils.rs");
2082        assert_eq!(file_path_to_module_path(file_path, src_root), "utils");
2083    }
2084
2085    #[test]
2086    fn test_file_path_to_module_path_two_levels() {
2087        // Test: src/foo/bar.rs -> foo::bar
2088        let src_root = Path::new("/project/src");
2089        let file_path = Path::new("/project/src/foo/bar.rs");
2090        assert_eq!(file_path_to_module_path(file_path, src_root), "foo::bar");
2091    }
2092
2093    #[test]
2094    fn test_file_path_to_module_path_bin() {
2095        // Test: src/bin/cli.rs -> bin::cli
2096        let src_root = Path::new("/project/src");
2097        let file_path = Path::new("/project/src/bin/cli.rs");
2098        assert_eq!(file_path_to_module_path(file_path, src_root), "bin::cli");
2099    }
2100
2101    #[test]
2102    fn test_file_path_to_module_path_mismatched_root() {
2103        // When strip_prefix fails, we fall back to using the full path
2104        // This handles edge cases where src_root doesn't match
2105        let src_root = Path::new("/other/src");
2106        let file_path = Path::new("/project/src/utils.rs");
2107        // Falls back to full path processing
2108        let result = file_path_to_module_path(file_path, src_root);
2109        // Should still produce something reasonable
2110        assert!(result.contains("utils"));
2111    }
2112
2113    #[test]
2114    fn test_has_test_attribute_with_test() {
2115        let code = r#"
2116            #[test]
2117            fn my_test() {}
2118        "#;
2119        let syntax: syn::File = syn::parse_str(code).unwrap();
2120        if let syn::Item::Fn(func) = &syntax.items[0] {
2121            assert!(has_test_attribute(&func.attrs));
2122        } else {
2123            panic!("Expected function");
2124        }
2125    }
2126
2127    #[test]
2128    fn test_has_test_attribute_without_test() {
2129        let code = r#"
2130            fn regular_fn() {}
2131        "#;
2132        let syntax: syn::File = syn::parse_str(code).unwrap();
2133        if let syn::Item::Fn(func) = &syntax.items[0] {
2134            assert!(!has_test_attribute(&func.attrs));
2135        } else {
2136            panic!("Expected function");
2137        }
2138    }
2139
2140    #[test]
2141    fn test_has_cfg_test_attribute_with_cfg_test() {
2142        let code = r#"
2143            #[cfg(test)]
2144            mod tests {}
2145        "#;
2146        let syntax: syn::File = syn::parse_str(code).unwrap();
2147        if let syn::Item::Mod(module) = &syntax.items[0] {
2148            assert!(has_cfg_test_attribute(&module.attrs));
2149        } else {
2150            panic!("Expected module");
2151        }
2152    }
2153
2154    #[test]
2155    fn test_has_cfg_test_attribute_without_cfg_test() {
2156        let code = r#"
2157            mod regular_mod {}
2158        "#;
2159        let syntax: syn::File = syn::parse_str(code).unwrap();
2160        if let syn::Item::Mod(module) = &syntax.items[0] {
2161            assert!(!has_cfg_test_attribute(&module.attrs));
2162        } else {
2163            panic!("Expected module");
2164        }
2165    }
2166
2167    #[test]
2168    fn test_has_cfg_test_attribute_with_other_cfg() {
2169        let code = r#"
2170            #[cfg(feature = "foo")]
2171            mod feature_mod {}
2172        "#;
2173        let syntax: syn::File = syn::parse_str(code).unwrap();
2174        if let syn::Item::Mod(module) = &syntax.items[0] {
2175            assert!(!has_cfg_test_attribute(&module.attrs));
2176        } else {
2177            panic!("Expected module");
2178        }
2179    }
2180
2181    #[test]
2182    fn test_is_test_module_named_tests() {
2183        let code = r#"
2184            mod tests {}
2185        "#;
2186        let syntax: syn::File = syn::parse_str(code).unwrap();
2187        if let syn::Item::Mod(module) = &syntax.items[0] {
2188            assert!(is_test_module(module));
2189        } else {
2190            panic!("Expected module");
2191        }
2192    }
2193
2194    #[test]
2195    fn test_is_test_module_with_cfg_test() {
2196        let code = r#"
2197            #[cfg(test)]
2198            mod my_tests {}
2199        "#;
2200        let syntax: syn::File = syn::parse_str(code).unwrap();
2201        if let syn::Item::Mod(module) = &syntax.items[0] {
2202            assert!(is_test_module(module));
2203        } else {
2204            panic!("Expected module");
2205        }
2206    }
2207
2208    #[test]
2209    fn test_is_test_module_regular_module() {
2210        let code = r#"
2211            mod utils {}
2212        "#;
2213        let syntax: syn::File = syn::parse_str(code).unwrap();
2214        if let syn::Item::Mod(module) = &syntax.items[0] {
2215            assert!(!is_test_module(module));
2216        } else {
2217            panic!("Expected module");
2218        }
2219    }
2220
2221    /// Regression test for Issue #39: `[analysis].exclude` patterns must be applied during analysis.
2222    ///
2223    /// We assert on module names (not just `total_files`) so the test distinguishes
2224    /// "excluded by config" from "silently dropped due to parse failure".
2225    /// Both `src/generated/*` and `src/generated/**` are kept to mirror the reporter's repro.
2226    #[test]
2227    fn test_analyze_project_parallel_applies_exclude_patterns() {
2228        use crate::config::{CompiledConfig, CouplingConfig};
2229
2230        let tmp = tempfile::tempdir().expect("create tempdir");
2231        let root = tmp.path();
2232        let src = root.join("src");
2233        let generated = src.join("generated");
2234        std::fs::create_dir_all(&generated).expect("create generated dir");
2235        std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
2236            .expect("write lib.rs");
2237        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2238            .expect("write generated/mod.rs");
2239
2240        // Baseline: with empty config both files are analyzed, including the generated module.
2241        let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
2242            .expect("baseline analysis");
2243        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2244        assert!(
2245            baseline.modules.keys().any(|k| k.contains("generated")),
2246            "baseline must include the generated module; saw {:?}",
2247            baseline.modules.keys().collect::<Vec<_>>()
2248        );
2249
2250        // With exclude patterns the generated file is filtered out by config.
2251        let toml = r#"
2252            [analysis]
2253            exclude = ["src/generated/*", "src/generated/**"]
2254        "#;
2255        let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
2256        let compiled = CompiledConfig::from_config(config).expect("compile config");
2257        let filtered =
2258            analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
2259        assert_eq!(
2260            filtered.total_files, 1,
2261            "generated file should be excluded from analysis"
2262        );
2263        assert!(
2264            !filtered.modules.keys().any(|k| k.contains("generated")),
2265            "no generated module should remain; saw {:?}",
2266            filtered.modules.keys().collect::<Vec<_>>()
2267        );
2268    }
2269
2270    /// Regression test for Issue #39 on the CLI/workspace path:
2271    /// a relative `./src`-style path must still apply `[analysis].exclude`.
2272    #[test]
2273    fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
2274        use crate::config::{CompiledConfig, load_compiled_config};
2275
2276        let current_dir = std::env::current_dir().expect("get current dir");
2277        let target_dir = current_dir.join("target");
2278        let tmp = tempfile::Builder::new()
2279            .prefix("issue39-workspace-")
2280            .tempdir_in(&target_dir)
2281            .expect("create tempdir in target");
2282        let root = tmp.path();
2283        let src = root.join("src");
2284        let generated = src.join("generated");
2285        std::fs::create_dir_all(&generated).expect("create generated dir");
2286        std::fs::write(
2287            root.join("Cargo.toml"),
2288            r#"[package]
2289name = "coupling-fixture-exclude"
2290version = "0.1.0"
2291edition = "2024"
2292"#,
2293        )
2294        .expect("write Cargo.toml");
2295        std::fs::write(
2296            root.join(".coupling.toml"),
2297            "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2298        )
2299        .expect("write .coupling.toml");
2300        std::fs::write(
2301            src.join("lib.rs"),
2302            "pub mod generated;\npub fn call() { generated::helper(); }\n",
2303        )
2304        .expect("write lib.rs");
2305        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2306            .expect("write generated/mod.rs");
2307
2308        let relative_src = src
2309            .strip_prefix(&current_dir)
2310            .expect("temp crate should be under current dir");
2311
2312        let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
2313            .expect("baseline workspace analysis");
2314        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2315        assert!(
2316            baseline.modules.keys().any(|k| k.contains("generated")),
2317            "baseline must include the generated module; saw {:?}",
2318            baseline.modules.keys().collect::<Vec<_>>()
2319        );
2320
2321        let compiled = load_compiled_config(relative_src).expect("load compiled config");
2322        let filtered = analyze_workspace_with_config(relative_src, &compiled)
2323            .expect("filtered workspace analysis");
2324        assert_eq!(
2325            filtered.total_files, 1,
2326            "generated file should be excluded from workspace analysis"
2327        );
2328        assert!(
2329            !filtered.modules.keys().any(|k| k.contains("generated")),
2330            "no generated module should remain; saw {:?}",
2331            filtered.modules.keys().collect::<Vec<_>>()
2332        );
2333    }
2334
2335    /// Regression test for the non-workspace fallback path:
2336    /// when analyzing `./src`, exclude patterns must still be rooted at the config file.
2337    #[test]
2338    fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
2339        use crate::config::{CompiledConfig, load_compiled_config};
2340
2341        let tmp = tempfile::tempdir().expect("create tempdir");
2342        let root = tmp.path();
2343        let src = root.join("src");
2344        let generated = src.join("generated");
2345        std::fs::create_dir_all(&generated).expect("create generated dir");
2346        std::fs::write(
2347            root.join(".coupling.toml"),
2348            "[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
2349        )
2350        .expect("write .coupling.toml");
2351        std::fs::write(
2352            src.join("lib.rs"),
2353            "pub mod generated;\npub fn call() { generated::helper(); }\n",
2354        )
2355        .expect("write lib.rs");
2356        std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
2357            .expect("write generated/mod.rs");
2358
2359        let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
2360            .expect("baseline analysis");
2361        assert_eq!(baseline.total_files, 2, "both files should be analyzed");
2362        assert!(
2363            baseline.modules.keys().any(|k| k.contains("generated")),
2364            "baseline must include the generated module; saw {:?}",
2365            baseline.modules.keys().collect::<Vec<_>>()
2366        );
2367
2368        let compiled = load_compiled_config(&src).expect("load compiled config");
2369        let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
2370        assert_eq!(
2371            filtered.total_files, 1,
2372            "generated file should be excluded from fallback analysis"
2373        );
2374        assert!(
2375            !filtered.modules.keys().any(|k| k.contains("generated")),
2376            "no generated module should remain; saw {:?}",
2377            filtered.modules.keys().collect::<Vec<_>>()
2378        );
2379    }
2380}