use crate::cli::enums::TdgOutputFormat;
use crate::models::tdg::{TDGScore, TDGSummary, TDGHotspot, TDGSeverity};
use crate::services::tdg_calculator::TDGCalculator;
use anyhow::Result;
use std::path::PathBuf;
#[allow(clippy::too_many_arguments)]
pub async fn handle_analyze_tdg(
project_path: PathBuf,
file: Option<PathBuf>,
files: Vec<PathBuf>,
threshold: f64,
top_files: usize,
format: TdgOutputFormat,
include_components: bool,
output: Option<PathBuf>,
critical_only: bool,
verbose: bool,
include: Vec<String>,
watch: bool,
) -> Result<()> {
if watch {
eprintln!("⏱️ Watch mode: Monitoring for file changes...");
eprintln!("Press Ctrl+C to stop watching");
}
eprintln!("🔍 Analyzing Technical Debt Gradient...");
let calculator = TDGCalculator::new();
let output_content = if let Some(single_file) = file {
analyze_single_file(
&calculator,
&project_path,
single_file,
threshold,
format,
include_components,
critical_only,
verbose,
).await?
} else if !files.is_empty() {
analyze_multiple_files(
&calculator,
&project_path,
files,
threshold,
top_files,
format,
include_components,
critical_only,
verbose,
).await?
} else {
analyze_project(
&calculator,
&project_path,
include,
threshold,
top_files,
format,
include_components,
critical_only,
verbose,
).await?
};
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(())
}
async fn analyze_single_file(
calculator: &TDGCalculator,
project_path: &PathBuf,
file: PathBuf,
threshold: f64,
format: TdgOutputFormat,
include_components: bool,
critical_only: bool,
verbose: bool,
) -> Result<String> {
eprintln!("📄 Analyzing TDG for file: {}", file.display());
let full_path = if file.is_absolute() {
file
} else {
project_path.join(&file)
};
if !full_path.exists() {
anyhow::bail!("File not found: {}", full_path.display());
}
let score = calculator.calculate_file(&full_path).await?;
if critical_only && score.value <= 2.5 {
return Ok(format_empty_results(format));
}
if score.value < threshold {
return Ok(format_empty_results(format));
}
format_single_file_output(&score, &full_path, format, include_components, verbose)
}
async fn analyze_multiple_files(
calculator: &TDGCalculator,
project_path: &PathBuf,
files: Vec<PathBuf>,
threshold: f64,
top_files: usize,
format: TdgOutputFormat,
include_components: bool,
critical_only: bool,
verbose: bool,
) -> Result<String> {
eprintln!("📄 Analyzing TDG for {} files...", files.len());
let mut results = Vec::new();
for file_path in files {
let full_path = if file_path.is_absolute() {
file_path
} else {
project_path.join(&file_path)
};
if !full_path.exists() {
eprintln!("⚠️ Skipping missing file: {}", full_path.display());
continue;
}
match calculator.calculate_file(&full_path).await {
Ok(score) => {
if critical_only && score.value <= 2.5 {
continue;
}
if score.value < threshold {
continue;
}
results.push((score, full_path));
}
Err(e) => {
eprintln!("⚠️ Error analyzing {}: {}", full_path.display(), e);
}
}
}
results.sort_unstable_by(|a, b| b.0.value.partial_cmp(&a.0.value).unwrap());
if top_files > 0 && results.len() > top_files {
results.truncate(top_files);
}
let summary = create_summary_from_file_results(&results);
format_output_from_summary(&summary, format, include_components, verbose)
}
async fn analyze_project(
calculator: &TDGCalculator,
project_path: &PathBuf,
_include: Vec<String>,
threshold: f64,
top_files: usize,
format: TdgOutputFormat,
include_components: bool,
critical_only: bool,
verbose: bool,
) -> Result<String> {
eprintln!("📁 Project path: {}", project_path.display());
let mut summary = calculator.analyze_directory(project_path).await?;
summary.hotspots = summary.hotspots
.into_iter()
.filter(|h| {
if critical_only {
h.tdg_score > 2.5
} else {
h.tdg_score >= threshold
}
})
.take(if top_files > 0 { top_files } else { usize::MAX })
.collect();
format_output_from_summary(&summary, format, include_components, verbose)
}
fn create_summary_from_file_results(results: &[(TDGScore, PathBuf)]) -> TDGSummary {
let total_files = results.len();
let critical_files = results.iter().filter(|(s, _)| matches!(s.severity, TDGSeverity::Critical)).count();
let warning_files = results.iter().filter(|(s, _)| matches!(s.severity, TDGSeverity::Warning)).count();
let tdg_values: Vec<f64> = results.iter().map(|(s, _)| s.value).collect();
let average_tdg = if tdg_values.is_empty() {
0.0
} else {
tdg_values.iter().sum::<f64>() / tdg_values.len() as f64
};
let mut sorted_values = tdg_values.clone();
sorted_values.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
let p95_tdg = percentile(&sorted_values, 0.95);
let p99_tdg = percentile(&sorted_values, 0.99);
let hotspots = results
.iter()
.map(|(score, path)| TDGHotspot {
path: path.display().to_string(),
tdg_score: score.value,
primary_factor: identify_primary_factor(&score.components),
estimated_hours: estimate_refactoring_hours(score.value),
})
.collect();
let estimated_debt_hours = results
.iter()
.map(|(s, _)| estimate_refactoring_hours(s.value))
.sum();
TDGSummary {
total_files,
critical_files,
warning_files,
average_tdg,
p95_tdg,
p99_tdg,
estimated_debt_hours,
hotspots,
}
}
fn format_output_from_summary(
summary: &TDGSummary,
format: TdgOutputFormat,
include_components: bool,
verbose: bool,
) -> Result<String> {
match format {
TdgOutputFormat::Table => Ok(format_table_output(summary, include_components, verbose)),
TdgOutputFormat::Json => Ok(format_json_output(summary, include_components)),
TdgOutputFormat::Markdown => Ok(format_markdown_output(summary, include_components)),
TdgOutputFormat::Sarif => Ok(format_sarif_output(summary)),
}
}
fn format_single_file_output(
score: &TDGScore,
path: &PathBuf,
format: TdgOutputFormat,
include_components: bool,
verbose: bool,
) -> Result<String> {
let hotspot = TDGHotspot {
path: path.display().to_string(),
tdg_score: score.value,
primary_factor: identify_primary_factor(&score.components),
estimated_hours: estimate_refactoring_hours(score.value),
};
let summary = TDGSummary {
total_files: 1,
critical_files: if matches!(score.severity, TDGSeverity::Critical) { 1 } else { 0 },
warning_files: if matches!(score.severity, TDGSeverity::Warning) { 1 } else { 0 },
average_tdg: score.value,
p95_tdg: score.value,
p99_tdg: score.value,
estimated_debt_hours: estimate_refactoring_hours(score.value),
hotspots: vec![hotspot],
};
format_output_from_summary(&summary, format, include_components, verbose)
}
fn format_empty_results(format: TdgOutputFormat) -> String {
match format {
TdgOutputFormat::Table => "No files found matching the specified criteria.\n".to_string(),
TdgOutputFormat::Json => r#"{"summary": {"total_files": 0}, "hotspots": []}"#.to_string(),
TdgOutputFormat::Markdown => "# Technical Debt Gradient Analysis\n\nNo files found matching the specified criteria.\n".to_string(),
TdgOutputFormat::Sarif => r#"{"version": "2.1.0", "runs": [{"tool": {"driver": {"name": "pmat-tdg"}}, "results": []}]}"#.to_string(),
}
}
fn format_table_output(summary: &TDGSummary, include_components: bool, verbose: bool) -> String {
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));
if summary.total_files > 0 {
table.push_str(&format!("🔴 **Critical Files**: {} ({:.1}%)\n",
summary.critical_files,
(summary.critical_files as f64 / summary.total_files as f64) * 100.0
));
table.push_str(&format!("🟡 **Warning Files**: {} ({:.1}%)\n",
summary.warning_files,
(summary.warning_files as f64 / summary.total_files 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 !summary.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 &summary.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
}
fn format_json_output(summary: &TDGSummary, include_components: bool) -> String {
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": summary.hotspots,
"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).unwrap_or_else(|_| "{}".to_string())
}
fn format_markdown_output(summary: &TDGSummary, include_components: bool) -> String {
let mut md = String::new();
md.push_str("# Technical Debt Gradient Analysis\n\n");
md.push_str("## Summary\n\n");
md.push_str(&format!("- **Total Files**: {}\n", summary.total_files));
if summary.total_files > 0 {
md.push_str(&format!("- **Critical Files**: {} ({:.1}%)\n",
summary.critical_files,
(summary.critical_files as f64 / summary.total_files as f64) * 100.0
));
md.push_str(&format!("- **Warning Files**: {} ({:.1}%)\n",
summary.warning_files,
(summary.warning_files as f64 / summary.total_files 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 !summary.hotspots.is_empty() {
md.push_str("## Hotspots\n\n");
for (i, hotspot) in summary.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
}
fn format_sarif_output(summary: &TDGSummary) -> String {
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": summary.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).unwrap_or_else(|_| "{}".to_string())
}
fn percentile(sorted_values: &[f64], p: f64) -> f64 {
if sorted_values.is_empty() {
return 0.0;
}
let index = (sorted_values.len() as f64 * p) as usize;
let index = index.min(sorted_values.len() - 1);
sorted_values[index]
}
fn identify_primary_factor(components: &crate::models::tdg::TDGComponents) -> String {
let mut factors = [
(components.complexity * 0.30, "High Complexity"),
(components.churn * 0.35, "Frequent Changes"),
(components.coupling * 0.15, "High Coupling"),
(components.domain_risk * 0.10, "Domain Risk"),
(components.duplication * 0.10, "Code Duplication"),
];
factors.sort_unstable_by(|a, b| b.0.partial_cmp(&a.0).unwrap());
factors[0].1.to_string()
}
fn estimate_refactoring_hours(tdg_score: f64) -> f64 {
let base_hours = 2.0;
let multiplier = 1.8;
base_hours * multiplier.powf(tdg_score)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::tdg::{TDGComponents, TDGSeverity, TDGScore, TDGSummary, TDGHotspot};
use tempfile::TempDir;
use std::fs;
#[test]
fn test_percentile_calculation() {
let values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(percentile(&values, 0.0), 1.0);
assert_eq!(percentile(&values, 0.5), 3.0);
assert_eq!(percentile(&values, 1.0), 5.0);
let empty: Vec<f64> = vec![];
assert_eq!(percentile(&empty, 0.5), 0.0);
}
#[test]
fn test_identify_primary_factor() {
let components = TDGComponents {
complexity: 0.8,
churn: 0.2,
coupling: 0.1,
domain_risk: 0.1,
duplication: 0.1,
};
assert_eq!(identify_primary_factor(&components), "High Complexity");
let components2 = TDGComponents {
complexity: 0.2,
churn: 0.9,
coupling: 0.1,
domain_risk: 0.1,
duplication: 0.1,
};
assert_eq!(identify_primary_factor(&components2), "Frequent Changes");
}
#[test]
fn test_estimate_refactoring_hours() {
let hours_0 = estimate_refactoring_hours(0.0);
assert_eq!(hours_0, 2.0);
let hours_1 = estimate_refactoring_hours(1.0);
assert!((hours_1 - 3.6).abs() < 0.01);
let hours_2 = estimate_refactoring_hours(2.0);
assert!((hours_2 - 6.48).abs() < 0.01); }
#[test]
fn test_format_empty_results() {
let json_result = format_empty_results(TdgOutputFormat::Json);
assert!(json_result.contains("\"hotspots\": []"));
assert!(json_result.contains("\"total_files\": 0"));
let csv_result = format_empty_results(TdgOutputFormat::Csv);
assert!(csv_result.contains("Path,TDG Score"));
let summary_result = format_empty_results(TdgOutputFormat::Summary);
assert!(summary_result.contains("No files"));
}
#[test]
fn test_tdg_severity_conversion() {
assert_eq!(TDGSeverity::from(0.5), TDGSeverity::Normal);
assert_eq!(TDGSeverity::from(2.0), TDGSeverity::Warning);
assert_eq!(TDGSeverity::from(3.0), TDGSeverity::Critical);
assert_eq!(TDGSeverity::Normal.as_str(), "normal");
assert_eq!(TDGSeverity::Warning.as_str(), "warning");
assert_eq!(TDGSeverity::Critical.as_str(), "critical");
}
#[test]
fn test_format_json_output() {
let summary = TDGSummary {
total_files: 2,
average_tdg: 1.5,
p95_tdg: 2.0,
p99_tdg: 2.0,
estimated_debt_hours: 10.0,
critical_files: 0,
warning_files: 1,
hotspots: vec![
TDGHotspot {
path: "file1.rs".to_string(),
tdg_score: 2.0,
primary_factor: "High Complexity".to_string(),
estimated_hours: 3.6,
},
],
};
let json = format_json_output(&summary);
assert!(json.contains("\"total_files\": 2"));
assert!(json.contains("\"average_tdg\": 1.5"));
assert!(json.contains("file1.rs"));
}
#[test]
fn test_format_csv_output() {
let summary = TDGSummary {
total_files: 1,
average_tdg: 1.5,
p95_tdg: 1.5,
p99_tdg: 1.5,
estimated_debt_hours: 5.0,
critical_files: 0,
warning_files: 1,
hotspots: vec![
TDGHotspot {
path: "test.rs".to_string(),
tdg_score: 1.5,
primary_factor: "Frequent Changes".to_string(),
estimated_hours: 2.7,
},
],
};
let csv = format_csv_output(&summary);
assert!(csv.contains("Path,TDG Score,Severity,Primary Factor,Est. Hours"));
assert!(csv.contains("test.rs,1.50,Medium,Frequent Changes,2.70"));
}
#[test]
fn test_format_summary_output() {
let summary = TDGSummary {
total_files: 10,
average_tdg: 1.2,
p95_tdg: 3.0,
p99_tdg: 3.5,
estimated_debt_hours: 50.0,
critical_files: 1,
warning_files: 2,
hotspots: vec![],
};
let output = format_summary_output(&summary, false);
assert!(output.contains("📊 Technical Debt Gradient Summary"));
assert!(output.contains("Files analyzed: 10"));
assert!(output.contains("Average TDG"));
assert!(output.contains("Critical"));
}
#[test]
fn test_format_markdown_output() {
let summary = TDGSummary {
total_files: 5,
average_tdg: 2.0,
p95_tdg: 3.5,
p99_tdg: 4.0,
estimated_debt_hours: 20.0,
critical_files: 2,
warning_files: 1,
hotspots: vec![
TDGHotspot {
path: "critical.rs".to_string(),
tdg_score: 4.0,
primary_factor: "High Complexity".to_string(),
estimated_hours: 10.0,
},
],
};
let md = format_markdown_output(&summary, true);
assert!(md.contains("# Technical Debt Gradient Report"));
assert!(md.contains("## Summary Statistics"));
assert!(md.contains("critical.rs"));
assert!(md.contains("## Component Breakdown"));
}
#[test]
fn test_format_sarif_output() {
let summary = TDGSummary {
total_files: 1,
average_tdg: 3.0,
p95_tdg: 3.0,
p99_tdg: 3.0,
estimated_debt_hours: 7.2,
critical_files: 1,
warning_files: 0,
hotspots: vec![
TDGHotspot {
path: "problem.rs".to_string(),
tdg_score: 3.0,
primary_factor: "High Coupling".to_string(),
estimated_hours: 7.2,
},
],
};
let sarif = format_sarif_output(&summary);
let parsed: serde_json::Value = serde_json::from_str(&sarif).unwrap();
assert_eq!(parsed["version"], "2.1.0");
assert!(parsed["runs"][0]["tool"]["driver"]["name"] == "pmat-tdg");
assert!(parsed["runs"][0]["results"][0]["ruleId"] == "TDG001");
assert!(parsed["runs"][0]["results"][0]["level"] == "error");
}
#[tokio::test]
async fn test_handle_analyze_tdg_single_file() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() { println!(\"test\"); }").unwrap();
let output_file = temp_dir.path().join("output.json");
let result = handle_analyze_tdg(
temp_dir.path().to_path_buf(),
Some(test_file.clone()),
vec![],
0.0, 5,
TdgOutputFormat::Json,
false,
Some(output_file.clone()),
false,
false,
vec![],
false,
).await;
assert!(result.is_ok() || result.is_err());
}
#[tokio::test]
async fn test_handle_analyze_tdg_watch_mode() {
let temp_dir = TempDir::new().unwrap();
let result = handle_analyze_tdg(
temp_dir.path().to_path_buf(),
None,
vec![],
1.0,
5,
TdgOutputFormat::Summary,
false,
None,
false,
false,
vec![],
true, ).await;
assert!(result.is_ok());
}
#[test]
fn test_format_single_file_output() {
let score = TDGScore {
value: 2.5,
severity: TDGSeverity::Warning,
components: TDGComponents {
complexity: 0.7,
churn: 0.3,
coupling: 0.2,
domain_risk: 0.1,
duplication: 0.1,
},
percentile: 90.0,
confidence: 0.95,
};
let path = PathBuf::from("test.rs");
let json_result = format_single_file_output(
&score,
&path,
TdgOutputFormat::Json,
true,
false,
);
assert!(json_result.is_ok());
let json = json_result.unwrap();
assert!(json.contains("\"value\": 2.5"));
assert!(json.contains("\"severity\": \"warning\""));
let summary_result = format_single_file_output(
&score,
&path,
TdgOutputFormat::Summary,
false,
true,
);
assert!(summary_result.is_ok());
let summary = summary_result.unwrap();
assert!(summary.contains("TDG Score: 2.50"));
}
}
#[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);
}
}
}