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.first().unwrap();
35        let last = display_records.last().unwrap();
36
37        // Compare overall
38        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        // Compare each tool
47        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    // List recent scans
61    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
77/// Format trend as JSON.
78pub 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
98/// Render an ASCII chart of scores over time.
99fn 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    // Chart dimensions
109    let chart_height: usize = 10;
110    let chart_width = scores.len().max(2);
111
112    // Scale scores to chart height
113    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    // Draw chart top-down
123    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    // X-axis
168    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    // Date labels (first, middle, last)
175    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]; // MM-DD
180            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}" // up
193    } else if diff < -1.0 {
194        "\u{1f4c9}" // down
195    } else {
196        "\u{27a1}" // flat
197    }
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}