From cfdd491923ae01d29d52741972523a023c324c37 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Thu, 11 Jun 2026 00:22:43 +0700 Subject: [PATCH 1/3] feat: add `cora debt` subcommand (Phase 2 of #206) - New src/commands/debt.rs with `cora debt` CLI subcommand - `--json` flag for machine-readable output - `--trend` flag for ASCII quality score graph - `--since ` filter by date or git tag - `--branch ` filter by branch - Terminal-formatted debt report table with severity breakdown - Category breakdown with trend indicators (improving/stable/worsening) - Quality score with color-coded display - Auto-cleanup of old snapshots on each run - All 485 tests pass, clippy clean, fmt clean --- src/commands/debt.rs | 382 +++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 35 +++- 3 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/commands/debt.rs diff --git a/src/commands/debt.rs b/src/commands/debt.rs new file mode 100644 index 00000000..38cda09d --- /dev/null +++ b/src/commands/debt.rs @@ -0,0 +1,382 @@ +//! `cora debt` subcommand — show tech debt report from review history. + +use anyhow::Result; +use chrono::DateTime; +use colored::Colorize; +use std::io::Write; + +use crate::engine::debt_tracker; + +/// Exit codes. +const EXIT_OK: i32 = 0; +const EXIT_NO_DATA: i32 = 1; + +/// Debt subcommand options. +pub struct DebtOptions { + /// Output as JSON. + pub json: bool, + /// Show ASCII trend graph. + pub trend: bool, + /// Filter: only snapshots since this git tag or date (YYYY-MM-DD). + pub since: Option, + /// Filter: only snapshots for this branch. + pub branch: Option, +} + +/// Execute the `cora debt` subcommand. +pub fn execute_debt(opts: &DebtOptions) -> Result { + let config = crate::config::loader::load_config(None, None, None, None, None, false)?; + + if !config.debt.enabled { + println!( + "{}", + "Debt tracking is disabled. Enable it in .cora.yaml:".yellow() + ); + println!(" debt:"); + println!(" enabled: true"); + return Ok(EXIT_OK); + } + + let mut snapshots = debt_tracker::load_snapshots(config.debt.history_dir.as_deref()); + + if snapshots.is_empty() { + println!( + "{}", + "No review history found. Run `cora review` to build history.".yellow() + ); + return Ok(EXIT_NO_DATA); + } + + // Apply filters + if let Some(ref since) = opts.since { + snapshots = filter_since(&snapshots, since); + } + if let Some(ref branch) = opts.branch { + snapshots.retain(|s| s.branch.as_deref() == Some(branch.as_str())); + } + + if snapshots.is_empty() { + println!("{}", "No snapshots match the given filters.".yellow()); + return Ok(EXIT_NO_DATA); + } + + // Cleanup old snapshots (best-effort) + let _ = debt_tracker::cleanup_old_snapshots( + config.debt.history_dir.as_deref(), + config.debt.retention_days, + ); + + let report = debt_tracker::aggregate(&snapshots); + + if opts.json { + let json = serde_json::to_string_pretty(&report)?; + println!("{json}"); + return Ok(EXIT_OK); + } + + if opts.trend { + print_trend_graph(&snapshots); + println!(); + } + + print_debt_table(&report); + + Ok(EXIT_OK) +} + +/// Filter snapshots since a date or git tag. +fn filter_since( + snapshots: &[debt_tracker::DebtSnapshot], + since: &str, +) -> Vec { + // Try parsing as date first (YYYY-MM-DD) + if let Ok(date) = DateTime::parse_from_rfc3339(&format!("{since}T00:00:00Z")) { + let cutoff = date.with_timezone(&chrono::Utc); + return snapshots + .iter() + .filter(|s| s.timestamp >= cutoff) + .cloned() + .collect(); + } + + // Try as git tag — resolve to commit date + let tag_date = resolve_tag_date(since); + if let Some(cutoff) = tag_date { + return snapshots + .iter() + .filter(|s| s.timestamp >= cutoff) + .cloned() + .collect(); + } + + // Fallback: return all + eprintln!( + "{}", + format!("Warning: could not resolve '{since}' as date or git tag. Showing all snapshots.") + .yellow() + ); + snapshots.to_vec() +} + +/// Resolve a git tag to its commit timestamp. +fn resolve_tag_date(tag: &str) -> Option> { + let output = std::process::Command::new("git") + .args(["log", "-1", "--format=%cI", tag]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let date_str = String::from_utf8(output.stdout).ok()?; + let date_str = date_str.trim(); + + DateTime::parse_from_rfc3339(date_str) + .map(|d| d.with_timezone(&chrono::Utc)) + .ok() +} + +/// Print the debt report as a terminal table. +fn print_debt_table(report: &debt_tracker::DebtReport) { + let mut out = Vec::new(); + + // Header + writeln!( + out, + "\n{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed() + ) + .unwrap(); + writeln!(out, " {}", "TECH DEBT REPORT".cyan().bold()).unwrap(); + writeln!( + out, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed() + ) + .unwrap(); + + // Period + let period = match (report.period_start, report.period_end) { + (Some(s), Some(e)) => format!("{} to {}", s.format("%Y-%m-%d"), e.format("%Y-%m-%d")), + _ => "N/A".to_string(), + }; + writeln!(out, " Period: {}", period.white()).unwrap(); + writeln!( + out, + " Reviews: {}", + report.reviews_analyzed.to_string().white() + ) + .unwrap(); + + // Overall trend + let trend_sym = trend_icon(&report.trend); + let trend_colored = color_trend(&report.trend, &format!("{} {}", trend_sym, report.trend)); + writeln!(out, " Trend: {}", trend_colored).unwrap(); + + // Quality score + let score_colored = format_score(report.quality_score_avg); + let score_change = format_change_f64(report.quality_score_change); + writeln!(out, " Quality: {} {}", score_colored, score_change).unwrap(); + + // Total findings + let change_str = format_change_i64(report.change_from_previous); + writeln!( + out, + " Findings: {} {}", + report.total_findings.to_string().white(), + change_str + ) + .unwrap(); + + // Severity breakdown + writeln!(out).unwrap(); + writeln!(out, " {}", "Severity Breakdown:".white().bold()).unwrap(); + for sev in &["critical", "major", "minor", "info"] { + let count = report.findings.get(*sev).copied().unwrap_or(0); + let label = match *sev { + "critical" => "critical".red().to_string(), + "major" => "major".yellow().to_string(), + "minor" => "minor".blue().to_string(), + _ => "info".to_string(), + }; + writeln!(out, " {:<12} {}", label, count).unwrap(); + } + + // Category breakdown + if !report.categories.is_empty() { + writeln!(out).unwrap(); + writeln!( + out, + " {:<16} {:>8} {:>10} {}", + "Category".white().bold(), + "Total".white().bold(), + "Change".white().bold(), + "Trend".white().bold() + ) + .unwrap(); + writeln!( + out, + " {}", + "─────────────────────────────────────────────────".dimmed() + ) + .unwrap(); + + for cat in &report.categories { + let change = format_change_i64(cat.change); + let trend = format!("{} {}", trend_icon(&cat.trend), cat.trend); + let trend_colored = color_trend(&cat.trend, &trend); + writeln!( + out, + " {:<16} {:>8} {:>10} {}", + cat.name, cat.count, change, trend_colored + ) + .unwrap(); + } + } + + writeln!( + out, + "{}", + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".dimmed() + ) + .unwrap(); + + let output = String::from_utf8(out).unwrap_or_default(); + print!("{output}"); +} + +/// Print ASCII trend graph of quality scores over reviews. +fn print_trend_graph(snapshots: &[debt_tracker::DebtSnapshot]) { + if snapshots.len() < 2 { + println!( + "\n{}", + "Need at least 2 reviews to show a trend graph.".yellow() + ); + return; + } + + let scores: Vec = snapshots.iter().map(|s| s.quality_score).collect(); + let max_score = 10.0; + let min_score = 0.0; + + // Graph dimensions + let height = 8; + let width = scores.len().min(40); + + let step = if scores.len() > width { + scores.len() / width + } else { + 1 + }; + + let sampled: Vec = (0..width) + .map(|i| { + let idx = (i * step).min(scores.len() - 1); + scores[idx] + }) + .collect(); + + println!("\n{}", "Quality Score Trend".cyan().bold()); + println!(); + + for row in (0..height).rev() { + let threshold = min_score + (max_score - min_score) * (row as f64 / (height - 1) as f64); + let label = format!("{:.1}", threshold); + let mut line = format!("{} ┤", label.dimmed()); + + for (i, score) in sampled.iter().enumerate() { + let bar_threshold_low = + min_score + (max_score - min_score) * (row as f64 / (height - 1) as f64); + let bar_threshold_high = + min_score + (max_score - min_score) * ((row + 1) as f64 / (height - 1) as f64); + + if (*score >= bar_threshold_low && *score < bar_threshold_high) + || (row == 0 && (*score - max_score).abs() < f64::EPSILON) + { + line.push_str(&"●".green().to_string()); + } else if i > 0 { + let prev = sampled[i - 1]; + if *score >= bar_threshold_low && prev < bar_threshold_low { + line.push_str(&"●".green().to_string()); + } else { + line.push_str(" "); + } + } else { + line.push_str(" "); + } + } + + println!("{line}"); + } + + // X-axis + print!(" └"); + for _ in 0..width { + print!("──"); + } + println!(); + + print!(" "); + // Show review indices + let labels = ["R1"]; + for (i, l) in labels.iter().enumerate() { + print!("{}", l.dimmed()); + if i < width - 1 { + let gap = (width - 1).max(1); + for _ in 0..(gap * 2 / 3).max(1) { + print!(" "); + } + } + } + let last = format!("R{}", snapshots.len()); + println!(" {}", last.dimmed()); +} + +// ─── Formatting helpers ─── + +fn trend_icon(trend: &str) -> &'static str { + match trend { + "improving" => "▼", + "worsening" => "▲", + _ => "►", + } +} + +fn color_trend(trend: &str, text: &str) -> String { + match trend { + "improving" => text.green().to_string(), + "worsening" => text.red().to_string(), + _ => text.dimmed().to_string(), + } +} + +fn format_score(score: f64) -> String { + if score >= 8.0 { + format!("{:.1}/10", score).green().to_string() + } else if score >= 5.0 { + format!("{:.1}/10", score).yellow().to_string() + } else { + format!("{:.1}/10", score).red().to_string() + } +} + +fn format_change_i64(change: i64) -> String { + if change > 0 { + format!("(+{change})").red().to_string() + } else if change < 0 { + format!("({change})").green().to_string() + } else { + "(±0)".dimmed().to_string() + } +} + +fn format_change_f64(change: f64) -> String { + if change > 0.1 { + format!("(+{:.1})", change).green().to_string() + } else if change < -0.1 { + format!("({:.1})", change).red().to_string() + } else { + "(±0.0)".dimmed().to_string() + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a12ce518..d568b31f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod completion; pub mod config_cmd; +pub mod debt; pub mod hook_cmd; pub mod init; pub mod profile; diff --git a/src/main.rs b/src/main.rs index 2d03432a..77d46a3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ mod mcp; mod progress; use commands::{ - auth, completion, config_cmd, hook_cmd, init, profile, providers, review, scan, upload, + auth, completion, config_cmd, debt, hook_cmd, init, profile, providers, review, scan, upload, }; use config::loader; use formatters::OutputFormat; @@ -249,6 +249,25 @@ enum Command { /// Start MCP server (Model Context Protocol for AI agents) Mcp, + + /// Show tech debt report from review history + Debt { + /// Output as JSON + #[clap(long)] + json: bool, + + /// Show quality score trend graph + #[clap(long)] + trend: bool, + + /// Only show data since a date (YYYY-MM-DD) or git tag + #[clap(long)] + since: Option, + + /// Filter by branch name + #[clap(long)] + branch: Option, + }, } #[derive(Subcommand, Debug)] @@ -509,6 +528,20 @@ async fn main() -> Result<()> { mcp::server::run_server()?; 0 } + Command::Debt { + json, + trend, + since, + branch, + } => { + let opts = debt::DebtOptions { + json, + trend, + since, + branch, + }; + debt::execute_debt(&opts)? + } }; std::process::exit(exit_code); From c6059423c97979b3b2a2b2a7069145e15d868cfe Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Thu, 11 Jun 2026 00:23:41 +0700 Subject: [PATCH 2/3] fix: correct date parsing and trend graph edge case in cora debt - Use NaiveDate::parse_from_str for YYYY-MM-DD (was RFC3339 which rejects plain dates) - Fix trend graph: top row check uses height-1 instead of row==0 - All 485 tests pass, clippy clean --- src/commands/debt.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/commands/debt.rs b/src/commands/debt.rs index 38cda09d..f74f10e1 100644 --- a/src/commands/debt.rs +++ b/src/commands/debt.rs @@ -89,8 +89,18 @@ fn filter_since( snapshots: &[debt_tracker::DebtSnapshot], since: &str, ) -> Vec { - // Try parsing as date first (YYYY-MM-DD) - if let Ok(date) = DateTime::parse_from_rfc3339(&format!("{since}T00:00:00Z")) { + // Try parsing as date first (YYYY-MM-DD or full ISO 8601) + if let Ok(date) = chrono::NaiveDate::parse_from_str(since, "%Y-%m-%d") { + let cutoff = date.and_hms_opt(0, 0, 0).unwrap().and_utc(); + return snapshots + .iter() + .filter(|s| s.timestamp >= cutoff) + .cloned() + .collect(); + } + + // Try full ISO 8601 / RFC 3339 + if let Ok(date) = DateTime::parse_from_rfc3339(since) { let cutoff = date.with_timezone(&chrono::Utc); return snapshots .iter() @@ -292,7 +302,7 @@ fn print_trend_graph(snapshots: &[debt_tracker::DebtSnapshot]) { min_score + (max_score - min_score) * ((row + 1) as f64 / (height - 1) as f64); if (*score >= bar_threshold_low && *score < bar_threshold_high) - || (row == 0 && (*score - max_score).abs() < f64::EPSILON) + || (row == height - 1 && (*score - max_score).abs() < 0.01) { line.push_str(&"●".green().to_string()); } else if i > 0 { From 1630d52177c1c4a45aad26d9b8f002b485f2dfd0 Mon Sep 17 00:00:00 2001 From: Aji Anaz Date: Thu, 11 Jun 2026 00:24:32 +0700 Subject: [PATCH 3/3] fix: clarify trend graph top-row range check for max score - Top row (row == height-1) range includes max_score boundary - Cleaner in_range condition with inline comment --- src/commands/debt.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/commands/debt.rs b/src/commands/debt.rs index f74f10e1..fe67cf33 100644 --- a/src/commands/debt.rs +++ b/src/commands/debt.rs @@ -301,9 +301,12 @@ fn print_trend_graph(snapshots: &[debt_tracker::DebtSnapshot]) { let bar_threshold_high = min_score + (max_score - min_score) * ((row + 1) as f64 / (height - 1) as f64); - if (*score >= bar_threshold_low && *score < bar_threshold_high) - || (row == height - 1 && (*score - max_score).abs() < 0.01) - { + // Score falls within this row's range + // Special case: top row (row == height-1) should also catch max_score + let in_range = + *score >= bar_threshold_low && (*score < bar_threshold_high || row == height - 1); + + if in_range { line.push_str(&"●".green().to_string()); } else if i > 0 { let prev = sampled[i - 1];