nolo 0.1.0

A CLI tool for discovering and analyzing `TODO` comments across codebases.
Documentation
use crate::comment::TodoComment;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoAnalytics {
    pub total_todos: usize,
    pub files_with_todos: usize,
    pub total_files_scanned: usize,
    pub keyword_counts: HashMap<String, usize>,
    pub language_stats: HashMap<String, LanguageStats>,
    pub top_files: Vec<FileStats>,
    pub scan_path: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageStats {
    pub todo_count: usize,
    pub file_count: usize,
    pub extensions: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileStats {
    pub path: PathBuf,
    pub todo_count: usize,
}

impl TodoAnalytics {
    pub fn new(scan_path: PathBuf) -> Self {
        Self {
            total_todos: 0,
            files_with_todos: 0,
            total_files_scanned: 0,
            keyword_counts: HashMap::new(),
            language_stats: HashMap::new(),
            top_files: Vec::new(),
            scan_path,
        }
    }

    pub fn todo_density(&self) -> f64 {
        if self.total_files_scanned == 0 {
            0.0
        } else {
            (self.files_with_todos as f64 / self.total_files_scanned as f64) * 100.0
        }
    }
}

pub fn analyze_todos(
    file_todos: Vec<(PathBuf, Vec<TodoComment>)>,
    total_files_scanned: usize,
    scan_path: PathBuf,
    display_limit: Option<usize>,
    keywords: &[String],
) -> TodoAnalytics {
    let mut analytics = TodoAnalytics::new(scan_path.clone());
    analytics.total_files_scanned = total_files_scanned;

    let mut file_stats: Vec<FileStats> = Vec::new();

    for (file_path, todos) in file_todos {
        if !todos.is_empty() {
            analytics.files_with_todos += 1;
            analytics.total_todos += todos.len();

            let relative_path = if scan_path.is_file() && file_path == scan_path {
                scan_path
                    .file_name()
                    .map(PathBuf::from)
                    .unwrap_or_else(|| file_path.clone())
            } else if let Ok(relative) = file_path.strip_prefix(&scan_path) {
                relative.to_path_buf()
            } else {
                file_path.clone()
            };
            file_stats.push(FileStats {
                path: relative_path,
                todo_count: todos.len(),
            });

            for todo in &todos {
                let keyword = extract_keyword_from_text(&todo.text, keywords);
                *analytics.keyword_counts.entry(keyword).or_insert(0) += 1;
            }

            let language = detect_language(&file_path);
            let extension = file_path
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("")
                .to_string();

            let lang_stats =
                analytics
                    .language_stats
                    .entry(language.clone())
                    .or_insert(LanguageStats {
                        todo_count: 0,
                        file_count: 0,
                        extensions: Vec::new(),
                    });

            lang_stats.todo_count += todos.len();
            lang_stats.file_count += 1;
            if !extension.is_empty() && !lang_stats.extensions.contains(&format!(".{}", extension))
            {
                lang_stats.extensions.push(format!(".{}", extension));
            }
        }
    }

    file_stats.sort_by(|a, b| b.todo_count.cmp(&a.todo_count));
    analytics.top_files = match display_limit {
        Some(limit) => file_stats.into_iter().take(limit).collect(),
        None => file_stats,
    };

    analytics
}

fn extract_keyword_from_text(text: &str, keywords: &[String]) -> String {
    let text_upper = text.to_uppercase();
    for keyword in keywords {
        let keyword_upper = keyword.to_uppercase();
        if text_upper.starts_with(&keyword_upper) {
            return keyword_upper;
        }
    }

    // Fallback: if no keyword found at start, look for any keyword in text
    for keyword in keywords {
        let keyword_upper = keyword.to_uppercase();
        if text_upper.contains(&keyword_upper) {
            return keyword_upper;
        }
    }

    "UNKNOWN".to_string()
}

fn detect_language(path: &Path) -> String {
    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
        match extension {
            "rs" => "Rust".to_string(),
            "py" => "Python".to_string(),
            "js" | "mjs" => "JavaScript".to_string(),
            "ts" => "TypeScript".to_string(),
            "jsx" => "React JSX".to_string(),
            "tsx" => "React TSX".to_string(),
            "c" => "C".to_string(),
            "cpp" | "cc" | "cxx" => "C++".to_string(),
            "h" | "hpp" => "C/C++ Header".to_string(),
            "java" => "Java".to_string(),
            "go" => "Go".to_string(),
            "cs" => "C#".to_string(),
            "sh" | "bash" => "Shell".to_string(),
            "zsh" => "Zsh".to_string(),
            "fish" => "Fish".to_string(),
            "rb" => "Ruby".to_string(),
            "pl" => "Perl".to_string(),
            "yaml" | "yml" => "YAML".to_string(),
            "toml" => "TOML".to_string(),
            "nix" => "Nix".to_string(),
            "html" | "htm" => "HTML".to_string(),
            "xml" => "XML".to_string(),
            "svg" => "SVG".to_string(),
            _ => format!("Unknown (.{})", extension),
        }
    } else {
        "No Extension".to_string()
    }
}