garbage_code_hunter/trend/
display.rs1use super::history::HistoryRecord;
4use colored::Colorize;
5
6pub fn format_terminal(records: &[HistoryRecord], last_n: usize) -> String {
8 if records.is_empty() {
9 return "\n No scan history found. Run `garbage-code-hunter scan` first.\n".to_string();
10 }
11
12 let display_records: Vec<&HistoryRecord> = if records.len() > last_n {
13 records[records.len() - last_n..].iter().collect()
14 } else {
15 records.iter().collect()
16 };
17
18 let mut out = String::new();
19 out.push_str(&format!("\n{}\n", "\u{1f4c8} Quality Trend".bold()));
20 out.push_str(&format!(
21 " (showing last {} scans)\n\n",
22 display_records.len()
23 ));
24
25 out.push_str(&render_chart(&display_records));
27 out.push('\n');
28
29 if display_records.len() >= 2 {
31 out.push_str(&format!("{}\n", "\u{1f4ca} Breakdown".bold()));
32 out.push_str(&format!(" {}\n", "\u{2500}".repeat(40)));
33
34 let first = display_records.first().unwrap();
35 let last = display_records.last().unwrap();
36
37 let overall_diff = last.overall_score - first.overall_score;
39 let overall_arrow = diff_arrow(overall_diff);
40 out.push_str(&format!(
41 " {:<20} {:.0} \u{2192} {:.0} ({:+.0}) {}\n",
42 "Overall", first.overall_score, last.overall_score, overall_diff, overall_arrow
43 ));
44 out.push('\n');
45
46 for last_tool in &last.tools {
48 if let Some(first_tool) = first.tools.iter().find(|t| t.name == last_tool.name) {
49 let diff = last_tool.score - first_tool.score;
50 let arrow = diff_arrow(diff);
51 out.push_str(&format!(
52 " {:<20} {:.0} \u{2192} {:.0} ({:+.0}) {}\n",
53 first_tool.name, first_tool.score, last_tool.score, diff, arrow
54 ));
55 }
56 }
57 out.push('\n');
58 }
59
60 out.push_str(&format!("{}\n", "\u{1f4cb} Recent Scans".bold()));
62 out.push_str(&format!(" {}\n", "\u{2500}".repeat(50)));
63 for record in display_records.iter().rev().take(5) {
64 let score_str = format_score(record.overall_score);
65 out.push_str(&format!(
66 " {} {} {}\n",
67 &record.timestamp[..19],
68 score_str,
69 record.project_path.dimmed()
70 ));
71 }
72 out.push('\n');
73
74 out
75}
76
77pub fn format_json(records: &[HistoryRecord]) -> String {
79 serde_json::to_string_pretty(&serde_json::json!({
80 "records": records.iter().map(|r| {
81 serde_json::json!({
82 "timestamp": r.timestamp,
83 "project_path": r.project_path,
84 "overall_score": r.overall_score,
85 "tools": r.tools.iter().map(|t| {
86 serde_json::json!({
87 "name": t.name,
88 "score": t.score,
89 "item_count": t.item_count,
90 })
91 }).collect::<Vec<_>>(),
92 })
93 }).collect::<Vec<_>>(),
94 }))
95 .unwrap_or_else(|_| "[]".to_string())
96}
97
98fn render_chart(records: &[&HistoryRecord]) -> String {
100 if records.is_empty() {
101 return String::new();
102 }
103
104 let scores: Vec<f64> = records.iter().map(|r| r.overall_score).collect();
105 let min_score = scores.iter().cloned().fold(f64::MAX, f64::min).max(0.0);
106 let max_score = scores.iter().cloned().fold(f64::MIN, f64::max).min(100.0);
107
108 let chart_height: usize = 10;
110 let chart_width = scores.len().max(2);
111
112 let range = (max_score - min_score).max(1.0);
114 let scaled: Vec<usize> = scores
115 .iter()
116 .map(|s| ((s - min_score) / range * (chart_height - 1) as f64).round() as usize)
117 .collect();
118
119 let mut out = String::new();
120 out.push_str(&format!(" {}\n", "Score".dimmed()));
121
122 for row in (0..chart_height).rev() {
124 let y_label = min_score + (row as f64 / (chart_height - 1) as f64) * range;
125 if row == chart_height - 1 || row == 0 || row == chart_height / 2 {
126 out.push_str(&format!(
127 " {:>4} \u{2502}",
128 format!("{:.0}", y_label).dimmed()
129 ));
130 } else {
131 out.push_str(" \u{2502}");
132 }
133
134 for (i, &s) in scaled.iter().enumerate() {
135 if s == row {
136 if i + 1 < scaled.len() && scaled[i + 1] > s {
137 out.push_str(" \u{2570}\u{2500}");
138 } else if i + 1 < scaled.len() && scaled[i + 1] < s {
139 out.push_str(" \u{256f}");
140 } else if i + 1 < scaled.len() {
141 out.push_str(" \u{2500}");
142 } else {
143 out.push_str(" \u{25cf}");
144 }
145 } else if i > 0 && i < scaled.len() {
146 let prev = scaled[i - 1];
147 let curr = s;
148 let should_draw = if prev < curr {
149 row > prev && row < curr
150 } else if prev > curr {
151 row < prev && row > curr
152 } else {
153 false
154 };
155 if should_draw {
156 out.push_str(" \u{2502}");
157 } else {
158 out.push_str(" ");
159 }
160 } else {
161 out.push_str(" ");
162 }
163 }
164 out.push('\n');
165 }
166
167 out.push_str(" \u{2514}");
169 for _ in 0..chart_width {
170 out.push_str("\u{2500}\u{2500}\u{2500}");
171 }
172 out.push('\n');
173
174 out.push_str(" ");
176 let len = records.len();
177 for (i, record) in records.iter().enumerate() {
178 if i == 0 || i == len / 2 || i == len - 1 {
179 let label = &record.timestamp[5..10]; out.push_str(&format!("{:<6}", label.dimmed()));
181 } else {
182 out.push_str(" ");
183 }
184 }
185 out.push('\n');
186
187 out
188}
189
190fn diff_arrow(diff: f64) -> &'static str {
191 if diff > 1.0 {
192 "\u{1f4c8}" } else if diff < -1.0 {
194 "\u{1f4c9}" } else {
196 "\u{27a1}" }
198}
199
200fn format_score(score: f64) -> colored::ColoredString {
201 use colored::Colorize;
202 if score >= 80.0 {
203 format!("{:.0}", score).green()
204 } else if score >= 60.0 {
205 format!("{:.0}", score).yellow()
206 } else {
207 format!("{:.0}", score).red()
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::super::history::ToolScore;
214 use super::*;
215
216 fn make_record(ts: &str, score: f64) -> HistoryRecord {
217 HistoryRecord {
218 timestamp: ts.to_string(),
219 project_path: "/tmp/test".to_string(),
220 overall_score: score,
221 tools: vec![ToolScore {
222 name: "code-hunter".to_string(),
223 score,
224 item_count: 10,
225 }],
226 }
227 }
228
229 #[test]
230 fn test_format_terminal_empty() {
231 let out = format_terminal(&[], 10);
232 assert!(out.contains("No scan history"));
233 }
234
235 #[test]
236 fn test_format_terminal_with_records() {
237 let records = vec![
238 make_record("2024-01-15T14:30:00Z", 65.0),
239 make_record("2024-01-16T09:00:00Z", 72.0),
240 make_record("2024-01-17T10:00:00Z", 80.0),
241 ];
242 let out = format_terminal(&records, 10);
243 assert!(out.contains("Quality Trend"));
244 assert!(out.contains("Breakdown"));
245 assert!(out.contains("Recent Scans"));
246 }
247
248 #[test]
249 fn test_format_json() {
250 let records = vec![make_record("2024-01-15T14:30:00Z", 72.5)];
251 let json = format_json(&records);
252 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
253 assert!(parsed["records"].as_array().unwrap().len() == 1);
254 }
255
256 #[test]
257 fn test_diff_arrow() {
258 assert_eq!(diff_arrow(5.0), "\u{1f4c8}");
259 assert_eq!(diff_arrow(-5.0), "\u{1f4c9}");
260 assert_eq!(diff_arrow(0.5), "\u{27a1}");
261 }
262}