mod cycles;
mod graph;
pub(crate) mod metrics;
pub(crate) mod sdp;
#[derive(Debug, Clone)]
pub struct ModuleGraph {
pub modules: Vec<String>,
pub forward: Vec<Vec<usize>>,
}
#[derive(Debug, Clone)]
pub struct CouplingMetrics {
pub module_name: String,
pub afferent: usize,
pub efferent: usize,
pub instability: f64,
pub incoming: Vec<String>,
pub outgoing: Vec<String>,
pub suppressed: bool,
}
#[derive(Debug, Clone)]
pub struct CycleReport {
pub modules: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CouplingAnalysis {
pub metrics: Vec<CouplingMetrics>,
pub cycles: Vec<CycleReport>,
pub sdp_violations: Vec<sdp::SdpViolation>,
}
pub fn analyze_coupling(parsed: &[(String, String, syn::File)]) -> CouplingAnalysis {
let graph = graph::build_module_graph(parsed);
let metrics = metrics::compute_coupling_metrics(&graph);
let cycles = cycles::detect_cycles(&graph);
let sdp_violations = sdp::check_sdp(&graph, &metrics);
CouplingAnalysis {
metrics,
cycles,
sdp_violations,
}
}
pub fn file_to_module(file_path: &str) -> String {
let path = file_path.replace('\\', "/");
let stripped = path.strip_prefix("src/").unwrap_or(&path);
if let Some(slash_pos) = stripped.find('/') {
stripped[..slash_pos].to_string()
} else {
stripped.strip_suffix(".rs").unwrap_or(stripped).to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_code(code: &str) -> syn::File {
syn::parse_file(code).expect("Failed to parse test code")
}
fn make_parsed(files: Vec<(&str, &str)>) -> Vec<(String, String, syn::File)> {
files
.into_iter()
.map(|(path, code)| (path.to_string(), code.to_string(), parse_code(code)))
.collect()
}
fn idx(graph: &ModuleGraph, name: &str) -> usize {
graph
.modules
.iter()
.position(|m| m == name)
.unwrap_or_else(|| panic!("module '{name}' not found in graph"))
}
#[test]
fn test_file_to_module_root_file() {
assert_eq!(file_to_module("main.rs"), "main");
assert_eq!(file_to_module("pipeline.rs"), "pipeline");
}
#[test]
fn test_file_to_module_subdir_mod() {
assert_eq!(file_to_module("config/mod.rs"), "config");
assert_eq!(file_to_module("analyzer/mod.rs"), "analyzer");
}
#[test]
fn test_file_to_module_subdir_file() {
assert_eq!(file_to_module("analyzer/types.rs"), "analyzer");
assert_eq!(file_to_module("report/text.rs"), "report");
}
#[test]
fn test_file_to_module_src_prefix() {
assert_eq!(file_to_module("src/main.rs"), "main");
assert_eq!(file_to_module("src/config/mod.rs"), "config");
assert_eq!(file_to_module("src/analyzer/types.rs"), "analyzer");
}
#[test]
fn test_file_to_module_backslash() {
assert_eq!(file_to_module("src\\config\\mod.rs"), "config");
assert_eq!(file_to_module("analyzer\\types.rs"), "analyzer");
}
#[test]
fn test_build_graph_no_deps() {
let parsed = make_parsed(vec![
("main.rs", "fn main() {}"),
("config.rs", "pub struct Config;"),
]);
let graph = graph::build_module_graph(&parsed);
assert_eq!(graph.modules.len(), 2);
assert!(graph.forward.iter().all(|adj| adj.is_empty()));
}
#[test]
fn test_build_graph_simple_dep() {
let parsed = make_parsed(vec![
("main.rs", "use crate::config::Config; fn main() {}"),
("config.rs", "pub struct Config;"),
]);
let graph = graph::build_module_graph(&parsed);
let main_idx = idx(&graph, "main");
let config_idx = idx(&graph, "config");
assert!(graph.forward[main_idx].contains(&config_idx));
assert!(graph.forward[config_idx].is_empty());
}
#[test]
fn test_build_graph_self_dep_skipped() {
let parsed = make_parsed(vec![
(
"analyzer/mod.rs",
"use crate::analyzer::types::Foo; fn f() {}",
),
("analyzer/types.rs", "pub struct Foo;"),
]);
let graph = graph::build_module_graph(&parsed);
let analyzer_idx = idx(&graph, "analyzer");
assert!(
graph.forward[analyzer_idx].is_empty(),
"Self-dependencies should be skipped"
);
}
#[test]
fn test_build_graph_group_use() {
let parsed = make_parsed(vec![
(
"main.rs",
"use crate::{config::Config, pipeline::run}; fn main() {}",
),
("config.rs", "pub struct Config;"),
("pipeline.rs", "pub fn run() {}"),
]);
let graph = graph::build_module_graph(&parsed);
let main_idx = idx(&graph, "main");
assert_eq!(graph.forward[main_idx].len(), 2);
}
#[test]
fn test_build_graph_external_dep_ignored() {
let parsed = make_parsed(vec![(
"main.rs",
"use std::collections::HashMap; use serde::Deserialize; fn main() {}",
)]);
let graph = graph::build_module_graph(&parsed);
let main_idx = idx(&graph, "main");
assert!(
graph.forward[main_idx].is_empty(),
"External dependencies should be ignored"
);
}
#[test]
fn test_build_graph_multiple_files_same_module() {
let parsed = make_parsed(vec![
(
"config/mod.rs",
"use crate::analyzer::Foo; pub mod sections;",
),
("config/sections.rs", "pub struct Defaults;"),
("analyzer.rs", "pub struct Foo;"),
]);
let graph = graph::build_module_graph(&parsed);
let config_idx = idx(&graph, "config");
let analyzer_idx = idx(&graph, "analyzer");
assert!(graph.forward[config_idx].contains(&analyzer_idx));
}
#[test]
fn test_build_graph_glob_use() {
let parsed = make_parsed(vec![
("main.rs", "use crate::analyzer::*; fn main() {}"),
("analyzer.rs", "pub fn analyze() {}"),
]);
let graph = graph::build_module_graph(&parsed);
let main_idx = idx(&graph, "main");
let analyzer_idx = idx(&graph, "analyzer");
assert!(graph.forward[main_idx].contains(&analyzer_idx));
}
#[test]
fn test_build_graph_rename_use() {
let parsed = make_parsed(vec![
("main.rs", "use crate::config::Config as Cfg; fn main() {}"),
("config.rs", "pub struct Config;"),
]);
let graph = graph::build_module_graph(&parsed);
let main_idx = idx(&graph, "main");
let config_idx = idx(&graph, "config");
assert!(graph.forward[main_idx].contains(&config_idx));
}
#[test]
fn test_metrics_empty() {
let graph = ModuleGraph {
modules: vec![],
forward: vec![],
};
let metrics = metrics::compute_coupling_metrics(&graph);
assert!(metrics.is_empty());
}
#[test]
fn test_metrics_simple_dep() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into()],
forward: vec![vec![1], vec![]],
};
let metrics = metrics::compute_coupling_metrics(&graph);
assert_eq!(metrics[0].afferent, 0);
assert_eq!(metrics[0].efferent, 1);
assert_eq!(metrics[1].afferent, 1);
assert_eq!(metrics[1].efferent, 0);
}
#[test]
fn test_metrics_instability_formula() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into(), "c".into()],
forward: vec![vec![1, 2], vec![], vec![]],
};
let metrics = metrics::compute_coupling_metrics(&graph);
assert!((metrics[0].instability - 1.0).abs() < f64::EPSILON);
assert!((metrics[1].instability).abs() < f64::EPSILON);
}
#[test]
fn test_metrics_isolated_module() {
let graph = ModuleGraph {
modules: vec!["isolated".into()],
forward: vec![vec![]],
};
let metrics = metrics::compute_coupling_metrics(&graph);
assert_eq!(metrics[0].afferent, 0);
assert_eq!(metrics[0].efferent, 0);
assert!((metrics[0].instability).abs() < f64::EPSILON);
}
#[test]
fn test_cycles_empty_graph() {
let graph = ModuleGraph {
modules: vec![],
forward: vec![],
};
let cycles = cycles::detect_cycles(&graph);
assert!(cycles.is_empty());
}
#[test]
fn test_cycles_no_cycles() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into(), "c".into()],
forward: vec![vec![1], vec![2], vec![]],
};
let cycles = cycles::detect_cycles(&graph);
assert!(cycles.is_empty());
}
#[test]
fn test_cycles_simple_cycle() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into()],
forward: vec![vec![1], vec![0]],
};
let cycles = cycles::detect_cycles(&graph);
assert_eq!(cycles.len(), 1);
assert!(cycles[0].modules.contains(&"a".to_string()));
assert!(cycles[0].modules.contains(&"b".to_string()));
}
#[test]
fn test_cycles_complex_cycle() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into(), "c".into()],
forward: vec![vec![1], vec![2], vec![0]],
};
let cycles = cycles::detect_cycles(&graph);
assert_eq!(cycles.len(), 1);
assert_eq!(cycles[0].modules.len(), 3);
}
#[test]
fn test_cycles_self_loop_not_counted() {
let graph = ModuleGraph {
modules: vec!["a".into()],
forward: vec![vec![0]],
};
let cycles = cycles::detect_cycles(&graph);
assert!(
cycles.is_empty(),
"Self-loops should not be reported as cycles"
);
}
#[test]
fn test_cycles_two_independent_cycles() {
let graph = ModuleGraph {
modules: vec!["a".into(), "b".into(), "c".into(), "d".into()],
forward: vec![vec![1], vec![0], vec![3], vec![2]],
};
let cycles = cycles::detect_cycles(&graph);
assert_eq!(cycles.len(), 2);
}
#[test]
fn test_analyze_coupling_integration() {
let parsed = make_parsed(vec![
("main.rs", "use crate::config::Config; fn main() {}"),
("config.rs", "pub struct Config;"),
("pipeline.rs", "use crate::config::Config; pub fn run() {}"),
]);
let analysis = analyze_coupling(&parsed);
assert_eq!(analysis.metrics.len(), 3);
assert!(analysis.cycles.is_empty());
let config_metrics = analysis
.metrics
.iter()
.find(|m| m.module_name == "config")
.unwrap();
assert_eq!(config_metrics.afferent, 2);
assert_eq!(config_metrics.efferent, 0);
}
#[test]
fn test_analyze_coupling_with_cycle() {
let parsed = make_parsed(vec![
("a.rs", "use crate::b::Foo; pub struct Bar;"),
("b.rs", "use crate::a::Bar; pub struct Foo;"),
]);
let analysis = analyze_coupling(&parsed);
assert_eq!(analysis.cycles.len(), 1);
assert!(analysis.cycles[0].modules.contains(&"a".to_string()));
assert!(analysis.cycles[0].modules.contains(&"b".to_string()));
}
#[test]
fn test_analyze_coupling_no_crate_deps() {
let parsed = make_parsed(vec![
("a.rs", "use std::collections::HashMap; fn f() {}"),
("b.rs", "use serde::Deserialize; fn g() {}"),
]);
let analysis = analyze_coupling(&parsed);
assert!(analysis.cycles.is_empty());
for m in &analysis.metrics {
assert_eq!(m.afferent, 0);
assert_eq!(m.efferent, 0);
}
}
}