use crate::commands::text_util::normalize_whitespace;
use crate::utils::xbp_ignore::{XbpIgnoreSet, DEFAULT_DISCOVERY_SKIP_DIRS};
use crate::utils::find_xbp_config_upwards;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
const MAX_FILE_BYTES: u64 = 1_500_000;
static TODO_SLASH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?://|--|;)\s*(TODO|FIXME|XXX|HACK)\b(?:\s*\([^)]*\))?\s*:?\s*(.*)$")
.expect("todo slash regex")
});
static TODO_HASH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^\s*#\s*(TODO|FIXME|XXX|HACK)\b(?:\s*\([^)]*\))?\s*:?\s*(.*)$")
.expect("todo hash regex")
});
static TODO_BLOCK_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)/\*\s*(TODO|FIXME|XXX|HACK)\b(?:\s*\([^)]*\))?\s*:?\s*(.*?)\*/")
.expect("todo block regex")
});
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TodoHit {
pub fingerprint: String,
pub kind: String,
pub path: String,
pub line: usize,
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
pub fn scan_todos(root: &Path) -> Result<Vec<TodoHit>, String> {
let ignore = build_ignore_set(root);
let mut hits = Vec::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.depth() > 0 && DEFAULT_DISCOVERY_SKIP_DIRS.iter().any(|d| *d == name) {
return false;
}
if e.file_type().is_dir() {
let rel = relative_path(root, e.path());
if ignore.matches_dir(&rel) {
return false;
}
}
true
})
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let rel = relative_path(root, path);
if ignore.matches_file(&rel) {
continue;
}
if should_skip_file(path) {
continue;
}
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(_) => continue,
};
if meta.len() > MAX_FILE_BYTES {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue, };
hits.extend(extract_hits_from_content(&rel, &content));
}
hits.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.kind.cmp(&b.kind))
});
let mut seen = std::collections::HashSet::new();
hits.retain(|h| seen.insert(h.fingerprint.clone()));
Ok(hits)
}
pub fn extract_hits_from_content(rel_path: &str, content: &str) -> Vec<TodoHit> {
let mut hits = Vec::new();
let lines: Vec<&str> = content.lines().collect();
for (idx, line) in lines.iter().enumerate() {
let line_no = idx + 1;
let trimmed_line = line.trim_start();
if trimmed_line.starts_with("///") || trimmed_line.starts_with("//!") {
continue;
}
let caps = TODO_SLASH_RE
.captures(line)
.filter(|caps| !is_doc_comment_slash_match(line, caps))
.filter(|caps| !is_inside_double_quotes(line, caps.get(0).map(|m| m.start()).unwrap_or(0)))
.or_else(|| {
let trimmed = line.trim_start();
if trimmed.starts_with("##") {
return None;
}
TODO_HASH_RE.captures(line)
})
.or_else(|| {
TODO_BLOCK_RE.captures(line).filter(|caps| {
!is_inside_double_quotes(
line,
caps.get(0).map(|m| m.start()).unwrap_or(0),
)
})
});
let Some(caps) = caps else {
continue;
};
let kind = caps.get(1).map(|m| m.as_str()).unwrap_or("TODO");
let text = caps.get(2).map(|m| m.as_str()).unwrap_or("").trim();
if text.is_empty() && kind.eq_ignore_ascii_case("XXX") {
continue;
}
let text = if text.is_empty() {
format!("({kind} without text)")
} else {
text.to_string()
};
let context = context_snippet(&lines, idx, 2);
hits.push(TodoHit {
fingerprint: fingerprint(rel_path, kind, &text),
kind: kind.to_ascii_uppercase(),
path: rel_path.replace('\\', "/"),
line: line_no,
text,
context: Some(context),
});
}
hits
}
pub fn fingerprint(path: &str, kind: &str, text: &str) -> String {
let normalized_path = path.replace('\\', "/");
let normalized_text = normalize_whitespace(text);
let kind = kind.trim().to_ascii_uppercase();
let mut hasher = Sha256::new();
hasher.update(normalized_path.as_bytes());
hasher.update([0]);
hasher.update(kind.as_bytes());
hasher.update([0]);
hasher.update(normalized_text.as_bytes());
format!("{:x}", hasher.finalize())
}
fn is_doc_comment_slash_match(line: &str, caps: ®ex::Captures<'_>) -> bool {
let Some(whole) = caps.get(0) else {
return false;
};
let start = whole.start();
let bytes = line.as_bytes();
if start > 0 && bytes[start - 1] == b'/' {
return true;
}
if bytes.get(start + 2) == Some(&b'/') {
return true;
}
false
}
fn is_inside_double_quotes(line: &str, index: usize) -> bool {
let mut in_string = false;
let mut escaped = false;
for (i, ch) in line.char_indices() {
if i >= index {
break;
}
if escaped {
escaped = false;
continue;
}
if in_string && ch == '\\' {
escaped = true;
continue;
}
if ch == '"' {
in_string = !in_string;
}
}
in_string
}
fn context_snippet(lines: &[&str], idx: usize, radius: usize) -> String {
let start = idx.saturating_sub(radius);
let end = (idx + radius + 1).min(lines.len());
lines[start..end].join("\n")
}
fn relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn should_skip_file(path: &Path) -> bool {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if name.ends_with(".lock")
|| name.ends_with(".min.js")
|| name.ends_with(".min.css")
|| name.ends_with(".map")
|| name.ends_with(".png")
|| name.ends_with(".jpg")
|| name.ends_with(".jpeg")
|| name.ends_with(".gif")
|| name.ends_with(".webp")
|| name.ends_with(".ico")
|| name.ends_with(".woff")
|| name.ends_with(".woff2")
|| name.ends_with(".ttf")
|| name.ends_with(".pdf")
|| name.ends_with(".zip")
|| name == "cargo.lock"
|| name == "pnpm-lock.yaml"
|| name == "package-lock.json"
|| name == "yarn.lock"
|| name == "todo-issues.json"
|| name == "catalog.json"
|| name == "todo.md"
|| name == "readme.md"
|| name == "changelog.md"
|| name == "agents.md"
|| name.ends_with(".mdx")
{
return true;
}
let rel = path.to_string_lossy().replace('\\', "/").to_ascii_lowercase();
if rel.contains("/generated/catalog.json") || rel.contains("/target/") {
return true;
}
false
}
fn build_ignore_set(root: &Path) -> XbpIgnoreSet {
let mut set = XbpIgnoreSet::from_patterns(
DEFAULT_DISCOVERY_SKIP_DIRS
.iter()
.map(|d| format!("{d}/")),
);
for candidate in [
root.join(".xbpignore"),
root.join(".xbp").join(".xbpignore"),
root.join(".gitignore"),
] {
if let Ok(content) = fs::read_to_string(candidate) {
let other = XbpIgnoreSet::from_file_content(&content);
set.merge(&other);
}
}
if let Some(found) = find_xbp_config_upwards(root) {
if let Ok(content) = fs::read_to_string(&found.config_path) {
if let Ok(cfg) = serde_yaml::from_str::<serde_yaml::Value>(&content) {
if let Some(paths) = cfg
.get("ignore_paths")
.or_else(|| cfg.get("ignored_paths"))
.and_then(|v| v.as_sequence())
{
let extra: Vec<String> = paths
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
let other = XbpIgnoreSet::from_patterns(extra);
set.merge(&other);
}
}
}
}
set
}
trait IgnoreMatch {
fn matches_dir(&self, rel: &str) -> bool;
fn matches_file(&self, rel: &str) -> bool;
}
impl IgnoreMatch for XbpIgnoreSet {
fn matches_dir(&self, rel: &str) -> bool {
self.is_ignored_relative(rel, true)
}
fn matches_file(&self, rel: &str) -> bool {
self.is_ignored_relative(rel, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_rust_and_python_todos() {
let content = [
format!("// {}: wire secrets fallback", "TODO"),
"fn main() {}".to_string(),
format!("# {}: handle empty list", "FIXME"),
format!("/* {}: temporary */", "HACK"),
]
.join("\n");
let hits = extract_hits_from_content("src/main.rs", &content);
assert!(hits
.iter()
.any(|h| h.kind == "TODO" && h.text.contains("wire secrets")));
assert!(hits.iter().any(|h| h.kind == "FIXME"));
assert!(hits.iter().any(|h| h.kind == "HACK"));
}
#[test]
fn fingerprint_stable_across_line_moves() {
let a = fingerprint("src/a.rs", "TODO", "fix the thing");
let b = fingerprint("src/a.rs", "TODO", " fix the thing ");
assert_eq!(a, b);
let c = fingerprint("src\\a.rs", "todo", "fix the thing");
assert_eq!(a, c);
}
#[test]
fn fingerprint_differs_for_different_text() {
let a = fingerprint("src/a.rs", "TODO", "one");
let b = fingerprint("src/a.rs", "TODO", "two");
assert_ne!(a, b);
}
#[test]
fn ignores_markdown_headers_and_string_literals() {
let content = [
" body.push_str(\"## TODO section\");".to_string(),
"## TODO should not match as a heading either".to_string(),
format!("// {}: real one", "TODO"),
]
.join("\n");
let hits = extract_hits_from_content("src/x.rs", &content);
assert_eq!(hits.len(), 1);
assert!(hits[0].text.contains("real one"));
}
#[test]
fn ignores_rust_doc_comments() {
let content = "/// FIXME: 1\n//! TODO: module docs\n// TODO: real code todo\n";
let hits = extract_hits_from_content("src/lib.rs", content);
assert_eq!(hits.len(), 1);
assert!(hits[0].text.contains("real code todo"));
}
#[test]
fn ignores_todos_inside_string_literals() {
let about = format!(
" about = \"Scan {} markers and file issues\",",
concat!("// ", "TODO")
);
let content = [about, format!(" // {}: actual work item", "TODO")].join("\n");
let hits = extract_hits_from_content("src/cli.rs", &content);
assert_eq!(hits.len(), 1);
assert!(hits[0].text.contains("actual work item"));
}
}