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;
const CONTEXT_RADIUS: usize = 10;
const MAX_CONTEXT_LINES: usize = 120;
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 = rich_context(rel_path, &lines, idx);
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());
format_context_slice(lines, start, end)
}
fn rich_context(rel_path: &str, lines: &[&str], idx: usize) -> String {
let lang = ContextLang::from_path(rel_path);
if let Some((start, end)) = expand_construct_range(lines, idx, lang) {
return format_context_slice(lines, start, end);
}
context_snippet(lines, idx, CONTEXT_RADIUS)
}
fn format_context_slice(lines: &[&str], start: usize, end: usize) -> String {
let start = start.min(lines.len());
let mut end = end.min(lines.len()).max(start);
if end.saturating_sub(start) > MAX_CONTEXT_LINES {
end = start + MAX_CONTEXT_LINES;
}
let last_line_no = end.max(1);
let width = ((last_line_no as f64).log10().floor() as usize) + 1;
lines[start..end]
.iter()
.enumerate()
.map(|(offset, line)| {
let line_no = start + offset + 1;
format!("{line_no:>width$} | {line}")
})
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ContextLang {
Rust,
JsTs,
Python,
Sql,
Css,
Go,
Generic,
}
impl ContextLang {
fn from_path(path: &str) -> Self {
let lower = path.replace('\\', "/").to_ascii_lowercase();
let ext = lower.rsplit('.').next().unwrap_or("");
match ext {
"rs" => Self::Rust,
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" => Self::JsTs,
"py" | "pyi" => Self::Python,
"sql" | "pgsql" | "psql" => Self::Sql,
"css" | "scss" | "sass" | "less" => Self::Css,
"go" => Self::Go,
_ => Self::Generic,
}
}
}
fn expand_construct_range(
lines: &[&str],
hit_idx: usize,
lang: ContextLang,
) -> Option<(usize, usize)> {
if let Some(decl) = find_following_declaration(lines, hit_idx, lang) {
if let Some(end) = find_construct_end(lines, decl, lang) {
let start = hit_idx; if end > start {
return Some((start, (end + 1).min(lines.len())));
}
}
}
if let Some(decl) = find_enclosing_declaration(lines, hit_idx, lang) {
if let Some(end) = find_construct_end(lines, decl, lang) {
if end >= hit_idx {
let start = expand_leading_comments(lines, decl);
return Some((start, (end + 1).min(lines.len())));
}
}
}
None
}
fn find_following_declaration(
lines: &[&str],
hit_idx: usize,
lang: ContextLang,
) -> Option<usize> {
let search_limit = (hit_idx + 1 + 12).min(lines.len());
let mut i = hit_idx + 1;
while i < search_limit {
let trimmed = lines[i].trim();
if trimmed.is_empty()
|| is_comment_only_line(trimmed, lang)
|| is_attribute_or_decorator(trimmed, lang)
{
i += 1;
continue;
}
if is_declaration_start(trimmed, lang) {
return Some(i);
}
return None;
}
None
}
fn is_attribute_or_decorator(trimmed: &str, lang: ContextLang) -> bool {
match lang {
ContextLang::Rust => trimmed.starts_with("#[") || trimmed.starts_with("#!["),
ContextLang::Python | ContextLang::JsTs => trimmed.starts_with('@'),
_ => false,
}
}
fn find_enclosing_declaration(
lines: &[&str],
hit_idx: usize,
lang: ContextLang,
) -> Option<usize> {
let mut i = hit_idx;
let mut scanned = 0usize;
while i > 0 && scanned < MAX_CONTEXT_LINES {
i -= 1;
scanned += 1;
let trimmed = lines[i].trim();
if !is_declaration_start(trimmed, lang) {
continue;
}
if let Some(end) = find_construct_end(lines, i, lang) {
if end >= hit_idx {
return Some(i);
}
}
}
None
}
fn expand_leading_comments(lines: &[&str], decl_idx: usize) -> usize {
let mut start = decl_idx;
while start > 0 {
let prev = lines[start - 1].trim();
if prev.is_empty()
|| prev.starts_with("//")
|| prev.starts_with('#')
|| prev.starts_with("/*")
|| prev.starts_with('*')
|| prev.starts_with("///")
|| prev.starts_with("//!")
|| prev.starts_with("--")
{
start -= 1;
if decl_idx.saturating_sub(start) > 20 {
break;
}
continue;
}
break;
}
start
}
fn is_comment_only_line(trimmed: &str, lang: ContextLang) -> bool {
match lang {
ContextLang::Python => trimmed.starts_with('#'),
ContextLang::Sql => trimmed.starts_with("--") || trimmed.starts_with("/*"),
ContextLang::Css => trimmed.starts_with("/*") || trimmed.starts_with("//"),
_ => {
trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| trimmed.starts_with('#')
|| trimmed.starts_with("--")
}
}
}
fn is_declaration_start(trimmed: &str, lang: ContextLang) -> bool {
if trimmed.is_empty() {
return false;
}
match lang {
ContextLang::Rust => is_rust_declaration(trimmed),
ContextLang::JsTs => is_js_declaration(trimmed),
ContextLang::Python => is_python_declaration(trimmed),
ContextLang::Sql => is_sql_declaration(trimmed),
ContextLang::Css => is_css_rule_start(trimmed),
ContextLang::Go => is_go_declaration(trimmed),
ContextLang::Generic => {
is_rust_declaration(trimmed)
|| is_js_declaration(trimmed)
|| is_python_declaration(trimmed)
|| is_sql_declaration(trimmed)
|| is_go_declaration(trimmed)
}
}
}
fn is_rust_declaration(trimmed: &str) -> bool {
let t = trimmed.trim_start_matches("pub(crate) ").trim_start();
let t = t.trim_start_matches("pub ").trim_start();
let t = t.trim_start_matches("async ").trim_start();
let t = t.trim_start_matches("const ").trim_start();
let t = t.trim_start_matches("unsafe ").trim_start();
t.starts_with("fn ")
|| t.starts_with("fn(") || t.starts_with("struct ")
|| t.starts_with("enum ")
|| t.starts_with("impl ")
|| t.starts_with("trait ")
|| t.starts_with("mod ")
|| t.starts_with("type ")
}
fn is_js_declaration(trimmed: &str) -> bool {
let t = strip_js_export_prefix(trimmed.trim_start());
if t.starts_with("function ")
|| t.starts_with("function*")
|| t.starts_with("class ")
|| t.starts_with("interface ")
|| t.starts_with("type ")
|| t.starts_with("enum ")
|| t.starts_with("namespace ")
{
return true;
}
if (t.starts_with("const ") || t.starts_with("let ") || t.starts_with("var "))
&& (t.contains("=>")
|| t.contains("function")
|| t.contains("= (")
|| t.contains("=("))
{
return true;
}
is_js_method_header(t)
}
fn strip_js_export_prefix(mut t: &str) -> &str {
loop {
let next = t
.strip_prefix("export ")
.or_else(|| t.strip_prefix("default "))
.or_else(|| t.strip_prefix("async "))
.or_else(|| t.strip_prefix("declare "))
.or_else(|| t.strip_prefix("public "))
.or_else(|| t.strip_prefix("private "))
.or_else(|| t.strip_prefix("protected "))
.or_else(|| t.strip_prefix("static "))
.or_else(|| t.strip_prefix("readonly "));
match next {
Some(rest) => t = rest.trim_start(),
None => break,
}
}
t
}
fn is_js_method_header(t: &str) -> bool {
let t = t.trim();
if t.starts_with("if ")
|| t.starts_with("if(")
|| t.starts_with("for ")
|| t.starts_with("for(")
|| t.starts_with("while ")
|| t.starts_with("while(")
|| t.starts_with("switch ")
|| t.starts_with("switch(")
|| t.starts_with("catch ")
|| t.starts_with("catch(")
{
return false;
}
let Some(open) = t.find('(') else {
return false;
};
let name = t[..open].trim();
if name.is_empty()
|| !name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
{
return false;
}
if name.contains('=') || name.contains(':') {
return false;
}
t.contains('{') || t.ends_with(')') || t.ends_with('{')
}
fn is_python_declaration(trimmed: &str) -> bool {
let t = trimmed.trim_start();
t.starts_with("def ")
|| t.starts_with("async def ")
|| t.starts_with("class ")
|| t.starts_with("@") }
fn is_sql_declaration(trimmed: &str) -> bool {
let upper = trimmed.to_ascii_uppercase();
upper.starts_with("CREATE TABLE")
|| upper.starts_with("CREATE OR REPLACE")
|| upper.starts_with("CREATE FUNCTION")
|| upper.starts_with("CREATE PROCEDURE")
|| upper.starts_with("CREATE VIEW")
|| upper.starts_with("CREATE INDEX")
|| upper.starts_with("CREATE UNIQUE INDEX")
|| upper.starts_with("CREATE TYPE")
|| upper.starts_with("ALTER TABLE")
|| upper.starts_with("CREATE SCHEMA")
}
fn is_css_rule_start(trimmed: &str) -> bool {
if trimmed.starts_with('}') || trimmed.starts_with("//") || trimmed.starts_with("/*") {
return false;
}
if trimmed.starts_with('@') {
return true;
}
if trimmed.contains('{') {
return true;
}
if trimmed.ends_with(',') && !trimmed.contains(':') {
return true;
}
let looks_like_selector = trimmed.starts_with('.')
|| trimmed.starts_with('#')
|| trimmed.starts_with('[')
|| trimmed
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic());
looks_like_selector && !trimmed.contains(';')
}
fn is_go_declaration(trimmed: &str) -> bool {
let t = trimmed.trim_start();
t.starts_with("func ") || t.starts_with("type ") || t.starts_with("package ")
}
fn find_construct_end(lines: &[&str], decl_idx: usize, lang: ContextLang) -> Option<usize> {
match lang {
ContextLang::Python => find_python_block_end(lines, decl_idx),
ContextLang::Sql => find_sql_statement_end(lines, decl_idx),
ContextLang::Css => find_brace_block_end(lines, decl_idx),
ContextLang::Rust | ContextLang::JsTs | ContextLang::Go | ContextLang::Generic => {
if let Some(end) = find_brace_block_end(lines, decl_idx) {
return Some(end);
}
find_semicolon_end(lines, decl_idx)
}
}
}
fn find_brace_block_end(lines: &[&str], decl_idx: usize) -> Option<usize> {
let mut depth = 0i32;
let mut seen_open = false;
let limit = (decl_idx + MAX_CONTEXT_LINES).min(lines.len());
for i in decl_idx..limit {
let line = lines[i];
for ch in line.chars() {
match ch {
'{' => {
depth += 1;
seen_open = true;
}
'}' => {
depth -= 1;
if seen_open && depth == 0 {
return Some(i);
}
}
_ => {}
}
}
}
None
}
fn find_semicolon_end(lines: &[&str], decl_idx: usize) -> Option<usize> {
let limit = (decl_idx + 30).min(lines.len());
for i in decl_idx..limit {
if lines[i].contains(';') {
return Some(i);
}
}
None
}
fn find_python_block_end(lines: &[&str], decl_idx: usize) -> Option<usize> {
let mut start = decl_idx;
while start < lines.len() {
let t = lines[start].trim_start();
if t.starts_with('@') {
start += 1;
continue;
}
break;
}
if start >= lines.len() {
return None;
}
let header = lines[start];
let base_indent = leading_indent(header);
if !header.trim_end().ends_with(':')
&& !lines
.get(start)
.map(|l| l.contains("def ") || l.contains("class "))
.unwrap_or(false)
{
}
let limit = (start + MAX_CONTEXT_LINES).min(lines.len());
let mut last = start;
for i in (start + 1)..limit {
let line = lines[i];
if line.trim().is_empty() {
last = i;
continue;
}
let indent = leading_indent(line);
if indent <= base_indent && !line.trim_start().starts_with('#') {
return Some(last);
}
last = i;
}
Some(last)
}
fn leading_indent(line: &str) -> usize {
line.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.map(|c| if c == '\t' { 4 } else { 1 })
.sum()
}
fn find_sql_statement_end(lines: &[&str], decl_idx: usize) -> Option<usize> {
let mut paren = 0i32;
let limit = (decl_idx + MAX_CONTEXT_LINES).min(lines.len());
for i in decl_idx..limit {
for ch in lines[i].chars() {
match ch {
'(' => paren += 1,
')' => paren -= 1,
';' if paren <= 0 => return Some(i),
_ => {}
}
}
}
let mut last = decl_idx;
for i in decl_idx..limit {
if lines[i].trim().is_empty() && i > decl_idx {
return Some(last);
}
last = i;
}
Some(last)
}
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"));
}
#[test]
fn context_includes_ten_line_window_with_line_numbers() {
let mut lines = Vec::new();
for i in 1..=25 {
lines.push(format!("line_{i}"));
}
lines[11] = format!("// {}: mid", "TODO");
let content = lines.join("\n");
let hits = extract_hits_from_content("notes.txt", &content);
assert_eq!(hits.len(), 1);
let ctx = hits[0].context.as_deref().unwrap_or("");
assert!(ctx.contains("2 | line_2"), "{ctx}");
assert!(ctx.contains("12 | // TODO: mid") || ctx.contains("TODO: mid"), "{ctx}");
assert!(ctx.contains("22 | line_22"), "{ctx}");
assert!(!ctx.contains("1 | line_1\n") || ctx.contains("1 |"), "{ctx}");
assert!(!ctx.contains("line_25"), "{ctx}");
}
#[test]
fn context_captures_entire_rust_function_when_todo_is_above() {
let content = [
"use std::io;".to_string(),
format!("// {}: handle empty path", "TODO"),
"pub fn load_config(path: &str) -> Result<String, String> {".to_string(),
" if path.is_empty() {".to_string(),
" return Err(\"empty\".into());".to_string(),
" }".to_string(),
" std::fs::read_to_string(path).map_err(|e| e.to_string())".to_string(),
"}".to_string(),
"fn other() {}".to_string(),
]
.join("\n");
let hits = extract_hits_from_content("src/config.rs", &content);
assert_eq!(hits.len(), 1);
let ctx = hits[0].context.as_deref().unwrap_or("");
assert!(ctx.contains("load_config"), "{ctx}");
assert!(ctx.contains("read_to_string"), "{ctx}");
assert!(ctx.contains("}"), "{ctx}");
assert!(
!ctx.contains("fn other"),
"should stop at function end: {ctx}"
);
}
#[test]
fn context_captures_sql_create_table_when_fixme_is_above() {
let content = [
format!(
"-- {}: existing drift is user_permission_scopes table, we migrate to grants",
"FIXME"
),
"CREATE TABLE IF NOT EXISTS athena.user_permission_scopes (".to_string(),
" id bigint NOT NULL,".to_string(),
" user_id uuid NOT NULL,".to_string(),
" scope text NOT NULL".to_string(),
");".to_string(),
"CREATE TABLE other (id int);".to_string(),
]
.join("\n");
let hits = extract_hits_from_content("sql/001_scopes.sql", &content);
assert_eq!(hits.len(), 1);
let ctx = hits[0].context.as_deref().unwrap_or("");
assert!(ctx.contains("CREATE TABLE IF NOT EXISTS athena.user_permission_scopes"), "{ctx}");
assert!(ctx.contains("scope text NOT NULL"), "{ctx}");
assert!(
!ctx.contains("CREATE TABLE other"),
"should stop at first statement: {ctx}"
);
}
#[test]
fn context_captures_python_function_body() {
let content = [
format!("# {}: validate input", "TODO"),
"def parse_row(raw: str) -> dict:".to_string(),
" if not raw:".to_string(),
" return {}".to_string(),
" return {\"v\": raw}".to_string(),
"".to_string(),
"def other():".to_string(),
" pass".to_string(),
]
.join("\n");
let hits = extract_hits_from_content("app/parse.py", &content);
assert_eq!(hits.len(), 1);
let ctx = hits[0].context.as_deref().unwrap_or("");
assert!(ctx.contains("def parse_row"), "{ctx}");
assert!(ctx.contains("return {\"v\": raw}"), "{ctx}");
assert!(!ctx.contains("def other"), "{ctx}");
}
#[test]
fn context_captures_ts_function_when_todo_inside() {
let content = [
"export function hydrate(userId: string) {".to_string(),
" const scope = { userId };".to_string(),
format!(" // {}: cache miss path", "FIXME"),
" return fetchUser(scope);".to_string(),
"}".to_string(),
"export function other() { return 1; }".to_string(),
]
.join("\n");
let hits = extract_hits_from_content("src/user.ts", &content);
assert_eq!(hits.len(), 1);
let ctx = hits[0].context.as_deref().unwrap_or("");
assert!(ctx.contains("export function hydrate"), "{ctx}");
assert!(ctx.contains("fetchUser"), "{ctx}");
assert!(!ctx.contains("function other"), "{ctx}");
}
}