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