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