use regex::Regex;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct LastWord {
pub file: PathBuf,
pub line: usize,
pub kind: LastWordKind,
pub text: String,
pub age_days: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LastWordKind {
Todo,
Fixme,
Hack,
Temp,
QuickFix,
Wontfix,
Workaround,
Deprecated,
Safety,
}
impl LastWordKind {
pub fn label(&self) -> &'static str {
match self {
Self::Todo => "TODO",
Self::Fixme => "FIXME",
Self::Hack => "HACK",
Self::Temp => "TEMP",
Self::QuickFix => "quick fix",
Self::Wontfix => "WONTFIX",
Self::Workaround => "workaround",
Self::Deprecated => "DEPRECATED",
Self::Safety => "SAFETY",
}
}
pub fn tombstone_quote(&self) -> &'static str {
match self {
Self::Todo => "I'll do it later",
Self::Fixme => "This is fine... probably",
Self::Hack => "Don't touch this",
Self::Temp => "Temporary workaround",
Self::QuickFix => "Quick fix for now",
Self::Wontfix => "Won't fix, not my problem",
Self::Workaround => "It works, don't ask how",
Self::Deprecated => "Dead code walking",
Self::Safety => "Unsafe but necessary",
}
}
}
pub fn scan(path: &Path) -> Vec<LastWord> {
let patterns = build_patterns();
let mut results = Vec::new();
let entries: Vec<_> = if path.is_file() {
vec![path.to_path_buf()]
} else {
WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| is_source_file(e.path()))
.map(|e| e.path().to_path_buf())
.collect()
};
for file_path in entries {
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(_) => continue,
};
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if !is_comment_line(trimmed) {
continue;
}
for (kind, re) in &patterns {
if re.is_match(trimmed) {
results.push(LastWord {
file: file_path.clone(),
line: line_num + 1,
kind: kind.clone(),
text: trimmed.to_string(),
age_days: None,
});
}
}
}
}
results
}
pub fn try_get_age(file: &Path, line: usize) -> Option<u64> {
let output = std::process::Command::new("git")
.args([
"blame",
"-L",
&format!("{},{}", line, line),
"--porcelain",
&file.to_string_lossy(),
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for l in stdout.lines() {
if let Some(rest) = l.strip_prefix("committer-time ") {
let timestamp: u64 = rest.trim().parse().ok()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
return now.checked_sub(timestamp).map(|d| d / 86400);
}
}
None
}
fn build_patterns() -> Vec<(LastWordKind, Regex)> {
vec![
(LastWordKind::Fixme, Regex::new(r"(?i)\bFIXME\b").unwrap()),
(LastWordKind::Todo, Regex::new(r"(?i)\bTODO\b").unwrap()),
(LastWordKind::Hack, Regex::new(r"(?i)\bHACK\b").unwrap()),
(
LastWordKind::Temp,
Regex::new(r"(?i)\bTEMP(ORARY)?\b").unwrap(),
),
(
LastWordKind::QuickFix,
Regex::new(r"(?i)\bquick\s*fix\b").unwrap(),
),
(
LastWordKind::Wontfix,
Regex::new(r"(?i)\bWONT\s*FIX\b").unwrap(),
),
(
LastWordKind::Workaround,
Regex::new(r"(?i)\bworkaround\b").unwrap(),
),
(
LastWordKind::Deprecated,
Regex::new(r"(?i)\bDEPRECATED?\b").unwrap(),
),
(LastWordKind::Safety, Regex::new(r"(?i)\bSAFETY\b").unwrap()),
]
}
fn is_comment_line(line: &str) -> bool {
line.starts_with("//")
|| line.starts_with("/*")
|| line.starts_with("*")
|| line.starts_with("#")
|| line.starts_with("<!--")
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some(
"rs" | "py"
| "js"
| "ts"
| "go"
| "java"
| "c"
| "cpp"
| "h"
| "hpp"
| "rb"
| "php"
| "swift"
| "kt"
| "scala"
| "sh"
| "bash"
| "zsh"
| "toml"
| "yaml"
| "yml"
| "json"
| "md"
| "html"
| "css"
| "sql"
)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_finds_todos() {
let dir = std::env::temp_dir().join("gch_last_words_test");
let _ = std::fs::create_dir_all(&dir);
let file = dir.join("test.rs");
std::fs::write(&file, "// TODO: fix this\nfn main() {}\n// FIXME: broken\n").unwrap();
let results = scan(&dir);
assert!(results.iter().any(|r| r.kind == LastWordKind::Todo));
assert!(results.iter().any(|r| r.kind == LastWordKind::Fixme));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_kind_label() {
assert_eq!(LastWordKind::Todo.label(), "TODO");
assert_eq!(LastWordKind::Hack.label(), "HACK");
}
#[test]
fn test_is_comment_line() {
assert!(is_comment_line("// TODO: fix"));
assert!(is_comment_line("/* FIXME */"));
assert!(is_comment_line("# HACK"));
assert!(!is_comment_line("let x = 1;"));
}
}