Skip to main content

garbage_code_hunter/last_words/
scanner.rs

1//! Scan source files for "last words" — TODO, FIXME, HACK, TEMP comments.
2
3use regex::Regex;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7/// A discovered "last word" comment in the codebase.
8#[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/// The type of last-word comment.
18#[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
61/// Scan a directory for last-word comments.
62pub 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            // Skip lines that are just code, not comments
86            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
107/// Try to get the age of a TODO comment via git blame.
108/// Returns age in days if successful.
109pub 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}