nolo 0.1.1

A CLI tool for discovering and analyzing `TODO` comments across codebases.
Documentation
use std::fs::read_to_string;
use std::path::{Path, PathBuf};

use commentator::{Tokenizer, spec};

#[derive(Debug, Clone)]
pub struct TodoComment {
    pub text: String,
    pub line: usize,
}

pub fn extract_todo_comments_with_language_detection(
    path: PathBuf,
    keywords: &[String],
) -> Vec<TodoComment> {
    let spec = detect_comment_spec(&path);
    extract_individual_todo_comments(path, &spec, keywords)
}

fn extract_individual_todo_comments(
    path: PathBuf,
    spec: &spec::StandardSpec,
    keywords: &[String],
) -> Vec<TodoComment> {
    let mut todo_comments = Vec::new();

    for (line_num, line) in string_content(path.clone()).lines().enumerate() {
        let mut tkn = Tokenizer::new(spec);
        tkn.update(line_num + 1, line);

        while let Some(cmt) = tkn.take() {
            if keywords
                .iter()
                .any(|keyword| comment_starts_with_keyword(&cmt.text, keyword))
            {
                let individual_todos =
                    extract_individual_todos_from_text(&cmt.text, line_num + 1, keywords);
                todo_comments.extend(individual_todos);
            }
        }

        tkn.finish();
        if let Some(cmt) = tkn.take() {
            if keywords
                .iter()
                .any(|keyword| comment_starts_with_keyword(&cmt.text, keyword))
            {
                let individual_todos =
                    extract_individual_todos_from_text(&cmt.text, line_num + 1, keywords);
                todo_comments.extend(individual_todos);
            }
        }
    }

    todo_comments
}

fn comment_starts_with_keyword(comment_text: &str, keyword: &str) -> bool {
    let trimmed = comment_text.trim();
    let upper = trimmed.to_uppercase();

    if upper.starts_with(keyword) {
        let after_keyword = &trimmed[keyword.len()..];
        after_keyword.is_empty()
            || after_keyword.starts_with(':')
            || after_keyword.starts_with(' ')
            || after_keyword.starts_with('\t')
    } else {
        false
    }
}

fn extract_individual_todos_from_text(
    comment_text: &str,
    line_num: usize,
    keywords: &[String],
) -> Vec<TodoComment> {
    let mut todos = Vec::new();

    for keyword in keywords {
        if comment_starts_with_keyword(comment_text, keyword) {
            todos.push(TodoComment {
                text: comment_text.trim().to_string(),
                line: line_num,
            });
            break;
        }
    }

    todos
}

fn detect_comment_spec(path: &Path) -> spec::StandardSpec {
    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
        match extension {
            // C-style comments: // and /* */
            "rs" | "js" | "ts" | "jsx" | "tsx" | "c" | "cpp" | "cc" | "cxx" | "h" | "hpp"
            | "java" | "go" | "cs" => spec::StandardSpec::C,
            // Bash-style comments: #
            "py" | "sh" | "bash" | "zsh" | "fish" | "rb" | "pl" | "yaml" | "yml" | "toml"
            | "cfg" | "ini" | "nix" => spec::StandardSpec::Bash,
            // HTML-style comments: <!-- -->
            "html" | "htm" | "xml" | "svg" => spec::StandardSpec::HTML,
            // Default to C-style for unknown extensions
            _ => spec::StandardSpec::C,
        }
    } else {
        // No extension, default to C-style
        spec::StandardSpec::C
    }
}

fn string_content(path: PathBuf) -> String {
    match read_to_string(&path) {
        Ok(s) => s,
        Err(e) => {
            tracing::debug!("Error reading file {:?}: {}", path, e);
            String::new()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn test_individual_todo_extraction() {
        let rust_file = PathBuf::from("tests/sample.rs");
        let keywords = vec![
            "TODO".to_string(),
            "FIXME".to_string(),
            "NOTE".to_string(),
            "HACK".to_string(),
            "BUG".to_string(),
        ];
        let todos = extract_todo_comments_with_language_detection(rust_file, &keywords);

        assert!(todos.len() >= 5);
        assert!(todos.iter().any(|t| t.line > 0));

        let todo_texts: Vec<&str> = todos.iter().map(|t| t.text.as_str()).collect();
        assert!(
            todo_texts
                .iter()
                .any(|&text| text.contains("TODO: Implement error handling"))
        );
        assert!(
            todo_texts
                .iter()
                .any(|&text| text.contains("FIXME: This is inefficient"))
        );
        assert!(
            todo_texts
                .iter()
                .any(|&text| text.contains("NOTE: Consider using"))
        );
    }

    #[test]
    fn test_python_todo_extraction() {
        let python_file = PathBuf::from("tests/sample.py");
        let keywords = vec![
            "TODO".to_string(),
            "FIXME".to_string(),
            "NOTE".to_string(),
            "HACK".to_string(),
            "BUG".to_string(),
        ];
        let todos = extract_todo_comments_with_language_detection(python_file, &keywords);

        assert!(todos.len() >= 4);

        let todo_texts: Vec<&str> = todos.iter().map(|t| t.text.as_str()).collect();
        assert!(
            todo_texts
                .iter()
                .any(|&text| text.contains("TODO: Add proper logging"))
        );
        assert!(todo_texts.iter().any(|&text| text.contains("FIXME:")));
    }

    #[test]
    fn test_todo_comment_structure() {
        let rust_file = PathBuf::from("tests/sample.rs");
        let keywords = vec![
            "TODO".to_string(),
            "FIXME".to_string(),
            "NOTE".to_string(),
            "HACK".to_string(),
            "BUG".to_string(),
        ];
        let todos = extract_todo_comments_with_language_detection(rust_file, &keywords);

        let todo_with_line = todos
            .iter()
            .find(|t| t.text.contains("TODO: Implement error handling"));
        assert!(todo_with_line.is_some());

        let todo = todo_with_line.unwrap();
        assert!(todo.line > 0, "Should have valid line number");
        assert!(!todo.text.is_empty(), "Should have non-empty text");
    }
}