Skip to main content

garbage_code_hunter/last_words/
display.rs

1//! Display last-words report in terminal or JSON.
2
3use super::scanner::LastWord;
4use crate::common::i18n_ext::t;
5use colored::Colorize;
6
7/// Format last-words report for terminal.
8pub fn format_terminal(words: &[LastWord], lang: &str) -> String {
9    if words.is_empty() {
10        return format!(
11            "\n  {}\n",
12            t(
13                lang,
14                "没有发现遗留注释。你的代码可疑地干净。",
15                "No legacy comments found. Your code is suspiciously clean."
16            )
17        );
18    }
19
20    let mut out = String::new();
21    out.push_str(&format!(
22        "\n{}\n",
23        t(
24            lang,
25            "\u{1f576}\u{fe0f} 代码遗言",
26            "\u{1f576}\u{fe0f} Code Last Words"
27        )
28        .bold()
29    ));
30    out.push_str(&format!("{}\n\n", "\u{2501}".repeat(40)));
31
32    // Group by kind
33    let mut by_kind: std::collections::HashMap<&str, Vec<&LastWord>> =
34        std::collections::HashMap::new();
35    for w in words {
36        by_kind.entry(w.kind.label()).or_default().push(w);
37    }
38
39    // Sort by count descending
40    let mut groups: Vec<_> = by_kind.iter().collect();
41    groups.sort_by_key(|a| std::cmp::Reverse(a.1.len()));
42
43    for (kind_label, items) in &groups {
44        out.push_str(&format!(
45            "{} {} ({} found)\n",
46            tombstone_emoji(kind_label),
47            kind_label.bold(),
48            items.len()
49        ));
50
51        // Show top 5 longest-lived
52        let mut sorted = items.to_vec();
53        sorted.sort_by_key(|a| std::cmp::Reverse(a.age_days.unwrap_or(0)));
54
55        for item in sorted.iter().take(5) {
56            let age_str = match item.age_days {
57                Some(days) if days > 365 => format!("{} {}", days / 365, t(lang, "年", "years"))
58                    .red()
59                    .to_string(),
60                Some(days) if days > 90 => format!("{} {}", days, t(lang, "天", "days"))
61                    .yellow()
62                    .to_string(),
63                Some(days) => format!("{} {}", days, t(lang, "天", "days"))
64                    .green()
65                    .to_string(),
66                None => t(lang, "年龄未知", "age unknown").dimmed().to_string(),
67            };
68            let quote = item.kind.tombstone_quote();
69            let file_short = item
70                .file
71                .file_name()
72                .map(|f| f.to_string_lossy().to_string())
73                .unwrap_or_else(|| item.file.display().to_string());
74            out.push_str(&format!(
75                "  {}:{} \"{}\"\n    \u{2514}\u{2500} {}\n    \u{2514}\u{2500} {}\n",
76                file_short.dimmed(),
77                item.line,
78                truncate(&item.text, 60).dimmed(),
79                quote,
80                age_str
81            ));
82        }
83        out.push('\n');
84    }
85
86    // Summary stats
87    let total = words.len();
88    let with_age: Vec<_> = words.iter().filter_map(|w| w.age_days).collect();
89    let oldest = with_age.iter().max().copied().unwrap_or(0);
90    let avg_age = if with_age.is_empty() {
91        0
92    } else {
93        with_age.iter().sum::<u64>() / with_age.len() as u64
94    };
95
96    out.push_str(&format!(
97        "{}\n",
98        t(lang, "\u{1f4ca} 统计", "\u{1f4ca} Summary").bold()
99    ));
100    out.push_str(&format!(
101        "  {}: {}\n",
102        t(lang, "遗留注释总数", "Total legacy comments"),
103        total
104    ));
105    if oldest > 0 {
106        out.push_str(&format!(
107            "  {}: {} 天 ({:.1} 年)\n",
108            t(lang, "最老", "Oldest"),
109            oldest,
110            oldest as f64 / 365.0
111        ));
112        out.push_str(&format!(
113            "  {}: {} 天\n",
114            t(lang, "平均年龄", "Average age"),
115            avg_age
116        ));
117    }
118
119    out
120}
121
122/// Format last-words as JSON.
123pub fn format_json(words: &[LastWord]) -> String {
124    let items: Vec<serde_json::Value> = words
125        .iter()
126        .map(|w| {
127            serde_json::json!({
128                "file": w.file.display().to_string(),
129                "line": w.line,
130                "kind": w.kind.label(),
131                "text": w.text,
132                "age_days": w.age_days,
133            })
134        })
135        .collect();
136
137    let total = words.len();
138    let with_age: Vec<_> = words.iter().filter_map(|w| w.age_days).collect();
139    let oldest = with_age.iter().max().copied().unwrap_or(0);
140    let avg_age = if with_age.is_empty() {
141        0
142    } else {
143        with_age.iter().sum::<u64>() / with_age.len() as u64
144    };
145
146    serde_json::json!({
147        "total": total,
148        "oldest_days": oldest,
149        "average_age_days": avg_age,
150        "items": items,
151    })
152    .to_string()
153}
154
155fn tombstone_emoji(kind: &str) -> &'static str {
156    match kind {
157        "TODO" => "\u{1f6cf}\u{fe0f}",
158        "FIXME" => "\u{1f527}",
159        "HACK" => "\u{1f529}",
160        "TEMP" => "\u{23f3}",
161        "quick fix" => "\u{26a1}",
162        "WONTFIX" => "\u{1f6ab}",
163        "workaround" => "\u{1f4a0}",
164        "DEPRECATED" => "\u{1f480}",
165        "SAFETY" => "\u{26a0}\u{fe0f}",
166        _ => "\u{1f4dc}",
167    }
168}
169
170fn truncate(s: &str, max_len: usize) -> String {
171    if s.len() <= max_len {
172        s.to_string()
173    } else {
174        format!("{}...", &s[..max_len - 3])
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::super::scanner::LastWordKind;
181    use super::*;
182    use std::path::PathBuf;
183
184    fn make_word(kind: LastWordKind, age: Option<u64>) -> LastWord {
185        LastWord {
186            file: PathBuf::from("test.rs"),
187            line: 42,
188            kind,
189            text: "// TODO: fix this".to_string(),
190            age_days: age,
191        }
192    }
193
194    #[test]
195    fn test_format_terminal_empty() {
196        let out = format_terminal(&[], "en-US");
197        assert!(out.contains("No legacy comments"));
198    }
199
200    #[test]
201    fn test_format_terminal_empty_chinese() {
202        let out = format_terminal(&[], "zh-CN");
203        assert!(out.contains("遗留注释"));
204    }
205
206    #[test]
207    fn test_format_terminal_with_items() {
208        let words = vec![
209            make_word(LastWordKind::Todo, Some(500)),
210            make_word(LastWordKind::Fixme, Some(100)),
211        ];
212        let out = format_terminal(&words, "en-US");
213        assert!(out.contains("Code Last Words"));
214        assert!(out.contains("TODO"));
215        assert!(out.contains("FIXME"));
216    }
217
218    #[test]
219    fn test_format_terminal_chinese() {
220        let words = vec![make_word(LastWordKind::Todo, Some(500))];
221        let out = format_terminal(&words, "zh-CN");
222        assert!(out.contains("代码遗言"));
223    }
224
225    #[test]
226    fn test_format_json() {
227        let words = vec![make_word(LastWordKind::Todo, Some(365))];
228        let json = format_json(&words);
229        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
230        assert_eq!(parsed["total"], 1);
231        assert_eq!(parsed["oldest_days"], 365);
232    }
233
234    #[test]
235    fn test_truncate() {
236        assert_eq!(truncate("hello", 10), "hello");
237        assert_eq!(truncate("hello world long text", 10), "hello w...");
238    }
239}