dictx 0.1.2

A fast, colorful terminal dictionary with offline indexes and optional AI explanations.
use anyhow::{anyhow, Context, Result};
use dictx_parser::{BUILTIN_KD_DATA_SOURCE, BUILTIN_NEW_CENTURY_SOURCE};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
    #[serde(default = "default_dict_dir")]
    pub dict_dir: PathBuf,
    pub index_dir: PathBuf,
    pub search: SearchConfig,
    pub output: OutputConfig,
    pub ai: AiConfig,
    pub sources: Vec<SourceConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
    pub default_limit: usize,
    pub fuzzy_distance: u8,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputConfig {
    pub color: bool,
    pub examples: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiConfig {
    pub base_url: String,
    pub model: String,
    pub api_key_env: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceConfig {
    pub name: String,
    pub path: PathBuf,
    pub format: String,
    pub display: Option<String>,
    pub enabled: bool,
}

impl SourceConfig {
    pub fn display_name(&self) -> &str {
        self.display.as_deref().unwrap_or(&self.name)
    }
}

impl AppConfig {
    pub fn resolve_path(explicit: Option<&Path>) -> Result<PathBuf> {
        if let Some(path) = explicit {
            return Ok(path.to_path_buf());
        }
        if let Ok(path) = env::var("DICTX_CONFIG") {
            return Ok(PathBuf::from(path));
        }
        let standard = standard_config_path();
        if standard.exists() {
            return Ok(standard);
        }
        let legacy = legacy_config_path()?;
        if legacy.exists() {
            return Ok(legacy);
        }
        Ok(standard)
    }

    pub fn load_or_create(explicit: Option<&Path>) -> Result<Self> {
        let path = Self::resolve_path(explicit)?;
        if path.exists() {
            let text = fs::read_to_string(&path)
                .with_context(|| format!("读取配置失败: {}", path.display()))?;
            let mut config: Self = toml::from_str(&text)
                .with_context(|| format!("解析配置失败: {}", path.display()))?;
            config.normalize_paths(&path);
            return Ok(config);
        }

        let config = Self::default_with_detected_sources()?;
        config.save_to(&path)?;
        Ok(config)
    }

    pub fn default_with_detected_sources() -> Result<Self> {
        let cwd = env::current_dir()?;
        let dict_dir = default_dict_dir();

        let mut sources = builtin_sources();
        for source in detect_sources_from(&cwd)
            .into_iter()
            .chain(detect_sources_from(&dict_dir))
        {
            push_if_exists(&mut sources, source);
        }

        Ok(Self {
            dict_dir: dict_dir.clone(),
            index_dir: default_index_dir(),
            search: SearchConfig {
                default_limit: 20,
                fuzzy_distance: 2,
            },
            output: OutputConfig {
                color: true,
                examples: true,
            },
            ai: AiConfig {
                base_url: "https://api.deepseek.com".to_string(),
                model: "deepseek-v4-flash".to_string(),
                api_key_env: "DEEPSEEK_API_KEY".to_string(),
            },
            sources,
        })
    }

    pub fn save_to(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)?;
        }
        fs::write(path, toml::to_string_pretty(self)?)?;
        Ok(())
    }

    pub fn index_dir_for(&self, source_name: &str) -> PathBuf {
        self.index_dir.join(source_name)
    }

    pub fn enabled_sources(&self, filter: Option<&str>) -> Vec<SourceConfig> {
        self.sources
            .iter()
            .filter(|source| source.enabled)
            .filter(|source| {
                filter
                    .map(|filter| source.name == filter || source.display_name() == filter)
                    .unwrap_or(true)
            })
            .cloned()
            .collect()
    }

    pub fn get_key(&self, key: &str) -> Option<String> {
        match key {
            "dict_dir" => Some(self.dict_dir.display().to_string()),
            "index_dir" => Some(self.index_dir.display().to_string()),
            "search.default_limit" => Some(self.search.default_limit.to_string()),
            "search.fuzzy_distance" => Some(self.search.fuzzy_distance.to_string()),
            "output.color" => Some(self.output.color.to_string()),
            "output.examples" => Some(self.output.examples.to_string()),
            "ai.base_url" => Some(self.ai.base_url.clone()),
            "ai.model" => Some(self.ai.model.clone()),
            "ai.api_key_env" => Some(self.ai.api_key_env.clone()),
            _ => None,
        }
    }

    pub fn set_key(&mut self, key: &str, value: &str) -> Result<()> {
        match key {
            "dict_dir" => self.dict_dir = PathBuf::from(value),
            "index_dir" => self.index_dir = PathBuf::from(value),
            "search.default_limit" => self.search.default_limit = value.parse()?,
            "search.fuzzy_distance" => self.search.fuzzy_distance = value.parse::<u8>()?.min(2),
            "output.color" => self.output.color = parse_bool(value)?,
            "output.examples" => self.output.examples = parse_bool(value)?,
            "ai.base_url" => self.ai.base_url = value.trim_end_matches('/').to_string(),
            "ai.model" => self.ai.model = value.to_string(),
            "ai.api_key_env" => self.ai.api_key_env = value.to_string(),
            _ => return Err(anyhow!("未知配置项: {key}")),
        }
        Ok(())
    }

    fn normalize_paths(&mut self, config_path: &Path) {
        let base = config_path.parent().unwrap_or_else(|| Path::new("."));
        if self.dict_dir.is_relative() {
            self.dict_dir = base.join(&self.dict_dir);
        }
        if self.index_dir.is_relative() {
            self.index_dir = base.join(&self.index_dir);
        }
        for source in &mut self.sources {
            if source.path.is_relative() && !is_virtual_source_path(&source.path) {
                source.path = base.join(&source.path);
            }
        }
    }
}

pub fn standard_config_path() -> PathBuf {
    dirs::config_dir()
        .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
        .unwrap_or_else(|| PathBuf::from("."))
        .join("dictx")
        .join("config.toml")
}

pub fn default_dict_dir() -> PathBuf {
    dirs::data_dir()
        .or_else(|| dirs::home_dir().map(|home| home.join(".local").join("share")))
        .unwrap_or_else(|| PathBuf::from("."))
        .join("dictx")
        .join("sources")
}

pub fn default_index_dir() -> PathBuf {
    dirs::cache_dir()
        .or_else(|| dirs::home_dir().map(|home| home.join(".cache")))
        .unwrap_or_else(|| PathBuf::from("."))
        .join("dictx")
        .join("indexes")
}

fn legacy_config_path() -> Result<PathBuf> {
    let home = dirs::home_dir().ok_or_else(|| anyhow!("无法定位 HOME 目录"))?;
    Ok(home.join(".dictx").join("config.toml"))
}

fn builtin_sources() -> Vec<SourceConfig> {
    vec![
        SourceConfig {
            name: "builtin_new_century".to_string(),
            path: PathBuf::from(BUILTIN_NEW_CENTURY_SOURCE),
            format: "builtin-dxdict".to_string(),
            display: Some("新世纪汉英大词典".to_string()),
            enabled: true,
        },
        SourceConfig {
            name: "builtin_kd_data".to_string(),
            path: PathBuf::from(BUILTIN_KD_DATA_SOURCE),
            format: "builtin-dxdict".to_string(),
            display: Some("金山词霸英汉词库".to_string()),
            enabled: true,
        },
    ]
}

fn is_virtual_source_path(path: &Path) -> bool {
    path.to_string_lossy().starts_with("builtin:")
}

pub fn detect_sources_from(start: &Path) -> Vec<SourceConfig> {
    let mut sources = Vec::new();

    for base in start.ancestors() {
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "ecdict".to_string(),
                path: base.join("ecdict.csv"),
                format: "ecdict".to_string(),
                display: Some("ECDICT 英汉双解".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "ecdict".to_string(),
                path: base.join("ecdict").join("ecdict.csv"),
                format: "ecdict".to_string(),
                display: Some("ECDICT 英汉双解".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "ecdict".to_string(),
                path: base.join("data").join("ecdict.csv"),
                format: "ecdict".to_string(),
                display: Some("ECDICT 英汉双解".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "cc_cedict".to_string(),
                path: base.join("cedict_ts.u8"),
                format: "cedict".to_string(),
                display: Some("CC-CEDICT 汉英词典".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "cc_cedict".to_string(),
                path: base.join("cc_cedict").join("cedict_ts.u8"),
                format: "cedict".to_string(),
                display: Some("CC-CEDICT 汉英词典".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "cc_cedict".to_string(),
                path: base.join("data").join("cedict_ts.u8"),
                format: "cedict".to_string(),
                display: Some("CC-CEDICT 汉英词典".to_string()),
                enabled: true,
            },
        );
        push_if_exists(
            &mut sources,
            SourceConfig {
                name: "cc_cedict".to_string(),
                path: base.join("old").join("cedict_ts.u8"),
                format: "cedict".to_string(),
                display: Some("CC-CEDICT 汉英词典".to_string()),
                enabled: true,
            },
        );
    }

    sources
}

fn push_if_exists(sources: &mut Vec<SourceConfig>, source: SourceConfig) {
    if !source.path.exists() || sources.iter().any(|item| item.name == source.name) {
        return;
    }
    sources.push(source);
}

fn parse_bool(value: &str) -> Result<bool> {
    match value.trim().to_ascii_lowercase().as_str() {
        "1" | "true" | "yes" | "on" => Ok(true),
        "0" | "false" | "no" | "off" => Ok(false),
        _ => Err(anyhow!("布尔值应为 true/false")),
    }
}

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

    #[test]
    fn set_key_updates_nested_value() {
        let mut config = AppConfig::default_with_detected_sources().unwrap();
        config.set_key("search.default_limit", "30").unwrap();
        assert_eq!(config.search.default_limit, 30);
    }

    #[test]
    fn default_config_includes_builtin_dictionary() {
        let config = AppConfig::default_with_detected_sources().unwrap();
        let new_century = config
            .sources
            .iter()
            .find(|source| source.name == "builtin_new_century")
            .unwrap();
        let kd = config
            .sources
            .iter()
            .find(|source| source.name == "builtin_kd_data")
            .unwrap();

        assert_eq!(new_century.format, "builtin-dxdict");
        assert_eq!(new_century.path, PathBuf::from(BUILTIN_NEW_CENTURY_SOURCE));
        assert!(new_century.enabled);
        assert_eq!(kd.format, "builtin-dxdict");
        assert_eq!(kd.path, PathBuf::from(BUILTIN_KD_DATA_SOURCE));
        assert!(kd.enabled);
    }

    #[test]
    fn normalize_paths_keeps_builtin_virtual_path() {
        let mut config = AppConfig::default_with_detected_sources().unwrap();
        let config_path = Path::new("/tmp/dictx/config.toml");

        config.normalize_paths(config_path);

        let source = config
            .sources
            .iter()
            .find(|source| source.name == "builtin_new_century")
            .unwrap();
        assert_eq!(source.path, PathBuf::from(BUILTIN_NEW_CENTURY_SOURCE));
    }

    #[test]
    fn detects_sources_from_parent_directories() {
        let temp = tempfile::tempdir().unwrap();
        let data = temp.path().join("data");
        let nested = temp.path().join("target").join("release");
        fs::create_dir_all(&data).unwrap();
        fs::create_dir_all(&nested).unwrap();
        fs::write(data.join("ecdict.csv"), "").unwrap();

        let sources = detect_sources_from(&nested);

        assert_eq!(sources.len(), 1);
        assert!(sources.iter().any(|source| source.name == "ecdict"));
    }
}