use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::ast::extract::extract_file;
use crate::error::TldrError;
use crate::types::{Language, ModuleInfo};
use crate::TldrResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MetricsHealth {
Healthy,
Warning,
Unhealthy,
Isolated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Zone {
Healthy,
WarningLow,
PainZone,
UselessnessZone,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageMetrics {
pub name: String,
pub path: PathBuf,
pub ca: usize,
pub ce: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub instability: Option<f64>,
pub abstractness: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub distance: Option<f64>,
pub total_types: usize,
pub abstract_types: usize,
pub incoming_packages: Vec<String>,
pub outgoing_packages: Vec<String>,
pub health: MetricsHealth,
#[serde(skip_serializing_if = "Option::is_none")]
pub zone: Option<Zone>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsSummary {
pub total_packages: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_instability: Option<f64>,
pub avg_abstractness: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_distance: Option<f64>,
pub healthy_count: usize,
pub warning_count: usize,
pub unhealthy_count: usize,
pub isolated_count: usize,
}
impl Default for MetricsSummary {
fn default() -> Self {
Self {
total_packages: 0,
avg_instability: None,
avg_abstractness: 0.0,
avg_distance: None,
healthy_count: 0,
warning_count: 0,
unhealthy_count: 0,
isolated_count: 0,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MetricsProblems {
pub zone_of_pain: Vec<String>,
pub zone_of_uselessness: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MartinReport {
pub packages_analyzed: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_instability: Option<f64>,
pub avg_abstractness: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub avg_distance: Option<f64>,
pub packages_in_pain_zone: usize,
pub packages_in_uselessness_zone: usize,
pub packages: Vec<PackageMetrics>,
pub summary: MetricsSummary,
pub problems: MetricsProblems,
}
impl Default for MartinReport {
fn default() -> Self {
Self {
packages_analyzed: 0,
avg_instability: None,
avg_abstractness: 0.0,
avg_distance: None,
packages_in_pain_zone: 0,
packages_in_uselessness_zone: 0,
packages: Vec::new(),
summary: MetricsSummary::default(),
problems: MetricsProblems::default(),
}
}
}
pub fn compute_martin_metrics(path: &Path, language: Option<Language>) -> TldrResult<MartinReport> {
let lang = match language {
Some(l) => l,
None => detect_language(path)?,
};
let packages_info = collect_packages(path, lang)?;
if packages_info.is_empty() {
return Ok(MartinReport::default());
}
let (dependencies, reverse_deps) = build_dependency_graph(&packages_info);
let mut packages: Vec<PackageMetrics> = Vec::new();
let mut problems = MetricsProblems::default();
for (pkg_name, pkg_info) in &packages_info {
let outgoing: HashSet<&String> = dependencies
.get(pkg_name)
.map(|s| s.iter().collect())
.unwrap_or_default();
let incoming: HashSet<&String> = reverse_deps
.get(pkg_name)
.map(|s| s.iter().collect())
.unwrap_or_default();
let ca = incoming.len();
let ce = outgoing.len();
let instability = compute_instability(ca, ce);
let abstractness = if pkg_info.total_types > 0 {
pkg_info.abstract_types as f64 / pkg_info.total_types as f64
} else {
0.0
};
let distance = instability.map(|i| (abstractness + i - 1.0).abs());
let health = match (instability, distance) {
(None, None) => MetricsHealth::Isolated,
(_, Some(d)) if d <= 0.2 => MetricsHealth::Healthy,
(_, Some(d)) if d <= 0.4 => MetricsHealth::Warning,
_ => MetricsHealth::Unhealthy,
};
let zone = detect_zone(instability, abstractness, distance);
if zone == Some(Zone::PainZone) {
problems.zone_of_pain.push(pkg_name.clone());
} else if zone == Some(Zone::UselessnessZone) {
problems.zone_of_uselessness.push(pkg_name.clone());
}
packages.push(PackageMetrics {
name: pkg_name.clone(),
path: pkg_info.path.clone(),
ca,
ce,
instability,
abstractness,
distance,
total_types: pkg_info.total_types,
abstract_types: pkg_info.abstract_types,
incoming_packages: incoming.iter().map(|s| (*s).clone()).collect(),
outgoing_packages: outgoing.iter().map(|s| (*s).clone()).collect(),
health,
zone,
});
}
packages.sort_by(|a, b| {
let da = a.distance.unwrap_or(f64::MAX);
let db = b.distance.unwrap_or(f64::MAX);
db.partial_cmp(&da)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
});
let total_packages = packages.len();
let (instability_sum, instability_count): (f64, usize) = packages
.iter()
.filter_map(|p| p.instability)
.fold((0.0, 0), |(sum, count), i| (sum + i, count + 1));
let avg_instability = if instability_count > 0 {
Some(instability_sum / instability_count as f64)
} else {
None
};
let avg_abstractness = if total_packages > 0 {
packages.iter().map(|p| p.abstractness).sum::<f64>() / total_packages as f64
} else {
0.0
};
let (distance_sum, distance_count): (f64, usize) = packages
.iter()
.filter_map(|p| p.distance)
.fold((0.0, 0), |(sum, count), d| (sum + d, count + 1));
let avg_distance = if distance_count > 0 {
Some(distance_sum / distance_count as f64)
} else {
None
};
let healthy_count = packages
.iter()
.filter(|p| p.health == MetricsHealth::Healthy)
.count();
let warning_count = packages
.iter()
.filter(|p| p.health == MetricsHealth::Warning)
.count();
let unhealthy_count = packages
.iter()
.filter(|p| p.health == MetricsHealth::Unhealthy)
.count();
let isolated_count = packages
.iter()
.filter(|p| p.health == MetricsHealth::Isolated)
.count();
let packages_in_pain_zone = problems.zone_of_pain.len();
let packages_in_uselessness_zone = problems.zone_of_uselessness.len();
Ok(MartinReport {
packages_analyzed: total_packages,
avg_instability,
avg_abstractness,
avg_distance,
packages_in_pain_zone,
packages_in_uselessness_zone,
packages,
summary: MetricsSummary {
total_packages,
avg_instability,
avg_abstractness,
avg_distance,
healthy_count,
warning_count,
unhealthy_count,
isolated_count,
},
problems,
})
}
struct PackageInfo {
path: PathBuf,
total_types: usize,
abstract_types: usize,
imports: HashSet<String>,
}
fn compute_instability(ca: usize, ce: usize) -> Option<f64> {
let total = ca + ce;
if total == 0 {
None } else {
Some(ce as f64 / total as f64)
}
}
fn detect_zone(instability: Option<f64>, abstractness: f64, distance: Option<f64>) -> Option<Zone> {
let i = instability?;
let d = distance?;
if i < 0.3 && abstractness < 0.3 && d > 0.5 {
return Some(Zone::PainZone);
}
if i > 0.7 && abstractness > 0.7 {
return Some(Zone::UselessnessZone);
}
if d <= 0.2 {
Some(Zone::Healthy)
} else if d <= 0.5 {
Some(Zone::WarningLow)
} else {
None
}
}
fn collect_packages(path: &Path, language: Language) -> TldrResult<IndexMap<String, PackageInfo>> {
let mut packages: IndexMap<String, PackageInfo> = IndexMap::new();
let extensions: &[&str] = language.extensions();
for entry in WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let file_path = entry.path();
if !file_path.is_file() {
continue;
}
if !matches!(
file_path.extension().and_then(|e| e.to_str()),
Some(ext) if extensions.contains(&ext)
) {
continue;
}
let pkg_name = get_package_name(file_path, path);
let info = match extract_file(file_path, Some(path)) {
Ok(info) => info,
Err(_) => continue, };
let pkg_info = packages
.entry(pkg_name.clone())
.or_insert_with(|| PackageInfo {
path: file_path.parent().unwrap_or(path).to_path_buf(),
total_types: 0,
abstract_types: 0,
imports: HashSet::new(),
});
pkg_info.total_types += info.classes.len();
for class in &info.classes {
if is_abstract_type(&class.name, &info, language) {
pkg_info.abstract_types += 1;
}
}
for import in &info.imports {
if let Some(pkg) = normalize_import(&import.module, language) {
if pkg != pkg_name {
pkg_info.imports.insert(pkg);
}
}
}
}
Ok(packages)
}
fn get_package_name(file_path: &Path, root: &Path) -> String {
let parent = file_path.parent().unwrap_or(file_path);
let relative = parent.strip_prefix(root).unwrap_or(parent);
if relative.as_os_str().is_empty() {
"<root>".to_string()
} else {
relative
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, ".")
}
}
fn normalize_import(module_path: &str, language: Language) -> Option<String> {
match language {
Language::Python => {
let parts: Vec<&str> = module_path.split('.').collect();
if parts.len() > 1 {
Some(parts[..parts.len() - 1].join("."))
} else {
Some(module_path.to_string())
}
}
Language::TypeScript | Language::JavaScript => {
if module_path.starts_with('@') {
let parts: Vec<&str> = module_path.splitn(3, '/').collect();
if parts.len() >= 2 {
Some(format!("{}/{}", parts[0], parts[1]))
} else {
Some(module_path.to_string())
}
} else if module_path.starts_with('.') {
None } else {
let parts: Vec<&str> = module_path.splitn(2, '/').collect();
Some(parts[0].to_string())
}
}
Language::Go => {
let parts: Vec<&str> = module_path.splitn(4, '/').collect();
if parts.len() >= 3 {
Some(format!("{}/{}/{}", parts[0], parts[1], parts[2]))
} else {
Some(module_path.to_string())
}
}
Language::Rust => {
let parts: Vec<&str> = module_path.split("::").collect();
if parts.len() > 1 {
Some(parts[..parts.len() - 1].join("::"))
} else {
Some(module_path.to_string())
}
}
_ => Some(module_path.to_string()),
}
}
fn is_abstract_type(class_name: &str, info: &ModuleInfo, language: Language) -> bool {
match language {
Language::Python => {
if class_name.ends_with("ABC")
|| class_name.ends_with("Base")
|| class_name.ends_with("Interface")
|| class_name.ends_with("Protocol")
{
return true;
}
for import in &info.imports {
if import.module == "abc" || import.module.ends_with(".abc") {
return true;
}
}
false
}
Language::TypeScript | Language::JavaScript => {
class_name.starts_with('I')
&& class_name
.chars()
.nth(1)
.map(|c| c.is_uppercase())
.unwrap_or(false)
|| class_name.ends_with("Interface")
}
Language::Go => {
class_name.ends_with("er") || class_name.ends_with("Interface")
}
Language::Rust => {
class_name.ends_with("Trait") || class_name.starts_with("dyn ")
}
_ => false,
}
}
fn build_dependency_graph(
packages: &IndexMap<String, PackageInfo>,
) -> (
HashMap<String, HashSet<String>>,
HashMap<String, HashSet<String>>,
) {
let mut dependencies: HashMap<String, HashSet<String>> = HashMap::new();
let mut reverse_deps: HashMap<String, HashSet<String>> = HashMap::new();
let package_names: HashSet<&String> = packages.keys().collect();
for (pkg_name, pkg_info) in packages {
for import in &pkg_info.imports {
let is_internal = package_names.iter().any(|&p| {
p == import
|| p.starts_with(&format!("{}.", import))
|| import.starts_with(&format!("{}.", p))
});
if is_internal || package_names.contains(import) {
dependencies
.entry(pkg_name.clone())
.or_default()
.insert(import.clone());
reverse_deps
.entry(import.clone())
.or_default()
.insert(pkg_name.clone());
}
}
}
(dependencies, reverse_deps)
}
fn detect_language(path: &Path) -> TldrResult<Language> {
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
return Language::from_extension(ext)
.ok_or_else(|| TldrError::UnsupportedLanguage(ext.to_string()));
}
}
let mut counts: HashMap<Language, usize> = HashMap::new();
for entry in WalkDir::new(path)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) {
if let Some(lang) = Language::from_extension(ext) {
*counts.entry(lang).or_default() += 1;
}
}
}
}
counts
.into_iter()
.max_by_key(|(_, count)| *count)
.map(|(lang, _)| lang)
.ok_or_else(|| TldrError::NoSupportedFiles(path.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_instability() {
assert_eq!(compute_instability(0, 10), Some(1.0)); assert_eq!(compute_instability(10, 0), Some(0.0)); assert_eq!(compute_instability(5, 5), Some(0.5));
assert_eq!(compute_instability(0, 0), None);
}
#[test]
fn test_detect_zone() {
let zone = detect_zone(Some(0.1), 0.1, Some(0.8));
assert_eq!(zone, Some(Zone::PainZone));
let zone = detect_zone(Some(0.8), 0.8, Some(0.6));
assert_eq!(zone, Some(Zone::UselessnessZone));
let zone = detect_zone(Some(0.5), 0.5, Some(0.0));
assert_eq!(zone, Some(Zone::Healthy));
let zone = detect_zone(None, 0.5, None);
assert_eq!(zone, None);
}
#[test]
fn test_metrics_health_from_distance() {
assert_eq!(
if 0.1_f64 <= 0.2 {
MetricsHealth::Healthy
} else {
MetricsHealth::Warning
},
MetricsHealth::Healthy
);
assert_eq!(
if 0.3_f64 <= 0.2 {
MetricsHealth::Healthy
} else if 0.3_f64 <= 0.4 {
MetricsHealth::Warning
} else {
MetricsHealth::Unhealthy
},
MetricsHealth::Warning
);
assert_eq!(
MetricsHealth::Unhealthy,
MetricsHealth::Unhealthy
);
}
#[test]
fn test_martin_report_default() {
let report = MartinReport::default();
assert_eq!(report.packages_analyzed, 0);
assert!(report.avg_instability.is_none());
assert_eq!(report.avg_abstractness, 0.0);
assert!(report.avg_distance.is_none());
}
#[test]
fn test_get_package_name() {
let root = Path::new("/project");
assert_eq!(
get_package_name(Path::new("/project/main.py"), root),
"<root>"
);
assert_eq!(
get_package_name(Path::new("/project/src/utils.py"), root),
"src"
);
assert_eq!(
get_package_name(Path::new("/project/src/core/module.py"), root),
"src.core"
);
}
}