1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct TlConfig {
10 pub provider: Option<String>,
12 pub model: Option<String>,
14 pub to: Option<String>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ProviderConfig {
23 pub endpoint: String,
25 #[serde(default)]
27 pub api_key: Option<String>,
28 #[serde(default)]
30 pub api_key_env: Option<String>,
31 #[serde(default)]
33 pub models: Vec<String>,
34}
35
36impl ProviderConfig {
37 pub fn get_api_key(&self) -> Option<String> {
39 if let Some(env_var) = &self.api_key_env
40 && let Ok(key) = std::env::var(env_var)
41 && !key.is_empty()
42 {
43 return Some(key);
44 }
45 self.api_key.clone()
46 }
47
48 pub const fn requires_api_key(&self) -> bool {
50 self.api_key.is_some() || self.api_key_env.is_some()
51 }
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct ConfigFile {
59 #[serde(default)]
61 pub tl: TlConfig,
62 #[serde(default)]
64 pub providers: HashMap<String, ProviderConfig>,
65}
66
67#[derive(Debug, Clone)]
69pub struct ResolvedConfig {
70 pub provider_name: String,
72 pub endpoint: String,
74 pub model: String,
76 pub api_key: Option<String>,
78 pub target_language: String,
80}
81
82pub struct ConfigManager {
84 config_path: PathBuf,
85}
86
87impl ConfigManager {
88 pub fn new() -> Result<Self> {
92 let config_dir = dirs::home_dir()
93 .context("Failed to determine home directory")?
94 .join(".config")
95 .join("tl");
96
97 Ok(Self {
98 config_path: config_dir.join("config.toml"),
99 })
100 }
101
102 pub const fn config_path(&self) -> &PathBuf {
103 &self.config_path
104 }
105
106 pub fn load(&self) -> Result<ConfigFile> {
107 let contents = fs::read_to_string(&self.config_path).with_context(|| {
108 format!("Failed to read config file: {}", self.config_path.display())
109 })?;
110
111 let config_file: ConfigFile =
112 toml::from_str(&contents).with_context(|| "Failed to parse config file")?;
113
114 Ok(config_file)
115 }
116
117 pub fn save(&self, config: &ConfigFile) -> Result<()> {
118 if let Some(parent) = self.config_path.parent() {
119 fs::create_dir_all(parent).with_context(|| {
120 format!("Failed to create config directory: {}", parent.display())
121 })?;
122 }
123
124 let contents = toml::to_string_pretty(config).context("Failed to serialize config")?;
125
126 fs::write(&self.config_path, contents).with_context(|| {
127 format!(
128 "Failed to write config file: {}",
129 self.config_path.display()
130 )
131 })?;
132
133 Ok(())
134 }
135
136 pub fn load_or_default(&self) -> ConfigFile {
137 self.load().unwrap_or_default()
138 }
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used)]
143mod tests {
144 use super::*;
145 use tempfile::TempDir;
146
147 fn create_test_manager(temp_dir: &TempDir) -> ConfigManager {
148 ConfigManager {
149 config_path: temp_dir.path().join("config.toml"),
150 }
151 }
152
153 #[test]
154 fn test_save_and_load_config() {
155 let temp_dir = TempDir::new().unwrap();
156 let manager = create_test_manager(&temp_dir);
157
158 let mut providers = HashMap::new();
159 providers.insert(
160 "ollama".to_string(),
161 ProviderConfig {
162 endpoint: "http://localhost:11434".to_string(),
163 api_key: None,
164 api_key_env: None,
165 models: vec!["gemma3:12b".to_string(), "llama3.2".to_string()],
166 },
167 );
168
169 let config = ConfigFile {
170 tl: TlConfig {
171 provider: Some("ollama".to_string()),
172 model: Some("gemma3:12b".to_string()),
173 to: Some("ja".to_string()),
174 },
175 providers,
176 };
177
178 manager.save(&config).unwrap();
179 let loaded = manager.load().unwrap();
180
181 assert_eq!(loaded.tl.provider, Some("ollama".to_string()));
182 assert_eq!(loaded.tl.model, Some("gemma3:12b".to_string()));
183 assert_eq!(loaded.tl.to, Some("ja".to_string()));
184 assert!(loaded.providers.contains_key("ollama"));
185 }
186
187 #[test]
188 fn test_load_nonexistent_config() {
189 let temp_dir = TempDir::new().unwrap();
190 let manager = create_test_manager(&temp_dir);
191
192 let result = manager.load();
193 assert!(result.is_err());
194 }
195
196 #[test]
197 fn test_provider_get_api_key_from_env() {
198 unsafe {
200 std::env::set_var("TEST_API_KEY", "test-key-value");
201 }
202
203 let provider = ProviderConfig {
204 endpoint: "https://api.example.com".to_string(),
205 api_key: Some("fallback-key".to_string()),
206 api_key_env: Some("TEST_API_KEY".to_string()),
207 models: vec![],
208 };
209
210 assert_eq!(provider.get_api_key(), Some("test-key-value".to_string()));
212
213 unsafe {
215 std::env::remove_var("TEST_API_KEY");
216 }
217 }
218
219 #[test]
220 fn test_provider_get_api_key_fallback() {
221 unsafe {
223 std::env::remove_var("NONEXISTENT_KEY");
224 }
225
226 let provider = ProviderConfig {
227 endpoint: "https://api.example.com".to_string(),
228 api_key: Some("fallback-key".to_string()),
229 api_key_env: Some("NONEXISTENT_KEY".to_string()),
230 models: vec![],
231 };
232
233 assert_eq!(provider.get_api_key(), Some("fallback-key".to_string()));
235 }
236
237 #[test]
238 fn test_provider_requires_api_key() {
239 let provider_with_key = ProviderConfig {
240 endpoint: "https://api.example.com".to_string(),
241 api_key: Some("key".to_string()),
242 api_key_env: None,
243 models: vec![],
244 };
245 assert!(provider_with_key.requires_api_key());
246
247 let provider_with_env = ProviderConfig {
248 endpoint: "https://api.example.com".to_string(),
249 api_key: None,
250 api_key_env: Some("API_KEY".to_string()),
251 models: vec![],
252 };
253 assert!(provider_with_env.requires_api_key());
254
255 let provider_without = ProviderConfig {
256 endpoint: "http://localhost:11434".to_string(),
257 api_key: None,
258 api_key_env: None,
259 models: vec![],
260 };
261 assert!(!provider_without.requires_api_key());
262 }
263}