Skip to main content

cargo_coupling/
analyzer.rs

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