use anyhow::{anyhow, Context, Result};
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();
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: detect_sources_from(&cwd)
.into_iter()
.chain(detect_sources_from(&dict_dir))
.fold(Vec::new(), |mut sources, source| {
push_if_exists(&mut sources, source);
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() {
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"))
}
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: "kao_yan".to_string(),
path: base.join("KaoYan_3.json"),
format: "anki-jsonl".to_string(),
display: Some("考研英语词汇".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kao_yan".to_string(),
path: base.join("kao_yan").join("KaoYan_3.json"),
format: "anki-jsonl".to_string(),
display: Some("考研英语词汇".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kao_yan".to_string(),
path: base.join("old").join("KaoYan_3.json"),
format: "anki-jsonl".to_string(),
display: Some("考研英语词汇".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kao_yan".to_string(),
path: base.join("data").join("KaoYan_3.json"),
format: "anki-jsonl".to_string(),
display: Some("考研英语词汇".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kd_db".to_string(),
path: base.join("kd_data.db"),
format: "sqlite".to_string(),
display: Some("金山词霸 SQLite".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kd_db".to_string(),
path: base.join("kd_db").join("kd_data.db"),
format: "sqlite".to_string(),
display: Some("金山词霸 SQLite".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kd_db".to_string(),
path: base.join("old").join("kd_data.db"),
format: "sqlite".to_string(),
display: Some("金山词霸 SQLite".to_string()),
enabled: true,
},
);
push_if_exists(
&mut sources,
SourceConfig {
name: "kd_db".to_string(),
path: base.join("data").join("kd_data.db"),
format: "sqlite".to_string(),
display: Some("金山词霸 SQLite".to_string()),
enabled: true,
},
);
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 detects_sources_from_parent_directories() {
let temp = tempfile::tempdir().unwrap();
let old = temp.path().join("old");
let nested = temp.path().join("target").join("release");
fs::create_dir_all(&old).unwrap();
fs::create_dir_all(&nested).unwrap();
fs::write(old.join("KaoYan_3.json"), "{}\n").unwrap();
fs::write(old.join("kd_data.db"), "").unwrap();
let sources = detect_sources_from(&nested);
assert_eq!(sources.len(), 2);
assert!(sources.iter().any(|source| source.name == "kao_yan"));
assert!(sources.iter().any(|source| source.name == "kd_db"));
}
}