garbage_code_hunter/last_words/
display.rs1use super::scanner::LastWord;
4use crate::common::i18n_ext::t;
5use colored::Colorize;
6
7pub 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 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 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 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 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
122pub 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}