tl_cli/config/
manager.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7/// Default settings in the `[tl]` section of config.toml.
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct TlConfig {
10    /// Default provider name.
11    pub provider: Option<String>,
12    /// Default model name.
13    pub model: Option<String>,
14    /// Default target language (ISO 639-1 code).
15    pub to: Option<String>,
16}
17
18/// Configuration for a translation provider.
19///
20/// Each provider has an endpoint and optional API key settings.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ProviderConfig {
23    /// The OpenAI-compatible API endpoint URL.
24    pub endpoint: String,
25    /// API key stored directly in config (not recommended).
26    #[serde(default)]
27    pub api_key: Option<String>,
28    /// Environment variable name containing the API key.
29    #[serde(default)]
30    pub api_key_env: Option<String>,
31    /// List of available models for this provider.
32    #[serde(default)]
33    pub models: Vec<String>,
34}
35
36impl ProviderConfig {
37    /// Gets the API key, preferring environment variable over config file.
38    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    /// Returns `true` if this provider requires an API key.
49    pub const fn requires_api_key(&self) -> bool {
50        self.api_key.is_some() || self.api_key_env.is_some()
51    }
52}
53
54/// The complete configuration file structure.
55///
56/// Corresponds to `~/.config/tl/config.toml`.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct ConfigFile {
59    /// Default settings.
60    #[serde(default)]
61    pub tl: TlConfig,
62    /// Provider configurations keyed by name.
63    #[serde(default)]
64    pub providers: HashMap<String, ProviderConfig>,
65}
66
67/// Resolved configuration after merging CLI arguments and config file.
68#[derive(Debug, Clone)]
69pub struct ResolvedConfig {
70    /// The selected provider name.
71    pub provider_name: String,
72    /// The API endpoint URL.
73    pub endpoint: String,
74    /// The model to use for translation.
75    pub model: String,
76    /// The API key (if required).
77    pub api_key: Option<String>,
78    /// The target language code.
79    pub target_language: String,
80}
81
82/// Manages loading and saving configuration files.
83pub struct ConfigManager {
84    config_path: PathBuf,
85}
86
87impl ConfigManager {
88    /// Creates a new config manager.
89    ///
90    /// Configuration is stored at `~/.config/tl/config.toml`.
91    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        // SAFETY: This test runs in isolation and only modifies a test-specific env var
199        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        // Environment variable takes priority
211        assert_eq!(provider.get_api_key(), Some("test-key-value".to_string()));
212
213        // SAFETY: Cleanup test env var
214        unsafe {
215            std::env::remove_var("TEST_API_KEY");
216        }
217    }
218
219    #[test]
220    fn test_provider_get_api_key_fallback() {
221        // SAFETY: This test runs in isolation and only modifies a test-specific env var
222        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        // Falls back to api_key when env var not set
234        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}