Skip to main content

garbage_code_hunter/trend/
display.rs

1//! ASCII trend chart rendering.
2
3use super::history::HistoryRecord;
4use colored::Colorize;
5
6/// Format trend report for terminal output.
7pub 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    // ASCII chart
26    out.push_str(&render_chart(&display_records));
27    out.push('\n');
28
29    // Breakdown comparison (first vs last)
30    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        // Compare overall
42        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        // Compare each tool
51        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    // List recent scans
65    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
81/// Format trend as JSON.
82pub 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
102/// Render an ASCII chart of scores over time.
103fn 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    // Chart dimensions
113    let chart_height: usize = 10;
114    let chart_width = scores.len().max(2);
115
116    // Scale scores to chart height
117    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    // Draw chart top-down
127    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    // X-axis
172    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    // Date labels (first, middle, last)
179    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]; // MM-DD
184            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}" // up
197    } else if diff < -1.0 {
198        "\u{1f4c9}" // down
199    } else {
200        "\u{27a1}" // flat
201    }
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}