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