use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use rayon::prelude::*;
use syn::visit::Visit;
use syn::{
Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
};
use thiserror::Error;
use crate::config::CompiledConfig;
use crate::discovery::{
DiscoveredWorkspaceFile, canonical_file_key, discover_module_tree, file_path_to_module_path,
normalize_exclude_path, rs_files, rs_files_excluding_nested_packages,
};
use crate::metrics::coupling::CouplingMetrics;
use crate::metrics::dimensions::{IntegrationStrength, Visibility};
use crate::metrics::module::ModuleMetrics;
use crate::metrics::project::ProjectMetrics;
use crate::volatility::Volatility;
use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
fn convert_visibility(vis: &syn::Visibility) -> Visibility {
match vis {
syn::Visibility::Public(_) => Visibility::Public,
syn::Visibility::Restricted(restricted) => {
let path_str = restricted
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
match path_str.as_str() {
"crate" => Visibility::PubCrate,
"super" => Visibility::PubSuper,
"self" => Visibility::Private, _ => Visibility::PubIn, }
}
syn::Visibility::Inherited => Visibility::Private,
}
}
fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| attr.path().is_ident("test"))
}
fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|attr| {
if attr.path().is_ident("cfg") {
if let Ok(meta) = attr.meta.require_list() {
let tokens = meta.tokens.to_string();
return tokens.contains("test");
}
}
false
})
}
fn is_test_module(item: &ItemMod) -> bool {
item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
}
#[derive(Error, Debug)]
pub enum AnalyzerError {
#[error("Failed to read file: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse Rust file: {0}")]
ParseError(String),
#[error("Invalid path: {0}")]
InvalidPath(String),
#[error("Workspace error: {0}")]
WorkspaceError(#[from] WorkspaceError),
}
#[derive(Debug, Clone)]
pub struct Dependency {
pub path: String,
pub kind: DependencyKind,
pub line: usize,
pub usage: UsageContext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DependencyKind {
InternalUse,
ExternalUse,
TraitImpl,
InherentImpl,
TypeRef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UsageContext {
Import,
TraitBound,
FieldAccess,
MethodCall,
FunctionCall,
StructConstruction,
TypeParameter,
FunctionParameter,
ReturnType,
InherentImplBlock,
}
impl UsageContext {
pub fn to_strength(&self) -> IntegrationStrength {
match self {
UsageContext::FieldAccess => IntegrationStrength::Intrusive,
UsageContext::StructConstruction => IntegrationStrength::Intrusive,
UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
UsageContext::MethodCall => IntegrationStrength::Functional,
UsageContext::FunctionCall => IntegrationStrength::Functional,
UsageContext::FunctionParameter => IntegrationStrength::Functional,
UsageContext::ReturnType => IntegrationStrength::Functional,
UsageContext::TypeParameter => IntegrationStrength::Model,
UsageContext::Import => IntegrationStrength::Model,
UsageContext::TraitBound => IntegrationStrength::Contract,
}
}
}
impl DependencyKind {
pub fn to_strength(&self) -> IntegrationStrength {
match self {
DependencyKind::TraitImpl => IntegrationStrength::Contract,
DependencyKind::InternalUse => IntegrationStrength::Model,
DependencyKind::ExternalUse => IntegrationStrength::Model,
DependencyKind::TypeRef => IntegrationStrength::Model,
DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
}
}
}
#[derive(Debug)]
pub struct CouplingAnalyzer {
pub current_module: String,
pub file_path: std::path::PathBuf,
pub metrics: ModuleMetrics,
pub dependencies: Vec<Dependency>,
pub defined_types: HashSet<String>,
pub defined_traits: HashSet<String>,
pub defined_functions: HashMap<String, Visibility>,
imported_types: HashMap<String, String>,
seen_dependencies: HashSet<(String, UsageContext)>,
pub usage_counts: UsageCounts,
pub type_visibility: HashMap<String, Visibility>,
current_item: Option<(String, ItemKind)>,
pub item_dependencies: Vec<ItemDependency>,
}
#[derive(Debug, Default, Clone)]
pub struct UsageCounts {
pub field_accesses: usize,
pub method_calls: usize,
pub function_calls: usize,
pub struct_constructions: usize,
pub trait_bounds: usize,
pub type_parameters: usize,
}
#[derive(Debug, Clone)]
pub struct ItemDependency {
pub source_item: String,
pub source_kind: ItemKind,
pub target: String,
pub target_module: Option<String>,
pub dep_type: ItemDepType,
pub line: usize,
pub expression: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemKind {
Function,
Method,
Struct,
Enum,
Trait,
Impl,
Module,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemDepType {
FunctionCall,
MethodCall,
TypeUsage,
FieldAccess,
StructConstruction,
TraitImpl,
TraitBound,
Import,
}
impl CouplingAnalyzer {
pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
Self {
current_module: module_name.clone(),
file_path: path.clone(),
metrics: ModuleMetrics::new(path, module_name),
dependencies: Vec::new(),
defined_types: HashSet::new(),
defined_traits: HashSet::new(),
defined_functions: HashMap::new(),
imported_types: HashMap::new(),
seen_dependencies: HashSet::new(),
usage_counts: UsageCounts::default(),
type_visibility: HashMap::new(),
current_item: None,
item_dependencies: Vec::new(),
}
}
pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
let syntax: File =
syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
self.visit_file(&syntax);
Ok(())
}
fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
let key = (path.clone(), usage);
if self.seen_dependencies.contains(&key) {
return;
}
self.seen_dependencies.insert(key);
self.dependencies.push(Dependency {
path,
kind,
line: 0,
usage,
});
}
fn add_item_dependency(
&mut self,
target: String,
dep_type: ItemDepType,
line: usize,
expression: Option<String>,
) {
if let Some((ref source_item, source_kind)) = self.current_item {
let target_module = self.imported_types.get(&target).cloned().or_else(|| {
if self.defined_types.contains(&target)
|| self.defined_functions.contains_key(&target)
{
Some(self.current_module.clone())
} else {
None
}
});
self.item_dependencies.push(ItemDependency {
source_item: source_item.clone(),
source_kind,
target,
target_module,
dep_type,
line,
expression,
});
}
}
fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
let mut paths = Vec::new();
match tree {
UseTree::Path(path) => {
let new_prefix = if prefix.is_empty() {
path.ident.to_string()
} else {
format!("{}::{}", prefix, path.ident)
};
paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
}
UseTree::Name(name) => {
let full_path = if prefix.is_empty() {
name.ident.to_string()
} else {
format!("{}::{}", prefix, name.ident)
};
let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
DependencyKind::InternalUse
} else {
DependencyKind::ExternalUse
};
paths.push((full_path, kind));
}
UseTree::Rename(rename) => {
let full_path = if prefix.is_empty() {
rename.ident.to_string()
} else {
format!("{}::{}", prefix, rename.ident)
};
let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
DependencyKind::InternalUse
} else {
DependencyKind::ExternalUse
};
paths.push((full_path, kind));
}
UseTree::Glob(_) => {
let full_path = format!("{}::*", prefix);
let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
DependencyKind::InternalUse
} else {
DependencyKind::ExternalUse
};
paths.push((full_path, kind));
}
UseTree::Group(group) => {
for item in &group.items {
paths.extend(self.extract_use_paths(item, prefix));
}
}
}
paths
}
fn extract_type_name(&self, ty: &Type) -> Option<String> {
match ty {
Type::Path(type_path) => {
let segments: Vec<_> = type_path
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
Some(segments.join("::"))
}
Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
Type::Array(array_type) => self.extract_type_name(&array_type.elem),
Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
Type::Group(group_type) => self.extract_type_name(&group_type.elem),
_ => None,
}
}
fn analyze_signature(&mut self, sig: &Signature) {
for arg in &sig.inputs {
if let FnArg::Typed(pat_type) = arg
&& let Some(type_name) = self.extract_type_name(&pat_type.ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::TypeRef,
UsageContext::FunctionParameter,
);
}
}
if let ReturnType::Type(_, ty) = &sig.output
&& let Some(type_name) = self.extract_type_name(ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
}
}
fn is_primitive_type(&self, type_name: &str) -> bool {
if matches!(
type_name,
"bool"
| "char"
| "str"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "usize"
| "i8"
| "i16"
| "i32"
| "i64"
| "i128"
| "isize"
| "f32"
| "f64"
| "String"
| "Self"
| "()"
| "Option"
| "Result"
| "Vec"
| "Box"
| "Rc"
| "Arc"
| "RefCell"
| "Cell"
| "Mutex"
| "RwLock"
) {
return true;
}
if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
return true;
}
if type_name.starts_with("self") || type_name == "self" {
return true;
}
false
}
}
impl<'ast> Visit<'ast> for CouplingAnalyzer {
fn visit_item_use(&mut self, node: &'ast ItemUse) {
let paths = self.extract_use_paths(&node.tree, "");
for (path, kind) in paths {
if path == "self" || path.starts_with("self::") {
continue;
}
if let Some(type_name) = path.split("::").last() {
self.imported_types
.insert(type_name.to_string(), path.clone());
}
self.add_dependency(path.clone(), kind, UsageContext::Import);
if kind == DependencyKind::InternalUse {
if !self.metrics.internal_deps.contains(&path) {
self.metrics.internal_deps.push(path.clone());
}
} else if kind == DependencyKind::ExternalUse {
let crate_name = path.split("::").next().unwrap_or(&path).to_string();
if !self.metrics.external_deps.contains(&crate_name) {
self.metrics.external_deps.push(crate_name);
}
}
}
syn::visit::visit_item_use(self, node);
}
fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
if let Some((_, trait_path, _)) = &node.trait_ {
self.metrics.trait_impl_count += 1;
let trait_name: String = trait_path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
self.add_dependency(
trait_name,
DependencyKind::TraitImpl,
UsageContext::TraitBound,
);
self.usage_counts.trait_bounds += 1;
} else {
self.metrics.inherent_impl_count += 1;
if let Some(type_name) = self.extract_type_name(&node.self_ty)
&& !self.defined_types.contains(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::InherentImpl,
UsageContext::InherentImplBlock,
);
}
}
syn::visit::visit_item_impl(self, node);
}
fn visit_item_fn(&mut self, node: &'ast ItemFn) {
let fn_name = node.sig.ident.to_string();
let visibility = convert_visibility(&node.vis);
self.defined_functions.insert(fn_name.clone(), visibility);
if has_test_attribute(&node.attrs) {
self.metrics.test_function_count += 1;
}
let mut param_count = 0;
let mut primitive_param_count = 0;
let mut param_types = Vec::new();
for arg in &node.sig.inputs {
if let FnArg::Typed(pat_type) = arg {
param_count += 1;
if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
param_types.push(type_name.clone());
if self.is_primitive_type(&type_name) {
primitive_param_count += 1;
}
}
}
}
self.metrics.add_function_definition_full(
fn_name.clone(),
visibility,
param_count,
primitive_param_count,
param_types,
);
let previous_item = self.current_item.take();
self.current_item = Some((fn_name, ItemKind::Function));
self.analyze_signature(&node.sig);
syn::visit::visit_item_fn(self, node);
self.current_item = previous_item;
}
fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
let name = node.ident.to_string();
let visibility = convert_visibility(&node.vis);
self.defined_types.insert(name.clone());
self.type_visibility.insert(name.clone(), visibility);
let (is_newtype, inner_type) = match &node.fields {
syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
let inner = fields
.unnamed
.first()
.and_then(|f| self.extract_type_name(&f.ty));
(true, inner)
}
_ => (false, None),
};
let has_serde_derive = node.attrs.iter().any(|attr| {
if attr.path().is_ident("derive")
&& let Ok(nested) = attr.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
{
return nested.iter().any(|path| {
let path_str = path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
path_str == "Serialize"
|| path_str == "Deserialize"
|| path_str == "serde::Serialize"
|| path_str == "serde::Deserialize"
});
}
false
});
let (total_field_count, public_field_count) = match &node.fields {
syn::Fields::Named(fields) => {
let total = fields.named.len();
let public = fields
.named
.iter()
.filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
.count();
(total, public)
}
syn::Fields::Unnamed(fields) => {
let total = fields.unnamed.len();
let public = fields
.unnamed
.iter()
.filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
.count();
(total, public)
}
syn::Fields::Unit => (0, 0),
};
self.metrics.add_type_definition_full(
name,
visibility,
false, is_newtype,
inner_type,
has_serde_derive,
public_field_count,
total_field_count,
);
match &node.fields {
syn::Fields::Named(fields) => {
self.metrics.type_usage_count += fields.named.len();
for field in &fields.named {
if let Some(type_name) = self.extract_type_name(&field.ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::TypeRef,
UsageContext::TypeParameter,
);
self.usage_counts.type_parameters += 1;
}
}
}
syn::Fields::Unnamed(fields) => {
for field in &fields.unnamed {
if let Some(type_name) = self.extract_type_name(&field.ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::TypeRef,
UsageContext::TypeParameter,
);
}
}
}
syn::Fields::Unit => {}
}
syn::visit::visit_item_struct(self, node);
}
fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
let name = node.ident.to_string();
let visibility = convert_visibility(&node.vis);
self.defined_types.insert(name.clone());
self.type_visibility.insert(name.clone(), visibility);
self.metrics.add_type_definition(name, visibility, false);
for variant in &node.variants {
match &variant.fields {
syn::Fields::Named(fields) => {
for field in &fields.named {
if let Some(type_name) = self.extract_type_name(&field.ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::TypeRef,
UsageContext::TypeParameter,
);
}
}
}
syn::Fields::Unnamed(fields) => {
for field in &fields.unnamed {
if let Some(type_name) = self.extract_type_name(&field.ty)
&& !self.is_primitive_type(&type_name)
{
self.add_dependency(
type_name,
DependencyKind::TypeRef,
UsageContext::TypeParameter,
);
}
}
}
syn::Fields::Unit => {}
}
}
syn::visit::visit_item_enum(self, node);
}
fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
let name = node.ident.to_string();
let visibility = convert_visibility(&node.vis);
self.defined_traits.insert(name.clone());
self.type_visibility.insert(name.clone(), visibility);
self.metrics.add_type_definition(name, visibility, true);
self.metrics.trait_impl_count += 1;
syn::visit::visit_item_trait(self, node);
}
fn visit_item_mod(&mut self, node: &'ast ItemMod) {
if is_test_module(node) {
self.metrics.is_test_module = true;
}
if node.content.is_some() {
self.metrics.internal_deps.push(node.ident.to_string());
}
syn::visit::visit_item_mod(self, node);
}
fn visit_expr_field(&mut self, node: &'ast ExprField) {
let field_name = match &node.member {
syn::Member::Named(ident) => ident.to_string(),
syn::Member::Unnamed(idx) => format!("{}", idx.index),
};
if let Expr::Path(path_expr) = &*node.base {
let base_name = path_expr
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
let full_path = self
.imported_types
.get(&base_name)
.cloned()
.unwrap_or(base_name.clone());
if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
self.add_dependency(
full_path.clone(),
DependencyKind::TypeRef,
UsageContext::FieldAccess,
);
self.usage_counts.field_accesses += 1;
}
let expr = format!("{}.{}", base_name, field_name);
self.add_item_dependency(
format!("{}.{}", full_path, field_name),
ItemDepType::FieldAccess,
0,
Some(expr),
);
}
syn::visit::visit_expr_field(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
let method_name = node.method.to_string();
if let Expr::Path(path_expr) = &*node.receiver {
let receiver_name = path_expr
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
let full_path = self
.imported_types
.get(&receiver_name)
.cloned()
.unwrap_or(receiver_name.clone());
if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
self.add_dependency(
full_path.clone(),
DependencyKind::TypeRef,
UsageContext::MethodCall,
);
self.usage_counts.method_calls += 1;
}
let expr = format!("{}.{}()", receiver_name, method_name);
self.add_item_dependency(
format!("{}::{}", full_path, method_name),
ItemDepType::MethodCall,
0, Some(expr),
);
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_call(&mut self, node: &'ast ExprCall) {
if let Expr::Path(path_expr) = &*node.func {
let path_str = path_expr
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
{
let full_path = self
.imported_types
.get(&path_str)
.cloned()
.unwrap_or(path_str.clone());
if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
self.add_dependency(
full_path.clone(),
DependencyKind::TypeRef,
UsageContext::FunctionCall,
);
self.usage_counts.function_calls += 1;
}
self.add_item_dependency(
full_path,
ItemDepType::FunctionCall,
0,
Some(format!("{}()", path_str)),
);
} else {
self.add_item_dependency(
path_str.clone(),
ItemDepType::FunctionCall,
0,
Some(format!("{}()", path_str)),
);
}
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
let struct_name = node
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect::<Vec<_>>()
.join("::");
if struct_name == "Self" || struct_name.starts_with("Self::") {
syn::visit::visit_expr_struct(self, node);
return;
}
let full_path = self
.imported_types
.get(&struct_name)
.cloned()
.unwrap_or(struct_name.clone());
if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
self.add_dependency(
full_path,
DependencyKind::TypeRef,
UsageContext::StructConstruction,
);
self.usage_counts.struct_constructions += 1;
}
syn::visit::visit_expr_struct(self, node);
}
}
#[derive(Debug, Clone)]
struct AnalyzedFile {
module_name: String,
#[allow(dead_code)]
file_path: PathBuf,
metrics: ModuleMetrics,
dependencies: Vec<Dependency>,
type_visibility: HashMap<String, Visibility>,
item_dependencies: Vec<ItemDependency>,
}
pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
analyze_project_parallel(path)
}
fn is_path_excluded(file_path: &Path, exclude_base: &Path, config: &CompiledConfig) -> bool {
let normalized_file = normalize_exclude_path(file_path);
let normalized_base = normalize_exclude_path(exclude_base);
let relative = normalized_file
.strip_prefix(&normalized_base)
.unwrap_or(&normalized_file);
let relative_str = relative.to_string_lossy().replace('\\', "/");
config.should_exclude(&relative_str)
}
fn path_for_config_matching(file_path: &Path, config: &CompiledConfig) -> String {
let normalized_file = normalize_exclude_path(file_path);
let path = config
.config_root()
.map(normalize_exclude_path)
.and_then(|base| {
normalized_file
.strip_prefix(base)
.ok()
.map(Path::to_path_buf)
})
.unwrap_or(normalized_file);
path.to_string_lossy().replace('\\', "/")
}
pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
analyze_project_parallel_with_config(path, &CompiledConfig::empty())
}
pub fn analyze_project_parallel_with_config(
path: &Path,
config: &CompiledConfig,
) -> Result<ProjectMetrics, AnalyzerError> {
if !path.exists() {
return Err(AnalyzerError::InvalidPath(path.display().to_string()));
}
let exclude_base = config.config_root().unwrap_or(path);
let file_paths: Vec<PathBuf> = rs_files(path)
.filter(|fp| !is_path_excluded(fp, exclude_base, config))
.collect();
let num_threads = rayon::current_num_threads();
let file_count = file_paths.len();
let chunk_size = if file_count < num_threads * 2 {
1 } else {
(file_count / (num_threads * 4)).max(1)
};
let analyzed_results: Vec<_> = file_paths
.par_chunks(chunk_size)
.flat_map(|chunk| {
chunk
.iter()
.filter_map(|file_path| match analyze_rust_file_full(file_path) {
Ok(result) => {
let module_path = file_path_to_module_path(file_path, path);
let original_module_name = result.metrics.name.clone();
let module_name = if module_path.is_empty() {
original_module_name.clone()
} else {
module_path
};
let item_dependencies = result
.item_dependencies
.into_iter()
.map(|mut dep| {
if dep.target_module.as_ref() == Some(&original_module_name) {
dep.target_module = Some(module_name.clone());
}
dep
})
.collect();
Some(AnalyzedFile {
module_name: module_name.clone(),
file_path: file_path.clone(),
metrics: {
let mut module_metrics = result.metrics;
module_metrics.name = module_name;
module_metrics
},
dependencies: result.dependencies,
type_visibility: result.type_visibility,
item_dependencies,
})
}
Err(e) => {
eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
None
}
})
.collect::<Vec<_>>()
})
.collect();
let module_names: HashSet<String> = analyzed_results
.iter()
.map(|a| a.module_name.clone())
.collect();
let mut project = ProjectMetrics::new();
project.total_files = analyzed_results.len();
project.parse_failures = file_paths.len().saturating_sub(analyzed_results.len());
let candidate_config_paths = file_paths
.iter()
.map(|file_path| path_for_config_matching(file_path, config))
.collect::<Vec<_>>();
for analyzed in &analyzed_results {
for (type_name, visibility) in &analyzed.type_visibility {
project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
}
}
for analyzed in &analyzed_results {
let mut metrics = analyzed.metrics.clone();
metrics.item_dependencies = analyzed.item_dependencies.clone();
metrics.subdomain =
config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
project.add_module(metrics);
for dep in &analyzed.dependencies {
if !is_valid_dependency_path(&dep.path) {
continue;
}
let target_module =
resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
let target_is_known_internal_module = module_names.contains(&target_module);
if !target_is_known_internal_module && !is_valid_dependency_path(&target_module) {
continue;
}
let distance = calculate_distance(
&analyzed.module_name,
&target_module,
target_is_known_internal_module,
);
let target_type = target_type_name(&dep.path);
let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
let strength = strength_for_dependency(dep, target_visibility);
let volatility = Volatility::Low;
let visibility = visibility_for_dependency(dep, target_visibility);
let coupling = CouplingMetrics::with_location(
analyzed.module_name.clone(),
target_module.clone(),
strength,
distance,
volatility,
visibility,
analyzed.file_path.clone(),
dep.line,
);
project.add_coupling(coupling);
}
}
project.update_coupling_visibility();
project.dead_config_patterns =
format_dead_config_patterns(config, &candidate_config_paths, path);
Ok(project)
}
pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
analyze_workspace_with_config(path, &CompiledConfig::empty())
}
pub fn analyze_workspace_with_config(
path: &Path,
config: &CompiledConfig,
) -> Result<ProjectMetrics, AnalyzerError> {
let workspace = match WorkspaceInfo::from_path(path) {
Ok(ws) => Some(ws),
Err(e) => {
eprintln!("Note: Could not load workspace metadata: {}", e);
eprintln!("Falling back to basic analysis...");
None
}
};
if let Some(ws) = workspace {
analyze_with_workspace(path, &ws, config)
} else {
analyze_project_parallel_with_config(path, config)
}
}
fn analyze_with_workspace(
_project_root: &Path,
workspace: &WorkspaceInfo,
config: &CompiledConfig,
) -> Result<ProjectMetrics, AnalyzerError> {
let exclude_base = config.config_root().unwrap_or(workspace.root.as_path());
let mut project = ProjectMetrics::new();
project.workspace_name = Some(
workspace
.root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string(),
);
project.workspace_members = workspace.members.clone();
let mut discovered_files: Vec<DiscoveredWorkspaceFile> = Vec::new();
for member_name in &workspace.members {
if let Some(crate_info) = workspace.get_crate(member_name) {
let mut member_files: HashMap<PathBuf, DiscoveredWorkspaceFile> = HashMap::new();
let mut source_contents = HashMap::new();
for source_root in &crate_info.source_roots {
if !source_root.exists() {
continue;
}
for file_path in
rs_files_excluding_nested_packages(source_root, &crate_info.manifest_path)
{
if is_path_excluded(&file_path, exclude_base, config) {
continue;
}
let file_key = canonical_file_key(&file_path);
member_files
.entry(file_key)
.or_insert_with(|| DiscoveredWorkspaceFile {
file_path: file_path.to_path_buf(),
crate_name: member_name.clone(),
source_root: source_root.clone(),
module_name: None,
});
}
}
let has_path_attribute = member_files
.keys()
.any(|file_key| cache_file_and_scan_path_attribute(file_key, &mut source_contents));
if has_path_attribute {
let mut visited = HashSet::new();
for crate_root in &crate_info.crate_roots {
let discovery = discover_module_tree(
crate_root,
&workspace.root,
&crate_info.manifest_path,
&mut visited,
&mut source_contents,
);
project.boundary_skipped_files += discovery.boundary_skipped_files;
for module_file in discovery.files {
if is_path_excluded(&module_file.file_path, exclude_base, config) {
continue;
}
let file_key = canonical_file_key(&module_file.file_path);
member_files
.entry(file_key)
.or_insert_with(|| DiscoveredWorkspaceFile {
file_path: module_file.file_path.clone(),
crate_name: member_name.clone(),
source_root: module_file
.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default(),
module_name: Some(module_file.module_name),
});
}
}
}
if member_files.is_empty() {
project.skipped_crates.push(member_name.clone());
} else {
discovered_files.extend(member_files.into_values());
}
}
}
let num_threads = rayon::current_num_threads();
let file_count = discovered_files.len();
let chunk_size = if file_count < num_threads * 2 {
1
} else {
(file_count / (num_threads * 4)).max(1)
};
let analyzed_files: Vec<AnalyzedFileWithCrate> = discovered_files
.par_chunks(chunk_size)
.flat_map(|chunk| {
chunk
.iter()
.filter_map(|discovered| {
match analyze_rust_file_full(&discovered.file_path) {
Ok(result) => {
let module_path = discovered.module_name.clone().unwrap_or_else(|| {
file_path_to_module_path(
&discovered.file_path,
&discovered.source_root,
)
});
let original_module_name = result.metrics.name.clone();
let module_name = if module_path.is_empty() {
original_module_name.clone()
} else {
module_path
};
let item_dependencies = result
.item_dependencies
.into_iter()
.map(|mut dep| {
if dep.target_module.as_ref() == Some(&original_module_name) {
dep.target_module = Some(module_name.clone());
}
dep
})
.collect();
Some(AnalyzedFileWithCrate {
module_name: module_name.clone(),
crate_name: discovered.crate_name.clone(),
file_path: discovered.file_path.clone(),
metrics: {
let mut module_metrics = result.metrics;
module_metrics.name = module_name;
module_metrics
},
dependencies: result.dependencies,
type_visibility: result.type_visibility,
item_dependencies,
})
}
Err(e) => {
eprintln!(
"Warning: Failed to analyze {}: {}",
discovered.file_path.display(),
e
);
None
}
}
})
.collect::<Vec<_>>()
})
.collect();
project.total_files = analyzed_files.len();
project.parse_failures = discovered_files.len().saturating_sub(analyzed_files.len());
let candidate_config_paths = discovered_files
.iter()
.map(|discovered| path_for_config_matching(&discovered.file_path, config))
.collect::<Vec<_>>();
let module_names: HashSet<String> = analyzed_files
.iter()
.map(|a| a.module_name.clone())
.collect();
for analyzed in &analyzed_files {
for (type_name, visibility) in &analyzed.type_visibility {
project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
}
}
for analyzed in &analyzed_files {
let mut metrics = analyzed.metrics.clone();
metrics.item_dependencies = analyzed.item_dependencies.clone();
metrics.subdomain =
config.get_subdomain(&path_for_config_matching(&analyzed.file_path, config));
project.add_module(metrics);
for dep in &analyzed.dependencies {
if !is_valid_dependency_path(&dep.path) {
continue;
}
let resolved_crate =
resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
let target_module =
resolve_target_module(&dep.path, &analyzed.module_name, &module_names, &project);
let target_is_known_internal_module = module_names.contains(&target_module);
let resolved_crate = if resolved_crate.is_none() && target_is_known_internal_module {
Some(analyzed.crate_name.clone())
} else {
resolved_crate
};
if !target_is_known_internal_module && !is_valid_dependency_path(&target_module) {
continue;
}
let distance = calculate_distance_with_workspace(
&analyzed.module_name,
&target_module,
target_is_known_internal_module,
&analyzed.crate_name,
resolved_crate.as_deref(),
workspace,
);
let target_type = target_type_name(&dep.path);
let target_visibility = target_type.and_then(|name| project.get_type_visibility(name));
let strength = strength_for_dependency(dep, target_visibility);
let volatility = Volatility::Low;
let mut coupling = CouplingMetrics::with_location(
format!("{}::{}", analyzed.crate_name, analyzed.module_name),
if let Some(ref crate_name) = resolved_crate {
format!("{}::{}", crate_name, target_module)
} else {
target_module.clone()
},
strength,
distance,
volatility,
visibility_for_dependency(dep, target_visibility),
analyzed.file_path.clone(),
dep.line,
);
coupling.source_crate = Some(analyzed.crate_name.clone());
coupling.target_crate = resolved_crate;
project.add_coupling(coupling);
}
}
for (crate_name, deps) in &workspace.dependency_graph {
if workspace.is_workspace_member(crate_name) {
for dep in deps {
project
.crate_dependencies
.entry(crate_name.clone())
.or_default()
.push(dep.clone());
}
}
}
project.dead_config_patterns =
format_dead_config_patterns(config, &candidate_config_paths, &workspace.root);
Ok(project)
}
fn format_dead_config_patterns(
config: &CompiledConfig,
candidate_paths: &[String],
analysis_scope: &Path,
) -> Vec<String> {
let Some(config_root) = config.config_root() else {
return Vec::new();
};
if !config.has_subdomain_config() && !config.has_volatility_overrides() {
return Vec::new();
}
let normalized_scope = normalize_exclude_path(analysis_scope);
let normalized_root = normalize_exclude_path(config_root);
let scope = match normalized_scope.strip_prefix(&normalized_root) {
Ok(rel) => rel.to_string_lossy().replace('\\', "/"),
Err(_) if normalized_root.starts_with(&normalized_scope) => String::new(),
Err(_) => return Vec::new(),
};
config
.dead_patterns(candidate_paths)
.into_iter()
.filter(|dead| pattern_within_scope(&dead.pattern, &scope))
.map(|dead| format!("{}: {}", dead.section, dead.pattern))
.collect()
}
fn pattern_within_scope(pattern: &str, scope: &str) -> bool {
if scope.is_empty() {
return true;
}
let (literal, had_meta) = match pattern.find(['*', '?', '[']) {
Some(idx) => (&pattern[..idx], true),
None => (pattern, false),
};
let literal = if had_meta {
match literal.rfind('/') {
Some(idx) => &literal[..idx],
None => "",
}
} else {
literal
};
let literal_components: Vec<&str> = literal.split('/').filter(|c| !c.is_empty()).collect();
let scope_components: Vec<&str> = scope.split('/').filter(|c| !c.is_empty()).collect();
literal_components.len() >= scope_components.len()
&& literal_components[..scope_components.len()] == scope_components[..]
}
fn cache_file_and_scan_path_attribute(
file_key: &Path,
source_contents: &mut HashMap<PathBuf, String>,
) -> bool {
if let Some(content) = source_contents.get(file_key) {
return content.contains("#[path");
}
let Ok(bytes) = fs::read(file_key) else {
return false;
};
let has_path_attribute = bytes
.windows(b"#[path".len())
.any(|window| window == b"#[path");
if let Ok(content) = String::from_utf8(bytes) {
source_contents.insert(file_key.to_path_buf(), content);
}
has_path_attribute
}
pub(crate) use crate::classification::{
calculate_distance, calculate_distance_with_workspace, is_valid_dependency_path,
resolve_target_module, strength_for_dependency, target_type_name, visibility_for_dependency,
};
#[derive(Debug, Clone)]
struct AnalyzedFileWithCrate {
module_name: String,
crate_name: String,
#[allow(dead_code)]
file_path: PathBuf,
metrics: ModuleMetrics,
dependencies: Vec<Dependency>,
type_visibility: HashMap<String, Visibility>,
item_dependencies: Vec<ItemDependency>,
}
pub struct AnalyzedFileResult {
pub metrics: ModuleMetrics,
pub dependencies: Vec<Dependency>,
pub type_visibility: HashMap<String, Visibility>,
pub item_dependencies: Vec<ItemDependency>,
}
pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
let result = analyze_rust_file_full(path)?;
Ok((result.metrics, result.dependencies))
}
pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
let content = fs::read_to_string(path)?;
let module_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
analyzer.analyze_file(&content)?;
Ok(AnalyzedFileResult {
metrics: analyzer.metrics,
dependencies: analyzer.dependencies,
type_visibility: analyzer.type_visibility,
item_dependencies: analyzer.item_dependencies,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classification::extract_target_module;
use crate::metrics::dimensions::Distance;
fn resolve_target_module_for_test(
path: &str,
source_module: &str,
known_modules: &HashSet<String>,
) -> String {
resolve_target_module(path, source_module, known_modules, &ProjectMetrics::new())
}
#[test]
fn pattern_within_scope_requires_full_coverage() {
assert!(pattern_within_scope("other/**", ""));
assert!(pattern_within_scope("src/balance/**", "src"));
assert!(pattern_within_scope("src/broken.rs", "src"));
assert!(!pattern_within_scope("other/**", "src"));
assert!(!pattern_within_scope("src/broken.rs", "src/web"));
assert!(!pattern_within_scope("src/**", "src/web"));
assert!(!pattern_within_scope("src/bal*", "src/balance"));
assert!(pattern_within_scope("src/balance/mod*", "src/balance"));
assert!(!pattern_within_scope("**/generated/**", "src"));
}
#[test]
fn test_analyzer_creation() {
let analyzer = CouplingAnalyzer::new(
"test_module".to_string(),
std::path::PathBuf::from("test.rs"),
);
assert_eq!(analyzer.current_module, "test_module");
}
#[test]
fn test_analyze_simple_file() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
pub struct User {
name: String,
email: String,
}
impl User {
pub fn new(name: String, email: String) -> Self {
Self { name, email }
}
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
assert_eq!(analyzer.metrics.inherent_impl_count, 1);
}
#[test]
fn test_item_dependencies() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
pub struct Config {
pub value: i32,
}
pub fn process(config: Config) -> i32 {
let x = config.value;
helper(x)
}
fn helper(n: i32) -> i32 {
n * 2
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
assert!(analyzer.defined_functions.contains_key("process"));
assert!(analyzer.defined_functions.contains_key("helper"));
println!(
"Item dependencies count: {}",
analyzer.item_dependencies.len()
);
for dep in &analyzer.item_dependencies {
println!(
" {} -> {} ({:?})",
dep.source_item, dep.target, dep.dep_type
);
}
let process_deps: Vec<_> = analyzer
.item_dependencies
.iter()
.filter(|d| d.source_item == "process")
.collect();
assert!(
!process_deps.is_empty(),
"process function should have item dependencies"
);
}
#[test]
fn test_analyze_trait_impl() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
trait Printable {
fn print(&self);
}
struct Document;
impl Printable for Document {
fn print(&self) {}
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
assert!(analyzer.metrics.trait_impl_count >= 1);
}
#[test]
fn test_analyze_use_statements() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
use std::collections::HashMap;
use serde::Serialize;
use crate::utils;
use crate::models::{User, Post};
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
assert!(
analyzer
.metrics
.external_deps
.contains(&"serde".to_string())
);
assert!(!analyzer.dependencies.is_empty());
let internal_deps: Vec<_> = analyzer
.dependencies
.iter()
.filter(|d| d.kind == DependencyKind::InternalUse)
.collect();
assert!(!internal_deps.is_empty());
}
#[test]
fn test_extract_use_paths() {
let analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
let paths = analyzer.extract_use_paths(&tree, "");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].0, "std::collections::HashMap");
let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
let paths = analyzer.extract_use_paths(&tree, "");
assert_eq!(paths.len(), 2);
}
#[test]
fn test_extract_target_module() {
assert_eq!(extract_target_module("crate::models::user"), "models");
assert_eq!(extract_target_module("super::utils"), "utils");
assert_eq!(extract_target_module("std::collections"), "std");
}
#[test]
fn test_resolve_target_module_prefers_longest_known_module() {
let known = HashSet::from([
"balance".to_string(),
"balance::issues".to_string(),
"balance::score".to_string(),
]);
assert_eq!(
resolve_target_module_for_test(
"crate::balance::issues::CouplingIssue",
"report",
&known
),
"balance::issues"
);
assert_eq!(
resolve_target_module_for_test("super::issues::IssueType", "balance::coupling", &known),
"balance::issues"
);
assert_eq!(
resolve_target_module_for_test(
"std::collections::HashMap",
"balance::coupling",
&known
),
"std"
);
}
#[test]
fn test_reexported_type_name_resolves_to_defining_module() {
let known = HashSet::from(["a".to_string(), "a::b".to_string(), "consumer".to_string()]);
let mut project = ProjectMetrics::new();
project.register_type(
"SomeType".to_string(),
"a::b".to_string(),
Visibility::Public,
);
let target = resolve_target_module("crate::SomeType", "a", &known, &project);
assert_eq!(target, "a::b");
assert_eq!(
calculate_distance("a", &target, known.contains(&target)),
Distance::SameModule
);
}
#[test]
fn test_unknown_reexported_type_name_keeps_external_fallback() {
let known = HashSet::from(["a::b".to_string(), "consumer".to_string()]);
let project = ProjectMetrics::new();
let target = resolve_target_module("crate::UnknownType", "consumer", &known, &project);
assert_eq!(target, "UnknownType");
assert_eq!(
calculate_distance("consumer", &target, known.contains(&target)),
Distance::DifferentCrate
);
}
#[test]
fn test_type_registry_ambiguity_prefers_public_then_lexicographic_module() {
let mut project = ProjectMetrics::new();
project.register_type(
"Shared".to_string(),
"z::private".to_string(),
Visibility::Private,
);
project.register_type(
"Shared".to_string(),
"m::public".to_string(),
Visibility::Public,
);
project.register_type(
"Shared".to_string(),
"a::public".to_string(),
Visibility::Public,
);
assert_eq!(project.get_type_module("Shared"), Some("a::public"));
assert_eq!(
project.get_type_visibility("Shared"),
Some(Visibility::Public)
);
}
#[test]
fn test_strength_mapping_uses_public_model_for_data_access_only() {
let public_field = Dependency {
path: "crate::PublicType".to_string(),
kind: DependencyKind::TypeRef,
line: 0,
usage: UsageContext::FieldAccess,
};
let public_struct = Dependency {
path: "crate::PublicType".to_string(),
kind: DependencyKind::TypeRef,
line: 0,
usage: UsageContext::StructConstruction,
};
let crate_field = Dependency {
path: "crate::CrateType".to_string(),
kind: DependencyKind::TypeRef,
line: 0,
usage: UsageContext::FieldAccess,
};
let unknown_struct = Dependency {
path: "crate::UnknownType".to_string(),
kind: DependencyKind::TypeRef,
line: 0,
usage: UsageContext::StructConstruction,
};
let inherent_impl = Dependency {
path: "crate::PublicType".to_string(),
kind: DependencyKind::InherentImpl,
line: 0,
usage: UsageContext::InherentImplBlock,
};
assert_eq!(
strength_for_dependency(&public_field, Some(Visibility::Public)),
IntegrationStrength::Model
);
assert_eq!(
strength_for_dependency(&public_struct, Some(Visibility::Public)),
IntegrationStrength::Model
);
assert_eq!(
strength_for_dependency(&crate_field, Some(Visibility::PubCrate)),
IntegrationStrength::Intrusive
);
assert_eq!(
strength_for_dependency(&unknown_struct, None),
IntegrationStrength::Intrusive
);
assert_eq!(
strength_for_dependency(&inherent_impl, Some(Visibility::Public)),
IntegrationStrength::Intrusive
);
}
#[test]
fn test_structural_distance_same_file_is_same_module() {
assert_eq!(
calculate_distance("balance::grade", "balance::grade", true),
Distance::SameModule
);
}
#[test]
fn test_structural_distance_siblings_are_same_module_when_parent_is_not_root() {
let known = HashSet::from([
"balance::grade".to_string(),
"balance::rationale".to_string(),
]);
let super_target = resolve_target_module_for_test(
"super::rationale::GradeRationale",
"balance::grade",
&known,
);
let crate_target = resolve_target_module(
"crate::balance::rationale::GradeRationale",
"balance::grade",
&known,
&ProjectMetrics::new(),
);
assert_eq!(super_target, "balance::rationale");
assert_eq!(crate_target, "balance::rationale");
assert_eq!(
calculate_distance(
"balance::grade",
&super_target,
known.contains(&super_target)
),
Distance::SameModule
);
assert_eq!(
calculate_distance(
"balance::grade",
&crate_target,
known.contains(&crate_target)
),
Distance::SameModule
);
}
#[test]
fn test_structural_distance_parent_child_is_same_module() {
assert_eq!(
calculate_distance("balance", "balance::grade", true),
Distance::SameModule
);
assert_eq!(
calculate_distance("balance::grade", "balance", true),
Distance::SameModule
);
assert_eq!(
calculate_distance("web", "web::server::internal", true),
Distance::SameModule
);
assert_eq!(
calculate_distance("web::server::internal", "web", true),
Distance::SameModule
);
}
#[test]
fn test_structural_distance_cousins_are_different_module() {
assert_eq!(
calculate_distance("web::a", "web::b::c", true),
Distance::DifferentModule
);
}
#[test]
fn test_structural_distance_root_level_siblings_are_different_module() {
assert_eq!(
calculate_distance("diff", "analyzer", true),
Distance::DifferentModule
);
assert_eq!(
calculate_distance("diff", "balance::issue", true),
Distance::DifferentModule
);
}
#[test]
fn test_structural_distance_crate_syntax_does_not_make_far_module_close() {
let known = HashSet::from(["far::away".to_string()]);
let target = resolve_target_module_for_test("crate::far::away::X", "diff", &known);
assert_eq!(target, "far::away");
assert_eq!(
calculate_distance("diff", &target, known.contains(&target)),
Distance::DifferentModule
);
}
#[test]
fn test_structural_distance_workspace_member_and_external_crate() {
let workspace = WorkspaceInfo {
root: PathBuf::new(),
crates: HashMap::new(),
members: vec!["app".to_string(), "domain".to_string()],
dependency_graph: HashMap::new(),
reverse_deps: HashMap::new(),
};
assert_eq!(
calculate_distance_with_workspace(
"app_module",
"serde",
false,
"app",
Some("serde"),
&workspace,
),
Distance::DifferentCrate
);
assert_eq!(
calculate_distance_with_workspace(
"app_module",
"domain_module",
false,
"app",
Some("domain"),
&workspace,
),
Distance::DifferentModule
);
}
#[test]
fn test_field_access_detection() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
use crate::models::User;
fn get_name(user: &User) -> String {
user.name.clone()
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
let _field_deps: Vec<_> = analyzer
.dependencies
.iter()
.filter(|d| d.usage == UsageContext::FieldAccess)
.collect();
}
#[test]
fn test_method_call_detection() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
fn process() {
let data = String::new();
data.push_str("hello");
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
}
#[test]
fn test_struct_construction_detection() {
let mut analyzer =
CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
let code = r#"
use crate::config::Config;
fn create_config() {
let c = Config { value: 42 };
}
"#;
let result = analyzer.analyze_file(code);
assert!(result.is_ok());
let struct_deps: Vec<_> = analyzer
.dependencies
.iter()
.filter(|d| d.usage == UsageContext::StructConstruction)
.collect();
assert!(!struct_deps.is_empty());
}
#[test]
fn test_usage_context_to_strength() {
assert_eq!(
UsageContext::FieldAccess.to_strength(),
IntegrationStrength::Intrusive
);
assert_eq!(
UsageContext::MethodCall.to_strength(),
IntegrationStrength::Functional
);
assert_eq!(
UsageContext::TypeParameter.to_strength(),
IntegrationStrength::Model
);
assert_eq!(
UsageContext::TraitBound.to_strength(),
IntegrationStrength::Contract
);
}
#[test]
fn test_has_test_attribute_with_test() {
let code = r#"
#[test]
fn my_test() {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Fn(func) = &syntax.items[0] {
assert!(has_test_attribute(&func.attrs));
} else {
panic!("Expected function");
}
}
#[test]
fn test_has_test_attribute_without_test() {
let code = r#"
fn regular_fn() {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Fn(func) = &syntax.items[0] {
assert!(!has_test_attribute(&func.attrs));
} else {
panic!("Expected function");
}
}
#[test]
fn test_has_cfg_test_attribute_with_cfg_test() {
let code = r#"
#[cfg(test)]
mod tests {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(has_cfg_test_attribute(&module.attrs));
} else {
panic!("Expected module");
}
}
#[test]
fn test_has_cfg_test_attribute_without_cfg_test() {
let code = r#"
mod regular_mod {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(!has_cfg_test_attribute(&module.attrs));
} else {
panic!("Expected module");
}
}
#[test]
fn test_has_cfg_test_attribute_with_other_cfg() {
let code = r#"
#[cfg(feature = "foo")]
mod feature_mod {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(!has_cfg_test_attribute(&module.attrs));
} else {
panic!("Expected module");
}
}
#[test]
fn test_is_test_module_named_tests() {
let code = r#"
mod tests {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(is_test_module(module));
} else {
panic!("Expected module");
}
}
#[test]
fn test_is_test_module_with_cfg_test() {
let code = r#"
#[cfg(test)]
mod my_tests {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(is_test_module(module));
} else {
panic!("Expected module");
}
}
#[test]
fn test_is_test_module_regular_module() {
let code = r#"
mod utils {}
"#;
let syntax: syn::File = syn::parse_str(code).unwrap();
if let syn::Item::Mod(module) = &syntax.items[0] {
assert!(!is_test_module(module));
} else {
panic!("Expected module");
}
}
#[test]
fn test_analyze_project_parallel_applies_exclude_patterns() {
use crate::config::{CompiledConfig, CouplingConfig};
let tmp = tempfile::tempdir().expect("create tempdir");
let root = tmp.path();
let src = root.join("src");
let generated = src.join("generated");
std::fs::create_dir_all(&generated).expect("create generated dir");
std::fs::write(src.join("lib.rs"), "pub mod generated;\npub fn call() {}\n")
.expect("write lib.rs");
std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
.expect("write generated/mod.rs");
let baseline = analyze_project_parallel_with_config(root, &CompiledConfig::empty())
.expect("baseline analysis");
assert_eq!(baseline.total_files, 2, "both files should be analyzed");
assert!(
baseline.modules.keys().any(|k| k.contains("generated")),
"baseline must include the generated module; saw {:?}",
baseline.modules.keys().collect::<Vec<_>>()
);
let toml = r#"
[analysis]
exclude = ["src/generated/*", "src/generated/**"]
"#;
let config: CouplingConfig = toml::from_str(toml).expect("parse toml");
let compiled = CompiledConfig::from_config(config).expect("compile config");
let filtered =
analyze_project_parallel_with_config(root, &compiled).expect("filtered analysis");
assert_eq!(
filtered.total_files, 1,
"generated file should be excluded from analysis"
);
assert!(
!filtered.modules.keys().any(|k| k.contains("generated")),
"no generated module should remain; saw {:?}",
filtered.modules.keys().collect::<Vec<_>>()
);
}
#[test]
fn test_analyze_workspace_applies_exclude_patterns_from_relative_src_path() {
use crate::config::{CompiledConfig, load_compiled_config};
let current_dir = std::env::current_dir().expect("get current dir");
let target_dir = current_dir.join("target");
let tmp = tempfile::Builder::new()
.prefix("issue39-workspace-")
.tempdir_in(&target_dir)
.expect("create tempdir in target");
let root = tmp.path();
let src = root.join("src");
let generated = src.join("generated");
std::fs::create_dir_all(&generated).expect("create generated dir");
std::fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "coupling-fixture-exclude"
version = "0.1.0"
edition = "2024"
"#,
)
.expect("write Cargo.toml");
std::fs::write(
root.join(".coupling.toml"),
"[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
)
.expect("write .coupling.toml");
std::fs::write(
src.join("lib.rs"),
"pub mod generated;\npub fn call() { generated::helper(); }\n",
)
.expect("write lib.rs");
std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
.expect("write generated/mod.rs");
let relative_src = src
.strip_prefix(¤t_dir)
.expect("temp crate should be under current dir");
let baseline = analyze_workspace_with_config(relative_src, &CompiledConfig::empty())
.expect("baseline workspace analysis");
assert_eq!(baseline.total_files, 2, "both files should be analyzed");
assert!(
baseline.modules.keys().any(|k| k.contains("generated")),
"baseline must include the generated module; saw {:?}",
baseline.modules.keys().collect::<Vec<_>>()
);
let compiled = load_compiled_config(relative_src).expect("load compiled config");
let filtered = analyze_workspace_with_config(relative_src, &compiled)
.expect("filtered workspace analysis");
assert_eq!(
filtered.total_files, 1,
"generated file should be excluded from workspace analysis"
);
assert!(
!filtered.modules.keys().any(|k| k.contains("generated")),
"no generated module should remain; saw {:?}",
filtered.modules.keys().collect::<Vec<_>>()
);
}
#[test]
fn test_basic_analysis_fallback_applies_exclude_patterns_from_config_root() {
use crate::config::{CompiledConfig, load_compiled_config};
let tmp = tempfile::tempdir().expect("create tempdir");
let root = tmp.path();
let src = root.join("src");
let generated = src.join("generated");
std::fs::create_dir_all(&generated).expect("create generated dir");
std::fs::write(
root.join(".coupling.toml"),
"[analysis]\nexclude = [\"src/generated/*\", \"src/generated/**\"]\n",
)
.expect("write .coupling.toml");
std::fs::write(
src.join("lib.rs"),
"pub mod generated;\npub fn call() { generated::helper(); }\n",
)
.expect("write lib.rs");
std::fs::write(generated.join("mod.rs"), "pub fn helper() {}\n")
.expect("write generated/mod.rs");
let baseline = analyze_workspace_with_config(&src, &CompiledConfig::empty())
.expect("baseline analysis");
assert_eq!(baseline.total_files, 2, "both files should be analyzed");
assert!(
baseline.modules.keys().any(|k| k.contains("generated")),
"baseline must include the generated module; saw {:?}",
baseline.modules.keys().collect::<Vec<_>>()
);
let compiled = load_compiled_config(&src).expect("load compiled config");
let filtered = analyze_workspace_with_config(&src, &compiled).expect("filtered analysis");
assert_eq!(
filtered.total_files, 1,
"generated file should be excluded from fallback analysis"
);
assert!(
!filtered.modules.keys().any(|k| k.contains("generated")),
"no generated module should remain; saw {:?}",
filtered.modules.keys().collect::<Vec<_>>()
);
}
}