use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{NikaError, Result};
use crate::util::atomic_write;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct NikaConfig {
#[serde(default)]
pub api_keys: ApiKeys,
#[serde(default)]
pub defaults: Defaults,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ApiKeys {
pub anthropic: Option<String>,
pub openai: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Defaults {
pub provider: Option<String>,
pub model: Option<String>,
}
impl NikaConfig {
pub fn config_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("nika")
}
pub fn config_path() -> PathBuf {
Self::config_dir().join("config.toml")
}
pub fn load() -> Result<Self> {
let path = Self::config_path();
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(&path).map_err(|e| NikaError::ConfigError {
reason: format!("Failed to read config file: {}", e),
})?;
toml::from_str(&content).map_err(|e| NikaError::ConfigError {
reason: format!("Failed to parse config file: {}", e),
})
}
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
let content = toml::to_string_pretty(self).map_err(|e| NikaError::ConfigError {
reason: format!("Failed to serialize config: {}", e),
})?;
atomic_write(&path, content.as_bytes()).map_err(|e| NikaError::ConfigError {
reason: format!("Failed to write config file: {}", e),
})?;
Ok(())
}
pub fn with_env(mut self) -> Self {
if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
if !key.is_empty() {
self.api_keys.anthropic = Some(key);
}
}
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
if !key.is_empty() {
self.api_keys.openai = Some(key);
}
}
self
}
pub fn anthropic_key(&self) -> Option<&str> {
self.api_keys.anthropic.as_deref()
}
pub fn openai_key(&self) -> Option<&str> {
self.api_keys.openai.as_deref()
}
pub fn has_any_key(&self) -> bool {
self.api_keys.anthropic.is_some() || self.api_keys.openai.is_some()
}
pub fn default_provider(&self) -> Option<&str> {
self.defaults.provider.as_deref().or_else(|| {
if self.api_keys.anthropic.is_some() {
Some("claude")
} else if self.api_keys.openai.is_some() {
Some("openai")
} else {
None
}
})
}
pub fn default_model(&self) -> Option<&str> {
self.defaults.model.as_deref()
}
}
pub fn mask_api_key(key: &str, visible_chars: usize) -> String {
if key.is_empty() {
return String::new();
}
let visible = key.len().min(visible_chars);
format!("{}***", &key[..visible])
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
#[test]
fn test_config_path_contains_nika() {
let path = NikaConfig::config_path();
assert!(path.to_string_lossy().contains("nika"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_config_dir_is_parent_of_config_path() {
let dir = NikaConfig::config_dir();
let path = NikaConfig::config_path();
assert_eq!(path.parent().unwrap(), dir);
}
#[test]
fn test_default_config_is_empty() {
let config = NikaConfig::default();
assert!(config.api_keys.anthropic.is_none());
assert!(config.api_keys.openai.is_none());
assert!(config.defaults.provider.is_none());
assert!(config.defaults.model.is_none());
}
#[test]
fn test_config_save_and_load_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-test-key".into()),
openai: Some("sk-openai-test".into()),
},
defaults: Defaults {
provider: Some("claude".into()),
model: Some("claude-sonnet-4-6".into()),
},
};
let content = toml::to_string_pretty(&config).unwrap();
fs::write(&config_path, &content).unwrap();
let loaded_content = fs::read_to_string(&config_path).unwrap();
let loaded: NikaConfig = toml::from_str(&loaded_content).unwrap();
assert_eq!(config, loaded);
}
#[test]
#[serial]
fn test_env_overrides_config() {
env::set_var("ANTHROPIC_API_KEY", "sk-ant-from-env");
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-from-config".into()),
openai: None,
},
..Default::default()
}
.with_env();
assert_eq!(config.anthropic_key(), Some("sk-ant-from-env"));
env::remove_var("ANTHROPIC_API_KEY");
}
#[test]
#[serial]
fn test_env_does_not_override_with_empty() {
env::set_var("OPENAI_API_KEY", "");
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: None,
openai: Some("sk-from-config".into()),
},
..Default::default()
}
.with_env();
assert_eq!(config.openai_key(), Some("sk-from-config"));
env::remove_var("OPENAI_API_KEY");
}
#[test]
fn test_has_any_key() {
let empty = NikaConfig::default();
assert!(!empty.has_any_key());
let with_anthropic = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("key".into()),
openai: None,
},
..Default::default()
};
assert!(with_anthropic.has_any_key());
let with_openai = NikaConfig {
api_keys: ApiKeys {
anthropic: None,
openai: Some("key".into()),
},
..Default::default()
};
assert!(with_openai.has_any_key());
}
#[test]
fn test_default_provider_autodetect() {
let empty = NikaConfig::default();
assert!(empty.default_provider().is_none());
let anthropic = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("key".into()),
openai: None,
},
..Default::default()
};
assert_eq!(anthropic.default_provider(), Some("claude"));
let openai = NikaConfig {
api_keys: ApiKeys {
anthropic: None,
openai: Some("key".into()),
},
..Default::default()
};
assert_eq!(openai.default_provider(), Some("openai"));
let explicit = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("key".into()),
openai: Some("key".into()),
},
defaults: Defaults {
provider: Some("openai".into()),
model: None,
},
};
assert_eq!(explicit.default_provider(), Some("openai"));
}
#[test]
fn test_mask_api_key() {
assert_eq!(
mask_api_key("sk-ant-api03-abcdefghij", 12),
"sk-ant-api03***"
);
assert_eq!(mask_api_key("sk-proj-abc", 7), "sk-proj***");
assert_eq!(mask_api_key("short", 10), "short***"); assert_eq!(mask_api_key("", 10), "");
}
#[test]
fn test_toml_format() {
let config = NikaConfig {
api_keys: ApiKeys {
anthropic: Some("sk-ant-test".into()),
openai: None,
},
defaults: Defaults {
provider: Some("claude".into()),
model: None,
},
};
let toml_str = toml::to_string_pretty(&config).unwrap();
assert!(toml_str.contains("[api_keys]"));
assert!(toml_str.contains("anthropic = \"sk-ant-test\""));
assert!(toml_str.contains("[defaults]"));
assert!(toml_str.contains("provider = \"claude\""));
}
#[test]
fn test_load_nonexistent_file_returns_default() {
let path = NikaConfig::config_path();
let backup = if path.exists() {
Some(fs::read_to_string(&path).unwrap())
} else {
None
};
if path.exists() {
fs::remove_file(&path).unwrap();
}
let config = NikaConfig::load().unwrap();
assert_eq!(config, NikaConfig::default());
if let Some(content) = backup {
fs::create_dir_all(path.parent().unwrap()).ok();
fs::write(&path, content).unwrap();
}
}
}