#![cfg_attr(coverage_nightly, coverage(off))]
use crate::cli::enums::TdgOutputFormat;
use crate::models::tdg::TDGSummary;
use crate::services::tdg_calculator::TDGCalculator;
use anyhow::Result;
use std::path::PathBuf;
#[allow(clippy::too_many_arguments)]
pub async fn handle_analyze_tdg_enhanced(
project_path: PathBuf,
file: Option<PathBuf>,
files: Vec<PathBuf>,
threshold: f64,
top: usize,
format: TdgOutputFormat,
include_components: bool,
output: Option<PathBuf>,
critical_only: bool,
verbose: bool,
_include: Vec<String>,
watch: bool,
) -> Result<()> {
if watch {
handle_watch_mode();
}
print_analysis_header(&project_path, threshold, top, &format);
let files_to_analyze = prepare_files_for_analysis(file, files);
let summary = perform_tdg_analysis(&project_path).await?;
let filtered_hotspots = filter_and_sort_hotspots(&summary, threshold, critical_only, top);
let output_content = generate_output_content(
&summary,
&filtered_hotspots,
format,
threshold,
critical_only,
include_components,
verbose,
)?;
handle_output(output, &output_content)?;
Ok(())
}
fn handle_watch_mode() {
eprintln!("⏱️ Watch mode: Monitoring for file changes...");
eprintln!("Press Ctrl+C to stop watching");
}
fn print_analysis_header(project_path: &PathBuf, threshold: f64, top: usize, format: &TdgOutputFormat) {
eprintln!("🔍 Analyzing Technical Debt Gradient...");
eprintln!("📁 Project path: {}", project_path.display());
eprintln!("📊 Threshold: {threshold}");
eprintln!("🔝 Top: {top} files");
eprintln!("📄 Format: {:?}", format);
}
fn prepare_files_for_analysis(file: Option<PathBuf>, files: Vec<PathBuf>) -> Vec<PathBuf> {
let files_to_analyze = if let Some(single_file) = file {
vec![single_file]
} else if !files.is_empty() {
files
} else {
vec![]
};
if !files_to_analyze.is_empty() {
eprintln!("📄 Analyzing {} specific file(s)", files_to_analyze.len());
}
files_to_analyze
}
async fn perform_tdg_analysis(project_path: &PathBuf) -> Result<TDGSummary> {
let calculator = TDGCalculator::new();
calculator.analyze_directory(project_path).await
}
fn filter_and_sort_hotspots(
summary: &TDGSummary,
threshold: f64,
critical_only: bool,
top: usize,
) -> Vec<crate::models::tdg::TDGHotspot> {
let mut filtered_hotspots: Vec<_> = summary.hotspots.iter()
.filter(|h| {
if critical_only {
h.tdg_score > 2.5
} else {
h.tdg_score >= threshold
}
})
.take(top)
.cloned()
.collect();
filtered_hotspots.sort_by(|a, b| b.tdg_score.total_cmp(&a.tdg_score));
filtered_hotspots
}
fn generate_output_content(
summary: &TDGSummary,
filtered_hotspots: &[crate::models::tdg::TDGHotspot],
format: TdgOutputFormat,
threshold: f64,
critical_only: bool,
include_components: bool,
verbose: bool,
) -> Result<String> {
match format {
TdgOutputFormat::Table => {
let mut table = String::new();
table.push_str("\n# Technical Debt Gradient Analysis\n\n");
table.push_str(&format!("📊 **Total Files Analyzed**: {}\n", summary.total_files));
table.push_str(&format!("🔴 **Critical Files**: {} ({:.1}%)\n",
summary.critical_files,
(summary.critical_files as f64 / summary.total_files.max(1) as f64) * 100.0
));
table.push_str(&format!("🟡 **Warning Files**: {} ({:.1}%)\n",
summary.warning_files,
(summary.warning_files as f64 / summary.total_files.max(1) as f64) * 100.0
));
table.push_str(&format!("📈 **Average TDG**: {:.2}\n", summary.average_tdg));
table.push_str(&format!("📊 **95th Percentile**: {:.2}\n", summary.p95_tdg));
table.push_str(&format!("📊 **99th Percentile**: {:.2}\n", summary.p99_tdg));
table.push_str(&format!("⏱️ **Estimated Debt**: {:.1} hours\n\n", summary.estimated_debt_hours));
if !filtered_hotspots.is_empty() {
table.push_str("## Top Hotspots\n\n");
table.push_str("| File | TDG Score | Primary Factor | Est. Hours |\n");
table.push_str("|------|-----------|----------------|------------|\n");
for hotspot in &filtered_hotspots {
table.push_str(&format!(
"| {} | {:.2} | {} | {:.1} |\n",
hotspot.path,
hotspot.tdg_score,
hotspot.primary_factor,
hotspot.estimated_hours
));
}
}
if include_components && verbose {
table.push_str("\n## Component Weights\n\n");
table.push_str("| Component | Weight |\n");
table.push_str("|-----------|--------|\n");
table.push_str("| Complexity | 30% |\n");
table.push_str("| Code Churn | 35% |\n");
table.push_str("| Coupling | 15% |\n");
table.push_str("| Domain Risk | 10% |\n");
table.push_str("| Duplication | 10% |\n");
}
table
}
TdgOutputFormat::Json => {
let json_output = serde_json::json!({
"summary": {
"total_files": summary.total_files,
"critical_files": summary.critical_files,
"warning_files": summary.warning_files,
"average_tdg": summary.average_tdg,
"p95_tdg": summary.p95_tdg,
"p99_tdg": summary.p99_tdg,
"estimated_debt_hours": summary.estimated_debt_hours,
},
"hotspots": filtered_hotspots,
"threshold": threshold,
"critical_only": critical_only,
"components": if include_components {
Some(serde_json::json!({
"complexity_weight": 0.30,
"churn_weight": 0.35,
"coupling_weight": 0.15,
"domain_risk_weight": 0.10,
"duplication_weight": 0.10,
}))
} else {
None
}
});
serde_json::to_string_pretty(&json_output)?
}
TdgOutputFormat::Markdown => {
let mut md = String::new();
md.push_str("# Technical Debt Gradient Analysis\n\n");
md.push_str(&format!("**Project**: {}\n\n", project_path.display()));
md.push_str("## Summary\n\n");
md.push_str(&format!("- **Total Files**: {}\n", summary.total_files));
md.push_str(&format!("- **Critical Files**: {} ({:.1}%)\n",
summary.critical_files,
(summary.critical_files as f64 / summary.total_files.max(1) as f64) * 100.0
));
md.push_str(&format!("- **Warning Files**: {} ({:.1}%)\n",
summary.warning_files,
(summary.warning_files as f64 / summary.total_files.max(1) as f64) * 100.0
));
md.push_str(&format!("- **Average TDG**: {:.2}\n", summary.average_tdg));
md.push_str(&format!("- **95th Percentile**: {:.2}\n", summary.p95_tdg));
md.push_str(&format!("- **99th Percentile**: {:.2}\n", summary.p99_tdg));
md.push_str(&format!("- **Estimated Technical Debt**: {:.1} hours\n\n", summary.estimated_debt_hours));
if !filtered_hotspots.is_empty() {
md.push_str("## Hotspots\n\n");
for (i, hotspot) in filtered_hotspots.iter().enumerate() {
md.push_str(&format!("### {}. {}\n\n", i + 1, hotspot.path));
md.push_str(&format!("- **TDG Score**: {:.2}\n", hotspot.tdg_score));
md.push_str(&format!("- **Primary Factor**: {}\n", hotspot.primary_factor));
md.push_str(&format!("- **Estimated Refactoring Time**: {:.1} hours\n\n", hotspot.estimated_hours));
}
}
if include_components {
md.push_str("## TDG Components\n\n");
md.push_str("The Technical Debt Gradient is calculated using the following weighted components:\n\n");
md.push_str("- **Complexity** (30%): Cyclomatic and cognitive complexity\n");
md.push_str("- **Code Churn** (35%): Frequency of changes over time\n");
md.push_str("- **Coupling** (15%): Dependencies between modules\n");
md.push_str("- **Domain Risk** (10%): Critical domain areas (auth, crypto, etc.)\n");
md.push_str("- **Duplication** (10%): Code duplication percentage\n");
}
md
}
TdgOutputFormat::Sarif => {
let sarif = serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "pmat-tdg",
"informationUri": "https://github.com/paiml/paiml-mcp-agent-toolkit",
"version": env!("CARGO_PKG_VERSION"),
"rules": [{
"id": "TDG001",
"name": "HighTechnicalDebtGradient",
"shortDescription": {
"text": "File has high technical debt gradient"
},
"fullDescription": {
"text": "Technical Debt Gradient exceeds threshold, indicating accumulated technical debt"
},
"help": {
"text": "Consider refactoring to reduce complexity, stabilize churn, or reduce coupling"
}
}]
}
},
"results": filtered_hotspots.iter().map(|hotspot| {
serde_json::json!({
"ruleId": "TDG001",
"level": if hotspot.tdg_score > 2.5 { "error" } else { "warning" },
"message": {
"text": format!("TDG score {:.2} - Primary factor: {}",
hotspot.tdg_score, hotspot.primary_factor)
},
"locations": [{
"physicalLocation": {
"artifactLocation": {
"uri": hotspot.path.clone()
}
}
}],
"properties": {
"tdg_score": hotspot.tdg_score,
"primary_factor": &hotspot.primary_factor,
"estimated_hours": hotspot.estimated_hours
}
})
}).collect::<Vec<_>>()
}]
});
serde_json::to_string_pretty(&sarif)?
}
}
}
async fn handle_output(output: Option<PathBuf>, output_content: &str) -> Result<()> {
if let Some(output_path) = output {
tokio::fs::write(&output_path, output_content).await?;
eprintln!("📝 Results written to {}", output_path.display());
} else {
println!("{output_content}");
}
eprintln!("✅ TDG analysis complete");
Ok(())
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
use super::*;
use crate::models::tdg::TDGHotspot;
#[test]
fn test_prepare_files_for_analysis_single_file() {
let file = Some(PathBuf::from("test.rs"));
let files: Vec<PathBuf> = vec![];
let result = prepare_files_for_analysis(file, files);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("test.rs"));
}
#[test]
fn test_prepare_files_for_analysis_multiple_files() {
let file = None;
let files = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
let result = prepare_files_for_analysis(file, files);
assert_eq!(result.len(), 2);
}
#[test]
fn test_prepare_files_for_analysis_no_files() {
let file = None;
let files: Vec<PathBuf> = vec![];
let result = prepare_files_for_analysis(file, files);
assert!(result.is_empty());
}
#[test]
fn test_prepare_files_for_analysis_single_overrides_multiple() {
let file = Some(PathBuf::from("single.rs"));
let files = vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")];
let result = prepare_files_for_analysis(file, files);
assert_eq!(result.len(), 1);
assert_eq!(result[0], PathBuf::from("single.rs"));
}
#[test]
fn test_filter_and_sort_hotspots_by_threshold() {
let summary = TDGSummary {
total_files: 3,
critical_files: 1,
warning_files: 1,
average_tdg: 1.5,
p95_tdg: 2.5,
p99_tdg: 3.0,
estimated_debt_hours: 10.0,
hotspots: vec![
TDGHotspot {
path: "high.rs".to_string(),
tdg_score: 3.0,
primary_factor: "complexity".to_string(),
estimated_hours: 5.0,
breakdown: Default::default(),
},
TDGHotspot {
path: "medium.rs".to_string(),
tdg_score: 1.5,
primary_factor: "churn".to_string(),
estimated_hours: 2.0,
breakdown: Default::default(),
},
TDGHotspot {
path: "low.rs".to_string(),
tdg_score: 0.5,
primary_factor: "coupling".to_string(),
estimated_hours: 1.0,
breakdown: Default::default(),
},
],
};
let result = filter_and_sort_hotspots(&summary, 1.0, false, 10);
assert_eq!(result.len(), 2); assert_eq!(result[0].path, "high.rs"); assert_eq!(result[1].path, "medium.rs");
}
#[test]
fn test_filter_and_sort_hotspots_critical_only() {
let summary = TDGSummary {
total_files: 2,
critical_files: 1,
warning_files: 0,
average_tdg: 2.0,
p95_tdg: 3.0,
p99_tdg: 3.5,
estimated_debt_hours: 8.0,
hotspots: vec![
TDGHotspot {
path: "critical.rs".to_string(),
tdg_score: 3.0,
primary_factor: "complexity".to_string(),
estimated_hours: 5.0,
breakdown: Default::default(),
},
TDGHotspot {
path: "warning.rs".to_string(),
tdg_score: 2.0,
primary_factor: "churn".to_string(),
estimated_hours: 3.0,
breakdown: Default::default(),
},
],
};
let result = filter_and_sort_hotspots(&summary, 0.0, true, 10);
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "critical.rs");
}
#[test]
fn test_filter_and_sort_hotspots_top_limit() {
let summary = TDGSummary {
total_files: 5,
critical_files: 3,
warning_files: 2,
average_tdg: 2.0,
p95_tdg: 3.0,
p99_tdg: 3.5,
estimated_debt_hours: 20.0,
hotspots: vec![
TDGHotspot {
path: "a.rs".to_string(),
tdg_score: 3.0,
primary_factor: "a".to_string(),
estimated_hours: 1.0,
breakdown: Default::default(),
},
TDGHotspot {
path: "b.rs".to_string(),
tdg_score: 2.8,
primary_factor: "b".to_string(),
estimated_hours: 1.0,
breakdown: Default::default(),
},
TDGHotspot {
path: "c.rs".to_string(),
tdg_score: 2.6,
primary_factor: "c".to_string(),
estimated_hours: 1.0,
breakdown: Default::default(),
},
],
};
let result = filter_and_sort_hotspots(&summary, 0.0, false, 2);
assert_eq!(result.len(), 2);
}
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod property_tests {
use proptest::prelude::*;
proptest! {
#[test]
fn basic_property_stability(_input in ".*") {
prop_assert!(true);
}
#[test]
fn module_consistency_check(_x in 0u32..1000) {
prop_assert!(_x < 1001);
}
}
}