use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub provider: String,
pub endpoint: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
}
impl LlmConfig {
pub fn openai(model: &str) -> Self {
Self {
provider: "openai".to_string(),
endpoint: "https://api.openai.com/v1".to_string(),
model: model.to_string(),
api_key: None,
api_key_env: Some("OPENAI_API_KEY".to_string()),
}
}
pub fn anthropic(model: &str) -> Self {
Self {
provider: "anthropic".to_string(),
endpoint: "https://api.anthropic.com/v1".to_string(),
model: model.to_string(),
api_key: None,
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
}
}
pub fn ollama(endpoint: &str, model: &str) -> Self {
Self {
provider: "ollama".to_string(),
endpoint: endpoint.to_string(),
model: model.to_string(),
api_key: None,
api_key_env: None,
}
}
pub fn custom(endpoint: &str, model: &str) -> Self {
Self {
provider: "custom".to_string(),
endpoint: endpoint.to_string(),
model: model.to_string(),
api_key: None,
api_key_env: None,
}
}
pub fn get_api_key(&self) -> Option<String> {
if let Some(ref env_var) = self.api_key_env
&& let Ok(key) = std::env::var(env_var)
{
return Some(key);
}
self.api_key.clone()
}
pub fn is_ready(&self) -> bool {
if self.provider == "ollama" {
return true;
}
self.get_api_key().is_some()
}
pub fn masked_api_key(&self) -> Option<String> {
self.get_api_key().map(|key| {
if key.len() > 8 {
format!("{}...{}", &key[..4], &key[key.len() - 4..])
} else {
"****".to_string()
}
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingConfig {
pub provider: String,
pub endpoint: String,
pub model: String,
#[serde(default = "default_embedding_dims")]
pub dimensions: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
}
fn default_embedding_dims() -> usize {
1536
}
impl EmbeddingConfig {
pub fn openai(model: &str, dims: usize) -> Self {
Self {
provider: "openai".to_string(),
endpoint: "https://api.openai.com/v1".to_string(),
model: model.to_string(),
dimensions: dims,
api_key: None,
api_key_env: Some("OPENAI_API_KEY".to_string()),
}
}
pub fn ollama(endpoint: &str, model: &str, dims: usize) -> Self {
Self {
provider: "ollama".to_string(),
endpoint: endpoint.to_string(),
model: model.to_string(),
dimensions: dims,
api_key: None,
api_key_env: None,
}
}
pub fn get_api_key(&self) -> Option<String> {
if let Some(ref env_var) = self.api_key_env
&& let Ok(key) = std::env::var(env_var)
{
return Some(key);
}
self.api_key.clone()
}
pub fn is_ready(&self) -> bool {
if self.provider == "ollama" {
return true;
}
self.get_api_key().is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embeddings: Option<EmbeddingConfig>,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn with_llm(mut self, llm_config: LlmConfig) -> Self {
self.llm = Some(llm_config);
self
}
pub fn with_embeddings(mut self, embedding_config: EmbeddingConfig) -> Self {
self.embeddings = Some(embedding_config);
self
}
pub fn remove_llm(&mut self) {
self.llm = None;
}
pub fn remove_embeddings(&mut self) {
self.embeddings = None;
}
pub fn has_llm(&self) -> bool {
self.llm.is_some()
}
pub fn llm_ready(&self) -> bool {
self.llm.as_ref().is_some_and(|l| l.is_ready())
}
pub fn embeddings_ready(&self) -> bool {
self.embeddings.as_ref().is_some_and(|e| e.is_ready())
}
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
let config: Config = serde_json::from_str(&contents)
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create config directory: {}", parent.display())
})?;
}
let contents = serde_json::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&path, contents)
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
Ok(())
}
pub fn exists() -> bool {
config_path().map(|p| p.exists()).unwrap_or(false)
}
pub fn delete() -> Result<()> {
let path = config_path()?;
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to delete config file: {}", path.display()))?;
}
Ok(())
}
pub fn config_dir() -> Result<PathBuf> {
let dir = dirs_config_dir().context("Could not determine config directory")?;
Ok(dir.join("unfault"))
}
}
fn config_path() -> Result<PathBuf> {
let config_dir = dirs_config_dir().context("Could not determine config directory")?;
Ok(config_dir.join("unfault").join("config.json"))
}
fn dirs_config_dir() -> Option<PathBuf> {
std::env::var("XDG_CONFIG_HOME")
.ok()
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok())
.map(|h| PathBuf::from(h).join(".config"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::new();
assert!(config.llm.is_none());
assert!(config.embeddings.is_none());
assert!(!config.llm_ready());
assert!(!config.embeddings_ready());
}
#[test]
fn test_config_with_llm() {
let config =
Config::new().with_llm(LlmConfig::ollama("http://localhost:11434", "llama3.2"));
assert!(config.has_llm());
assert!(config.llm_ready()); }
#[test]
fn test_config_with_embeddings() {
let config = Config::new().with_embeddings(EmbeddingConfig::ollama(
"http://localhost:11434",
"nomic-embed-text",
768,
));
assert!(config.embeddings_ready());
assert_eq!(config.embeddings.unwrap().dimensions, 768);
}
#[test]
fn test_config_serialization() {
let config = Config::new()
.with_llm(LlmConfig::ollama("http://localhost:11434", "llama3.2"))
.with_embeddings(EmbeddingConfig::openai("text-embedding-3-small", 1536));
let json = serde_json::to_string_pretty(&config).unwrap();
assert!(json.contains("ollama"));
assert!(json.contains("llama3.2"));
assert!(json.contains("text-embedding-3-small"));
}
#[test]
fn test_config_deserialization() {
let json = r#"{
"llm": {
"provider": "openai",
"endpoint": "https://api.openai.com/v1",
"model": "gpt-4o",
"api_key_env": "OPENAI_API_KEY"
}
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.has_llm());
assert!(config.embeddings.is_none());
assert_eq!(config.llm.unwrap().model, "gpt-4o");
}
#[test]
fn test_config_deserialization_empty() {
let json = "{}";
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.llm.is_none());
assert!(config.embeddings.is_none());
}
#[test]
fn test_config_backward_compat() {
let json = r#"{"api_key": "old_key", "llm": {"provider": "ollama", "endpoint": "http://localhost:11434", "model": "llama3.2"}}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert!(config.has_llm());
}
#[test]
fn test_llm_openai() {
let llm = LlmConfig::openai("gpt-4o");
assert_eq!(llm.provider, "openai");
assert_eq!(llm.api_key_env, Some("OPENAI_API_KEY".to_string()));
}
#[test]
fn test_llm_anthropic() {
let llm = LlmConfig::anthropic("claude-sonnet-4-20250514");
assert_eq!(llm.provider, "anthropic");
assert_eq!(llm.api_key_env, Some("ANTHROPIC_API_KEY".to_string()));
}
#[test]
fn test_llm_ollama_ready() {
let llm = LlmConfig::ollama("http://localhost:11434", "llama3.2");
assert!(llm.is_ready()); }
#[test]
fn test_llm_openai_not_ready_without_key() {
let llm = LlmConfig::openai("gpt-4o");
assert!(!llm.is_ready() || std::env::var("OPENAI_API_KEY").is_ok());
}
#[test]
fn test_embedding_openai() {
let emb = EmbeddingConfig::openai("text-embedding-3-small", 1536);
assert_eq!(emb.provider, "openai");
assert_eq!(emb.dimensions, 1536);
}
#[test]
fn test_embedding_ollama() {
let emb = EmbeddingConfig::ollama("http://localhost:11434", "nomic-embed-text", 768);
assert!(emb.is_ready());
assert_eq!(emb.dimensions, 768);
}
#[test]
fn test_masked_api_key() {
let mut llm = LlmConfig::openai("gpt-4o");
llm.api_key = Some("sk-1234567890abcdef".to_string());
let masked = llm.masked_api_key().unwrap();
assert!(masked.starts_with("sk-1"));
assert!(masked.ends_with("cdef"));
assert!(masked.contains("..."));
}
#[test]
fn test_config_exists() {
let _ = Config::exists(); }
}