tally-todo 0.12.0

Make TODO management a little more automatic
use anyhow::Result;
use std::fs;
use std::path::Path;
use std::process::Command;

const TODO_MARKER: &str = " TODO: ";
const DONE_MARKER: &str = " DONE: ";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceMarkerKind {
    Todo,
    Done,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceTodo {
    pub path: String,
    pub line: usize,
    pub text: String,
    pub kind: SourceMarkerKind,
}

impl SourceTodo {
    pub fn description(&self) -> String {
        format!("{}:{} - {}", self.path, self.line, self.text)
    }

    pub fn location(&self) -> String {
        format!("{}:{}", self.path, self.line)
    }
}

pub fn scan_project(root: &Path) -> Result<Vec<SourceTodo>> {
    let output = Command::new("git")
        .args(["ls-files"])
        .current_dir(root)
        .output()?;

    if !output.status.success() {
        anyhow::bail!("failed to list tracked files");
    }

    let mut todos = Vec::new();
    let files = String::from_utf8(output.stdout)?;
    for rel_path in files.lines().map(str::trim).filter(|l| !l.is_empty()) {
        if rel_path == "TODO.md" || rel_path == "CHANGELOG.md" {
            continue;
        }

        let file_path = root.join(rel_path);
        let bytes = match fs::read(&file_path) {
            Ok(bytes) => bytes,
            Err(_) => continue,
        };

        let content = match String::from_utf8(bytes) {
            Ok(s) => s,
            Err(_) => continue,
        };

        todos.extend(extract_todos_from_content(rel_path, &content));
    }

    Ok(todos)
}

pub fn extract_todos_from_content(path: &str, content: &str) -> Vec<SourceTodo> {
    let lines: Vec<&str> = content.lines().collect();
    let mut result = Vec::new();
    let mut i = 0;

    while i < lines.len() {
        let line = lines[i];
        let marker = if let Some(idx) = line.find(TODO_MARKER) {
            Some((idx, TODO_MARKER, SourceMarkerKind::Todo))
        } else {
            line.find(DONE_MARKER)
                .map(|idx| (idx, DONE_MARKER, SourceMarkerKind::Done))
        };
        let Some((marker_idx, marker_text, marker_kind)) = marker else {
            i += 1;
            continue;
        };

        let indent_len = line.chars().take_while(|c| c.is_whitespace()).count();
        let indent = &line[..indent_len];
        let comment_prefix = &line[indent_len..marker_idx];
        if comment_prefix.trim().is_empty() {
            i += 1;
            continue;
        }

        let mut parts = Vec::new();
        let first_part = line[marker_idx + marker_text.len()..].trim();
        if !first_part.is_empty() {
            parts.push(first_part.to_string());
        }

        let base = format!("{}{}", indent, comment_prefix);

        let mut j = i + 1;
        while j < lines.len() {
            let next = lines[j];
            if !next.starts_with(&base) {
                break;
            }

            let continuation = next[base.len()..].trim();
            if continuation.is_empty() {
                break;
            }
            parts.push(continuation.to_string());
            j += 1;
        }

        let text = parts.join(" ").trim().to_string();
        if !text.is_empty() {
            result.push(SourceTodo {
                path: path.to_string(),
                line: i + 1,
                text,
                kind: marker_kind,
            });
        }

        i = j;
    }

    result
}

#[cfg(test)]
mod tests {
    use super::{SourceMarkerKind, extract_todos_from_content};

    #[test]
    fn extracts_single_line_todo() {
        let content = "// TODO: fix parser\nlet x = 1;\n";
        let todos = extract_todos_from_content("src/main.rs", content);

        assert_eq!(todos.len(), 1);
        assert_eq!(todos[0].line, 1);
        assert_eq!(todos[0].text, "fix parser");
        assert_eq!(todos[0].kind, SourceMarkerKind::Todo);
    }

    #[test]
    fn multiline_requires_same_indent_and_prefix() {
        let content = "// TODO: fix this\n// and this too\n   whatever\nfn main() {}\n";
        let todos = extract_todos_from_content("src/main.rs", content);

        assert_eq!(todos.len(), 1);
        assert_eq!(todos[0].text, "fix this and this too");
        assert_eq!(todos[0].kind, SourceMarkerKind::Todo);
    }

    #[test]
    fn does_not_match_without_space_wrapped_marker() {
        let content = "//TODO: no match\n// TODO: yes\n";
        let todos = extract_todos_from_content("src/main.rs", content);

        assert_eq!(todos.len(), 1);
        assert_eq!(todos[0].text, "yes");
        assert_eq!(todos[0].kind, SourceMarkerKind::Todo);
    }

    #[test]
    fn extracts_done_marker() {
        let content = "# DONE: finish parser cleanup\n";
        let todos = extract_todos_from_content("src/main.rs", content);

        assert_eq!(todos.len(), 1);
        assert_eq!(todos[0].text, "finish parser cleanup");
        assert_eq!(todos[0].kind, SourceMarkerKind::Done);
    }
}