garbage_code_hunter/last_words/
scanner.rs1use regex::Regex;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7#[derive(Debug, Clone)]
9pub struct LastWord {
10 pub file: PathBuf,
11 pub line: usize,
12 pub kind: LastWordKind,
13 pub text: String,
14 pub age_days: Option<u64>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum LastWordKind {
20 Todo,
21 Fixme,
22 Hack,
23 Temp,
24 QuickFix,
25 Wontfix,
26 Workaround,
27 Deprecated,
28 Safety,
29}
30
31impl LastWordKind {
32 pub fn label(&self) -> &'static str {
33 match self {
34 Self::Todo => "TODO",
35 Self::Fixme => "FIXME",
36 Self::Hack => "HACK",
37 Self::Temp => "TEMP",
38 Self::QuickFix => "quick fix",
39 Self::Wontfix => "WONTFIX",
40 Self::Workaround => "workaround",
41 Self::Deprecated => "DEPRECATED",
42 Self::Safety => "SAFETY",
43 }
44 }
45
46 pub fn tombstone_quote(&self) -> &'static str {
47 match self {
48 Self::Todo => "I'll do it later",
49 Self::Fixme => "This is fine... probably",
50 Self::Hack => "Don't touch this",
51 Self::Temp => "Temporary workaround",
52 Self::QuickFix => "Quick fix for now",
53 Self::Wontfix => "Won't fix, not my problem",
54 Self::Workaround => "It works, don't ask how",
55 Self::Deprecated => "Dead code walking",
56 Self::Safety => "Unsafe but necessary",
57 }
58 }
59}
60
61pub fn scan(path: &Path) -> Vec<LastWord> {
63 let patterns = build_patterns();
64 let mut results = Vec::new();
65
66 let entries: Vec<_> = if path.is_file() {
67 vec![path.to_path_buf()]
68 } else {
69 WalkDir::new(path)
70 .into_iter()
71 .filter_map(|e| e.ok())
72 .filter(|e| is_source_file(e.path()))
73 .map(|e| e.path().to_path_buf())
74 .collect()
75 };
76
77 for file_path in entries {
78 let content = match std::fs::read_to_string(&file_path) {
79 Ok(c) => c,
80 Err(_) => continue,
81 };
82
83 for (line_num, line) in content.lines().enumerate() {
84 let trimmed = line.trim();
85 if !is_comment_line(trimmed) {
87 continue;
88 }
89
90 for (kind, re) in &patterns {
91 if re.is_match(trimmed) {
92 results.push(LastWord {
93 file: file_path.clone(),
94 line: line_num + 1,
95 kind: kind.clone(),
96 text: trimmed.to_string(),
97 age_days: None,
98 });
99 }
100 }
101 }
102 }
103
104 results
105}
106
107pub fn try_get_age(file: &Path, line: usize) -> Option<u64> {
110 let output = std::process::Command::new("git")
111 .args([
112 "blame",
113 "-L",
114 &format!("{},{}", line, line),
115 "--porcelain",
116 &file.to_string_lossy(),
117 ])
118 .output()
119 .ok()?;
120
121 if !output.status.success() {
122 return None;
123 }
124
125 let stdout = String::from_utf8_lossy(&output.stdout);
126 for l in stdout.lines() {
127 if let Some(rest) = l.strip_prefix("committer-time ") {
128 let timestamp: u64 = rest.trim().parse().ok()?;
129 let now = std::time::SystemTime::now()
130 .duration_since(std::time::UNIX_EPOCH)
131 .ok()?
132 .as_secs();
133 return now.checked_sub(timestamp).map(|d| d / 86400);
134 }
135 }
136
137 None
138}
139
140fn build_patterns() -> Vec<(LastWordKind, Regex)> {
141 vec![
142 (LastWordKind::Fixme, Regex::new(r"(?i)\bFIXME\b").unwrap()),
143 (LastWordKind::Todo, Regex::new(r"(?i)\bTODO\b").unwrap()),
144 (LastWordKind::Hack, Regex::new(r"(?i)\bHACK\b").unwrap()),
145 (
146 LastWordKind::Temp,
147 Regex::new(r"(?i)\bTEMP(ORARY)?\b").unwrap(),
148 ),
149 (
150 LastWordKind::QuickFix,
151 Regex::new(r"(?i)\bquick\s*fix\b").unwrap(),
152 ),
153 (
154 LastWordKind::Wontfix,
155 Regex::new(r"(?i)\bWONT\s*FIX\b").unwrap(),
156 ),
157 (
158 LastWordKind::Workaround,
159 Regex::new(r"(?i)\bworkaround\b").unwrap(),
160 ),
161 (
162 LastWordKind::Deprecated,
163 Regex::new(r"(?i)\bDEPRECATED?\b").unwrap(),
164 ),
165 (LastWordKind::Safety, Regex::new(r"(?i)\bSAFETY\b").unwrap()),
166 ]
167}
168
169fn is_comment_line(line: &str) -> bool {
170 line.starts_with("//")
171 || line.starts_with("/*")
172 || line.starts_with("*")
173 || line.starts_with("#")
174 || line.starts_with("<!--")
175}
176
177fn is_source_file(path: &Path) -> bool {
178 matches!(
179 path.extension().and_then(|e| e.to_str()),
180 Some(
181 "rs" | "py"
182 | "js"
183 | "ts"
184 | "go"
185 | "java"
186 | "c"
187 | "cpp"
188 | "h"
189 | "hpp"
190 | "rb"
191 | "php"
192 | "swift"
193 | "kt"
194 | "scala"
195 | "sh"
196 | "bash"
197 | "zsh"
198 | "toml"
199 | "yaml"
200 | "yml"
201 | "json"
202 | "md"
203 | "html"
204 | "css"
205 | "sql"
206 )
207 )
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_scan_finds_todos() {
216 let dir = std::env::temp_dir().join("gch_last_words_test");
217 let _ = std::fs::create_dir_all(&dir);
218 let file = dir.join("test.rs");
219 std::fs::write(&file, "// TODO: fix this\nfn main() {}\n// FIXME: broken\n").unwrap();
220
221 let results = scan(&dir);
222 assert!(results.iter().any(|r| r.kind == LastWordKind::Todo));
223 assert!(results.iter().any(|r| r.kind == LastWordKind::Fixme));
224
225 let _ = std::fs::remove_dir_all(&dir);
226 }
227
228 #[test]
229 fn test_kind_label() {
230 assert_eq!(LastWordKind::Todo.label(), "TODO");
231 assert_eq!(LastWordKind::Hack.label(), "HACK");
232 }
233
234 #[test]
235 fn test_is_comment_line() {
236 assert!(is_comment_line("// TODO: fix"));
237 assert!(is_comment_line("/* FIXME */"));
238 assert!(is_comment_line("# HACK"));
239 assert!(!is_comment_line("let x = 1;"));
240 }
241}