nolo 0.1.1

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

// Default TODO keywords used when no config is provided
const DEFAULT_TODO_KEYWORDS: &[&str] = &["TODO", "FIXME", "HACK", "NOTE", "BUG"];

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub default_path: Option<String>,
    pub keywords: Option<Vec<String>>,
    pub ignore_patterns: Option<Vec<String>>,
    pub report: Option<ReportConfig>,
    pub languages: Option<std::collections::HashMap<String, String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportConfig {
    pub default_format: Option<String>,
    pub default_limit: Option<usize>,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            default_path: Some(".".to_string()),
            keywords: Some(
                DEFAULT_TODO_KEYWORDS
                    .iter()
                    .map(|s| s.to_string())
                    .collect(),
            ),
            ignore_patterns: None,
            report: Some(ReportConfig {
                default_format: Some("table".to_string()),
                default_limit: Some(5),
            }),
            languages: None,
        }
    }
}

impl Config {
    pub fn load(config_path: Option<&Path>) -> Result<Self, NoloError> {
        if let Some(path) = config_path {
            return Self::load_from_path(path);
        }

        let search_paths = Self::get_config_search_paths();

        for path in search_paths {
            if path.exists() {
                return Self::load_from_path(&path);
            }
        }

        tracing::debug!("No config file found, using defaults");
        Ok(Self::default())
    }

    fn load_from_path(path: &Path) -> Result<Self, NoloError> {
        let content = fs::read_to_string(path).map_err(|e| {
            NoloError::ConfigError(format!("Failed to read config file {:?}: {}", path, e))
        })?;

        let config: Config = toml::from_str(&content).map_err(|e| {
            NoloError::ConfigError(format!("Failed to parse config file {:?}: {}", path, e))
        })?;

        Ok(config)
    }

    fn get_config_search_paths() -> Vec<PathBuf> {
        let mut paths = Vec::new();

        paths.push(PathBuf::from("nolo.toml"));
        paths.push(PathBuf::from(".nolo.toml"));

        if let Some(git_root) = crate::discover::git_root() {
            let git_config = PathBuf::from(&git_root).join("nolo.toml");
            let git_config_hidden = PathBuf::from(&git_root).join(".nolo.toml");
            if git_config != PathBuf::from("nolo.toml") {
                paths.push(git_config);
            }
            if git_config_hidden != PathBuf::from(".nolo.toml") {
                paths.push(git_config_hidden);
            }
        }

        if let Some(config_dir) = dirs::config_dir() {
            paths.push(config_dir.join("nolo").join("config.toml"));
        }

        if let Some(home_dir) = dirs::home_dir() {
            paths.push(home_dir.join(".nolo.toml"));
        }

        paths
    }

    pub fn get_keywords(&self) -> Vec<String> {
        self.keywords.clone().unwrap_or_else(|| {
            DEFAULT_TODO_KEYWORDS
                .iter()
                .map(|s| s.to_string())
                .collect()
        })
    }

    pub fn get_default_path(&self) -> String {
        self.default_path.clone().unwrap_or_else(|| ".".to_string())
    }

    pub fn get_report_format(&self) -> String {
        self.report
            .as_ref()
            .and_then(|r| r.default_format.clone())
            .unwrap_or_else(|| "table".to_string())
    }

    pub fn get_report_limit(&self) -> usize {
        self.report
            .as_ref()
            .and_then(|r| r.default_limit)
            .unwrap_or(5)
    }
}

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

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.get_default_path(), ".");
        assert!(config.get_keywords().contains(&"TODO".to_string()));
        assert_eq!(config.get_report_format(), "table");
        assert_eq!(config.get_report_limit(), 5);
    }

    #[test]
    fn test_config_parsing() {
        let toml_content = r#"
default_path = "/custom/path"
keywords = ["TODO", "CUSTOM"]

[report]
default_format = "json"
default_limit = 10
"#;

        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.get_default_path(), "/custom/path");
        assert_eq!(config.get_keywords(), vec!["TODO", "CUSTOM"]);
        assert_eq!(config.get_report_format(), "json");
        assert_eq!(config.get_report_limit(), 10);
    }

    #[test]
    fn test_load_nonexistent_config() {
        let result = Config::load(Some(Path::new("nonexistent.toml")));
        assert!(result.is_err());
    }
}