1use std::fs;
13use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{NikaError, Result};
18use crate::util::atomic_write;
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
22pub struct NikaConfig {
23 #[serde(default)]
25 pub api_keys: ApiKeys,
26
27 #[serde(default)]
29 pub defaults: Defaults,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34pub struct ApiKeys {
35 pub anthropic: Option<String>,
37
38 pub openai: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
44pub struct Defaults {
45 pub provider: Option<String>,
47
48 pub model: Option<String>,
50}
51
52impl NikaConfig {
53 pub fn config_dir() -> PathBuf {
57 dirs::config_dir()
58 .unwrap_or_else(|| PathBuf::from("."))
59 .join("nika")
60 }
61
62 pub fn config_path() -> PathBuf {
66 Self::config_dir().join("config.toml")
67 }
68
69 pub fn load() -> Result<Self> {
74 let path = Self::config_path();
75
76 if !path.exists() {
77 return Ok(Self::default());
78 }
79
80 let content = fs::read_to_string(&path).map_err(|e| NikaError::ConfigError {
81 reason: format!("Failed to read config file: {}", e),
82 })?;
83
84 toml::from_str(&content).map_err(|e| NikaError::ConfigError {
85 reason: format!("Failed to parse config file: {}", e),
86 })
87 }
88
89 pub fn save(&self) -> Result<()> {
94 let path = Self::config_path();
95
96 let content = toml::to_string_pretty(self).map_err(|e| NikaError::ConfigError {
98 reason: format!("Failed to serialize config: {}", e),
99 })?;
100
101 atomic_write(&path, content.as_bytes()).map_err(|e| NikaError::ConfigError {
103 reason: format!("Failed to write config file: {}", e),
104 })?;
105
106 Ok(())
107 }
108
109 pub fn with_env(mut self) -> Self {
113 if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
115 if !key.is_empty() {
116 self.api_keys.anthropic = Some(key);
117 }
118 }
119
120 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
122 if !key.is_empty() {
123 self.api_keys.openai = Some(key);
124 }
125 }
126
127 self
128 }
129
130 pub fn anthropic_key(&self) -> Option<&str> {
134 self.api_keys.anthropic.as_deref()
135 }
136
137 pub fn openai_key(&self) -> Option<&str> {
139 self.api_keys.openai.as_deref()
140 }
141
142 pub fn has_any_key(&self) -> bool {
144 self.api_keys.anthropic.is_some() || self.api_keys.openai.is_some()
145 }
146
147 pub fn default_provider(&self) -> Option<&str> {
149 self.defaults.provider.as_deref().or_else(|| {
150 if self.api_keys.anthropic.is_some() {
152 Some("claude")
153 } else if self.api_keys.openai.is_some() {
154 Some("openai")
155 } else {
156 None
157 }
158 })
159 }
160
161 pub fn default_model(&self) -> Option<&str> {
163 self.defaults.model.as_deref()
164 }
165}
166
167pub fn mask_api_key(key: &str, visible_chars: usize) -> String {
171 if key.is_empty() {
172 return String::new();
173 }
174
175 let visible = key.len().min(visible_chars);
176 format!("{}***", &key[..visible])
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use serial_test::serial;
183 use std::env;
184 use tempfile::TempDir;
185
186 #[test]
187 fn test_config_path_contains_nika() {
188 let path = NikaConfig::config_path();
189 assert!(path.to_string_lossy().contains("nika"));
190 assert!(path.to_string_lossy().ends_with("config.toml"));
191 }
192
193 #[test]
194 fn test_config_dir_is_parent_of_config_path() {
195 let dir = NikaConfig::config_dir();
196 let path = NikaConfig::config_path();
197 assert_eq!(path.parent().unwrap(), dir);
198 }
199
200 #[test]
201 fn test_default_config_is_empty() {
202 let config = NikaConfig::default();
203 assert!(config.api_keys.anthropic.is_none());
204 assert!(config.api_keys.openai.is_none());
205 assert!(config.defaults.provider.is_none());
206 assert!(config.defaults.model.is_none());
207 }
208
209 #[test]
210 fn test_config_save_and_load_roundtrip() {
211 let temp_dir = TempDir::new().unwrap();
212 let config_path = temp_dir.path().join("config.toml");
213
214 let config = NikaConfig {
215 api_keys: ApiKeys {
216 anthropic: Some("sk-ant-test-key".into()),
217 openai: Some("sk-openai-test".into()),
218 },
219 defaults: Defaults {
220 provider: Some("claude".into()),
221 model: Some("claude-sonnet-4-6".into()),
222 },
223 };
224
225 let content = toml::to_string_pretty(&config).unwrap();
227 fs::write(&config_path, &content).unwrap();
228
229 let loaded_content = fs::read_to_string(&config_path).unwrap();
231 let loaded: NikaConfig = toml::from_str(&loaded_content).unwrap();
232
233 assert_eq!(config, loaded);
234 }
235
236 #[test]
237 #[serial]
238 fn test_env_overrides_config() {
239 env::set_var("ANTHROPIC_API_KEY", "sk-ant-from-env");
241
242 let config = NikaConfig {
243 api_keys: ApiKeys {
244 anthropic: Some("sk-ant-from-config".into()),
245 openai: None,
246 },
247 ..Default::default()
248 }
249 .with_env();
250
251 assert_eq!(config.anthropic_key(), Some("sk-ant-from-env"));
253
254 env::remove_var("ANTHROPIC_API_KEY");
256 }
257
258 #[test]
259 #[serial]
260 fn test_env_does_not_override_with_empty() {
261 env::set_var("OPENAI_API_KEY", "");
262
263 let config = NikaConfig {
264 api_keys: ApiKeys {
265 anthropic: None,
266 openai: Some("sk-from-config".into()),
267 },
268 ..Default::default()
269 }
270 .with_env();
271
272 assert_eq!(config.openai_key(), Some("sk-from-config"));
274
275 env::remove_var("OPENAI_API_KEY");
276 }
277
278 #[test]
279 fn test_has_any_key() {
280 let empty = NikaConfig::default();
281 assert!(!empty.has_any_key());
282
283 let with_anthropic = NikaConfig {
284 api_keys: ApiKeys {
285 anthropic: Some("key".into()),
286 openai: None,
287 },
288 ..Default::default()
289 };
290 assert!(with_anthropic.has_any_key());
291
292 let with_openai = NikaConfig {
293 api_keys: ApiKeys {
294 anthropic: None,
295 openai: Some("key".into()),
296 },
297 ..Default::default()
298 };
299 assert!(with_openai.has_any_key());
300 }
301
302 #[test]
303 fn test_default_provider_autodetect() {
304 let empty = NikaConfig::default();
306 assert!(empty.default_provider().is_none());
307
308 let anthropic = NikaConfig {
310 api_keys: ApiKeys {
311 anthropic: Some("key".into()),
312 openai: None,
313 },
314 ..Default::default()
315 };
316 assert_eq!(anthropic.default_provider(), Some("claude"));
317
318 let openai = NikaConfig {
320 api_keys: ApiKeys {
321 anthropic: None,
322 openai: Some("key".into()),
323 },
324 ..Default::default()
325 };
326 assert_eq!(openai.default_provider(), Some("openai"));
327
328 let explicit = NikaConfig {
330 api_keys: ApiKeys {
331 anthropic: Some("key".into()),
332 openai: Some("key".into()),
333 },
334 defaults: Defaults {
335 provider: Some("openai".into()),
336 model: None,
337 },
338 };
339 assert_eq!(explicit.default_provider(), Some("openai"));
340 }
341
342 #[test]
343 fn test_mask_api_key() {
344 assert_eq!(
345 mask_api_key("sk-ant-api03-abcdefghij", 12),
346 "sk-ant-api03***"
347 );
348 assert_eq!(mask_api_key("sk-proj-abc", 7), "sk-proj***");
349 assert_eq!(mask_api_key("short", 10), "short***"); assert_eq!(mask_api_key("", 10), "");
351 }
352
353 #[test]
354 fn test_toml_format() {
355 let config = NikaConfig {
356 api_keys: ApiKeys {
357 anthropic: Some("sk-ant-test".into()),
358 openai: None,
359 },
360 defaults: Defaults {
361 provider: Some("claude".into()),
362 model: None,
363 },
364 };
365
366 let toml_str = toml::to_string_pretty(&config).unwrap();
367
368 assert!(toml_str.contains("[api_keys]"));
370 assert!(toml_str.contains("anthropic = \"sk-ant-test\""));
371 assert!(toml_str.contains("[defaults]"));
372 assert!(toml_str.contains("provider = \"claude\""));
373 }
374
375 #[test]
376 fn test_load_nonexistent_file_returns_default() {
377 let path = NikaConfig::config_path();
379 let backup = if path.exists() {
380 Some(fs::read_to_string(&path).unwrap())
381 } else {
382 None
383 };
384
385 if path.exists() {
387 fs::remove_file(&path).unwrap();
388 }
389
390 let config = NikaConfig::load().unwrap();
392 assert_eq!(config, NikaConfig::default());
393
394 if let Some(content) = backup {
396 fs::create_dir_all(path.parent().unwrap()).ok();
397 fs::write(&path, content).unwrap();
398 }
399 }
400}