Skip to main content

nika_engine/
config.rs

1//! Nika Configuration Module
2//!
3//! Manages persistent configuration for API keys and defaults.
4//! Config is stored in `~/.config/nika/config.toml`.
5//!
6//! ## Priority Order (highest to lowest)
7//!
8//! 1. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`)
9//! 2. Config file (`~/.config/nika/config.toml`)
10//! 3. Defaults
11
12use std::fs;
13use std::path::PathBuf;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{NikaError, Result};
18use crate::util::atomic_write;
19
20/// Main configuration structure
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
22pub struct NikaConfig {
23    /// API keys for LLM providers
24    #[serde(default)]
25    pub api_keys: ApiKeys,
26
27    /// Default provider and model settings
28    #[serde(default)]
29    pub defaults: Defaults,
30}
31
32/// API keys configuration
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34pub struct ApiKeys {
35    /// Anthropic API key (sk-ant-...)
36    pub anthropic: Option<String>,
37
38    /// OpenAI API key (sk-proj-... or sk-...)
39    pub openai: Option<String>,
40}
41
42/// Default settings
43#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
44pub struct Defaults {
45    /// Default provider (claude, openai)
46    pub provider: Option<String>,
47
48    /// Default model (claude-sonnet-4-6, gpt-4o, etc.)
49    pub model: Option<String>,
50}
51
52impl NikaConfig {
53    /// Get the config directory path
54    ///
55    /// Returns `~/.config/nika/` on Unix, `%APPDATA%/nika/` on Windows
56    pub fn config_dir() -> PathBuf {
57        dirs::config_dir()
58            .unwrap_or_else(|| PathBuf::from("."))
59            .join("nika")
60    }
61
62    /// Get the config file path
63    ///
64    /// Returns `~/.config/nika/config.toml`
65    pub fn config_path() -> PathBuf {
66        Self::config_dir().join("config.toml")
67    }
68
69    /// Load configuration from file
70    ///
71    /// Returns default config if file doesn't exist.
72    /// Returns error if file exists but is malformed.
73    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    /// Save configuration to file
90    ///
91    /// Creates the config directory if it doesn't exist.
92    /// Uses atomic write (temp+rename) for data integrity.
93    pub fn save(&self) -> Result<()> {
94        let path = Self::config_path();
95
96        // Serialize to TOML
97        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 (creates parent dirs, uses temp+rename)
102        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    /// Merge with environment variables
110    ///
111    /// Environment variables take precedence over config file values.
112    pub fn with_env(mut self) -> Self {
113        // Check for Anthropic key in env
114        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        // Check for OpenAI key in env
121        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    /// Get effective Anthropic API key
131    ///
132    /// Returns key from config (env vars should be merged first via `with_env()`)
133    pub fn anthropic_key(&self) -> Option<&str> {
134        self.api_keys.anthropic.as_deref()
135    }
136
137    /// Get effective OpenAI API key
138    pub fn openai_key(&self) -> Option<&str> {
139        self.api_keys.openai.as_deref()
140    }
141
142    /// Check if any API key is configured
143    pub fn has_any_key(&self) -> bool {
144        self.api_keys.anthropic.is_some() || self.api_keys.openai.is_some()
145    }
146
147    /// Get default provider (or auto-detect from available keys)
148    pub fn default_provider(&self) -> Option<&str> {
149        self.defaults.provider.as_deref().or_else(|| {
150            // Auto-detect based on available keys
151            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    /// Get default model for provider
162    pub fn default_model(&self) -> Option<&str> {
163        self.defaults.model.as_deref()
164    }
165}
166
167/// Mask an API key for display
168///
169/// Shows first N chars + asterisks, e.g. "sk-ant-api03-***"
170pub 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        // Manually save to temp path
226        let content = toml::to_string_pretty(&config).unwrap();
227        fs::write(&config_path, &content).unwrap();
228
229        // Load from temp path
230        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        // Set env var
240        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        // Env should override config
252        assert_eq!(config.anthropic_key(), Some("sk-ant-from-env"));
253
254        // Cleanup
255        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        // Empty env should not override
273        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        // No keys = no provider
305        let empty = NikaConfig::default();
306        assert!(empty.default_provider().is_none());
307
308        // Anthropic key = claude provider
309        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        // OpenAI key = openai provider
319        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        // Explicit provider overrides auto-detect
329        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***"); // Key shorter than visible chars
350        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        // Should contain expected sections
369        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        // This test uses the actual config path, so we save/restore if it exists
378        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        // Remove file if it exists
386        if path.exists() {
387            fs::remove_file(&path).unwrap();
388        }
389
390        // Load should return default
391        let config = NikaConfig::load().unwrap();
392        assert_eq!(config, NikaConfig::default());
393
394        // Restore backup if needed
395        if let Some(content) = backup {
396            fs::create_dir_all(path.parent().unwrap()).ok();
397            fs::write(&path, content).unwrap();
398        }
399    }
400}