xbp 10.38.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Walk a codebase and extract TODO/FIXME-style markers.

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;

// C-style / SQL / Lisp line comments (may trail code).
// `///` / `//!` doc comments are rejected after the match (regex crate has no lookaround).
// `\b` after the kind avoids matching section headers like `// TODOs (`xbp todos`)`.
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")
});

// Shell/Python `#` comments only at line start; excludes markdown `##` headers.
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, // binary / non-utf8
        };
        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))
    });
    // Dedupe identical fingerprints (keep first/earliest line).
    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;
        // Skip Rust/C++ doc-comment lines entirely (`///`, `//!`).
        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(|| {
                // Reject markdown headings like `## TODO`
                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())
}

/// True when a `// TODO` match is actually inside `///` or `////` (doc comment).
fn is_doc_comment_slash_match(line: &str, caps: &regex::Captures<'_>) -> bool {
    let Some(whole) = caps.get(0) else {
        return false;
    };
    let start = whole.start();
    let bytes = line.as_bytes();
    // Match began at a `//` that is preceded by `/` → part of `///`…
    if start > 0 && bytes[start - 1] == b'/' {
        return true;
    }
    // Or `//` is immediately followed by another `/` before the marker (///TODO).
    if bytes.get(start + 2) == Some(&b'/') {
        return true;
    }
    false
}

/// Rough string-literal check so clap `about = "…// TODO…"` is not filed as a TODO.
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"
        // Docs / trackers — not actionable code TODOs
        || name == "todo.md"
        || name == "readme.md"
        || name == "changelog.md"
        || name == "agents.md"
        || name.ends_with(".mdx")
    {
        return true;
    }
    // Skip generated MCP catalog path segments
    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() {
        // Build markers at runtime so this source file is not itself a scan hit.
        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() {
        // Build the false-positive line without embedding a live marker comment in this file.
        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"));
    }
}