use super::history::HistoryRecord;
use colored::Colorize;
pub fn format_terminal(records: &[HistoryRecord], last_n: usize) -> String {
if records.is_empty() {
return "\n No scan history found. Run `garbage-code-hunter scan` first.\n".to_string();
}
let display_records: Vec<&HistoryRecord> = if records.len() > last_n {
records[records.len() - last_n..].iter().collect()
} else {
records.iter().collect()
};
let mut out = String::new();
out.push_str(&format!("\n{}\n", "\u{1f4c8} Quality Trend".bold()));
out.push_str(&format!(
" (showing last {} scans)\n\n",
display_records.len()
));
out.push_str(&render_chart(&display_records));
out.push('\n');
if display_records.len() >= 2 {
out.push_str(&format!("{}\n", "\u{1f4ca} Breakdown".bold()));
out.push_str(&format!(" {}\n", "\u{2500}".repeat(40)));
let first = display_records.first().unwrap();
let last = display_records.last().unwrap();
let overall_diff = last.overall_score - first.overall_score;
let overall_arrow = diff_arrow(overall_diff);
out.push_str(&format!(
" {:<20} {:.0} \u{2192} {:.0} ({:+.0}) {}\n",
"Overall", first.overall_score, last.overall_score, overall_diff, overall_arrow
));
out.push('\n');
for last_tool in &last.tools {
if let Some(first_tool) = first.tools.iter().find(|t| t.name == last_tool.name) {
let diff = last_tool.score - first_tool.score;
let arrow = diff_arrow(diff);
out.push_str(&format!(
" {:<20} {:.0} \u{2192} {:.0} ({:+.0}) {}\n",
first_tool.name, first_tool.score, last_tool.score, diff, arrow
));
}
}
out.push('\n');
}
out.push_str(&format!("{}\n", "\u{1f4cb} Recent Scans".bold()));
out.push_str(&format!(" {}\n", "\u{2500}".repeat(50)));
for record in display_records.iter().rev().take(5) {
let score_str = format_score(record.overall_score);
out.push_str(&format!(
" {} {} {}\n",
&record.timestamp[..19],
score_str,
record.project_path.dimmed()
));
}
out.push('\n');
out
}
pub fn format_json(records: &[HistoryRecord]) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"records": records.iter().map(|r| {
serde_json::json!({
"timestamp": r.timestamp,
"project_path": r.project_path,
"overall_score": r.overall_score,
"tools": r.tools.iter().map(|t| {
serde_json::json!({
"name": t.name,
"score": t.score,
"item_count": t.item_count,
})
}).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
}))
.unwrap_or_else(|_| "[]".to_string())
}
fn render_chart(records: &[&HistoryRecord]) -> String {
if records.is_empty() {
return String::new();
}
let scores: Vec<f64> = records.iter().map(|r| r.overall_score).collect();
let min_score = scores.iter().cloned().fold(f64::MAX, f64::min).max(0.0);
let max_score = scores.iter().cloned().fold(f64::MIN, f64::max).min(100.0);
let chart_height: usize = 10;
let chart_width = scores.len().max(2);
let range = (max_score - min_score).max(1.0);
let scaled: Vec<usize> = scores
.iter()
.map(|s| ((s - min_score) / range * (chart_height - 1) as f64).round() as usize)
.collect();
let mut out = String::new();
out.push_str(&format!(" {}\n", "Score".dimmed()));
for row in (0..chart_height).rev() {
let y_label = min_score + (row as f64 / (chart_height - 1) as f64) * range;
if row == chart_height - 1 || row == 0 || row == chart_height / 2 {
out.push_str(&format!(
" {:>4} \u{2502}",
format!("{:.0}", y_label).dimmed()
));
} else {
out.push_str(" \u{2502}");
}
for (i, &s) in scaled.iter().enumerate() {
if s == row {
if i + 1 < scaled.len() && scaled[i + 1] > s {
out.push_str(" \u{2570}\u{2500}");
} else if i + 1 < scaled.len() && scaled[i + 1] < s {
out.push_str(" \u{256f}");
} else if i + 1 < scaled.len() {
out.push_str(" \u{2500}");
} else {
out.push_str(" \u{25cf}");
}
} else if i > 0 && i < scaled.len() {
let prev = scaled[i - 1];
let curr = s;
let should_draw = if prev < curr {
row > prev && row < curr
} else if prev > curr {
row < prev && row > curr
} else {
false
};
if should_draw {
out.push_str(" \u{2502}");
} else {
out.push_str(" ");
}
} else {
out.push_str(" ");
}
}
out.push('\n');
}
out.push_str(" \u{2514}");
for _ in 0..chart_width {
out.push_str("\u{2500}\u{2500}\u{2500}");
}
out.push('\n');
out.push_str(" ");
let len = records.len();
for (i, record) in records.iter().enumerate() {
if i == 0 || i == len / 2 || i == len - 1 {
let label = &record.timestamp[5..10]; out.push_str(&format!("{:<6}", label.dimmed()));
} else {
out.push_str(" ");
}
}
out.push('\n');
out
}
fn diff_arrow(diff: f64) -> &'static str {
if diff > 1.0 {
"\u{1f4c8}" } else if diff < -1.0 {
"\u{1f4c9}" } else {
"\u{27a1}" }
}
fn format_score(score: f64) -> colored::ColoredString {
use colored::Colorize;
if score >= 80.0 {
format!("{:.0}", score).green()
} else if score >= 60.0 {
format!("{:.0}", score).yellow()
} else {
format!("{:.0}", score).red()
}
}
#[cfg(test)]
mod tests {
use super::super::history::ToolScore;
use super::*;
fn make_record(ts: &str, score: f64) -> HistoryRecord {
HistoryRecord {
timestamp: ts.to_string(),
project_path: "/tmp/test".to_string(),
overall_score: score,
tools: vec![ToolScore {
name: "code-hunter".to_string(),
score,
item_count: 10,
}],
}
}
#[test]
fn test_format_terminal_empty() {
let out = format_terminal(&[], 10);
assert!(out.contains("No scan history"));
}
#[test]
fn test_format_terminal_with_records() {
let records = vec![
make_record("2024-01-15T14:30:00Z", 65.0),
make_record("2024-01-16T09:00:00Z", 72.0),
make_record("2024-01-17T10:00:00Z", 80.0),
];
let out = format_terminal(&records, 10);
assert!(out.contains("Quality Trend"));
assert!(out.contains("Breakdown"));
assert!(out.contains("Recent Scans"));
}
#[test]
fn test_format_json() {
let records = vec![make_record("2024-01-15T14:30:00Z", 72.5)];
let json = format_json(&records);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["records"].as_array().unwrap().len() == 1);
}
#[test]
fn test_diff_arrow() {
assert_eq!(diff_arrow(5.0), "\u{1f4c8}");
assert_eq!(diff_arrow(-5.0), "\u{1f4c9}");
assert_eq!(diff_arrow(0.5), "\u{27a1}");
}
}