use anyhow::Context;
use anyhow::Result;
use keyring::Entry;
use secrecy::{ExposeSecret, Secret};
use serde::{Deserialize, Serialize};
pub struct SecureKey {
inner: Secret<String>,
provider: String, }
impl SecureKey {
pub fn load(provider: &str) -> Result<Self> {
let entry = Entry::new("kandil", provider)?;
let key = entry.get_password().map_err(|_| {
anyhow::anyhow!(
"No key for {}. Run: kandil config set-key {}",
provider,
provider
)
})?;
Ok(Self {
inner: Secret::new(key),
provider: provider.to_string(),
})
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn expose(&self) -> &str {
self.inner.expose_secret()
}
pub fn save(provider: &str, key: &str) -> Result<()> {
let entry = Entry::new("kandil", provider)?;
entry.set_password(key)?;
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub ai_provider: String,
pub ai_model: String,
}
impl Config {
pub fn load() -> Result<Self> {
let mut provider = "ollama".to_string();
let mut model = "llama3:70b".to_string();
let cfg_path = std::env::current_dir()?.join("kandil.toml");
if cfg_path.exists() {
let s = std::fs::read_to_string(&cfg_path)?;
if let Ok(fc) = toml::from_str::<FileConfig>(&s) {
if let Some(ai) = fc.ai {
if !ai.provider.is_empty() {
provider = ai.provider;
}
if !ai.model.is_empty() {
model = ai.model;
}
}
}
}
if let Ok(p) = std::env::var("KANDIL_AI_PROVIDER") {
provider = p;
}
if let Ok(m) = std::env::var("KANDIL_AI_MODEL") {
model = m;
}
Ok(Config {
ai_provider: provider,
ai_model: model,
})
}
pub fn save(&self) -> Result<()> {
let cfg_path = std::env::current_dir()?.join("kandil.toml");
let mut fc = FileConfig::default();
fc.ai = Some(AISection {
provider: self.ai_provider.clone(),
model: self.ai_model.clone(),
});
let s = toml::to_string(&fc)?;
std::fs::write(cfg_path, s)?;
Ok(())
}
pub async fn validate_production(&self) -> Result<()> {
if self.ai_model.trim().is_empty() {
anyhow::bail!("AI model must be set for production");
}
let provider = AiProvider::from(&self.ai_provider)?;
match provider {
AiProvider::Anthropic | AiProvider::OpenAI | AiProvider::Qwen => {
let key = SecureKey::load(provider.as_str()).with_context(|| {
format!("Missing API key in OS keychain for {}", provider.as_str())
})?;
let _provider_name = key.provider();
if key.expose().is_empty() {
anyhow::bail!("API key for {} is empty", provider.as_str());
}
}
AiProvider::Ollama => {
#[cfg(not(test))]
{
let available = crate::utils::ollama::is_available().await.unwrap_or(false);
if !available {
anyhow::bail!("Ollama is not available at http://localhost:11434");
}
let models = crate::utils::ollama::list_models()
.await
.unwrap_or_default();
let present = models.iter().any(|m| m == &self.ai_model);
if !present {
anyhow::bail!("Selected local model not installed: {}", self.ai_model);
}
}
}
}
let supabase_url = std::env::var("SUPABASE_URL").ok();
let supabase_key = std::env::var("SUPABASE_ANON_KEY").ok();
if let (Some(url), Some(key)) = (supabase_url, supabase_key) {
if is_placeholder(&url) || is_placeholder(&key) {
anyhow::bail!("Supabase configuration contains placeholder values; set real SUPABASE_URL and SUPABASE_ANON_KEY for production");
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
enum AiProvider {
Anthropic,
OpenAI,
Qwen,
Ollama,
}
impl AiProvider {
fn from(s: &str) -> Result<Self> {
match s.to_lowercase().as_str() {
"anthropic" => Ok(AiProvider::Anthropic),
"openai" => Ok(AiProvider::OpenAI),
"qwen" => Ok(AiProvider::Qwen),
"ollama" => Ok(AiProvider::Ollama),
other => anyhow::bail!("Unsupported AI provider: {}", other),
}
}
fn as_str(&self) -> &str {
match self {
AiProvider::Anthropic => "anthropic",
AiProvider::OpenAI => "openai",
AiProvider::Qwen => "qwen",
AiProvider::Ollama => "ollama",
}
}
}
fn is_placeholder(s: &str) -> bool {
let lowered = s.to_lowercase();
lowered.contains("your-") || lowered.contains("example") || lowered.contains("placeholder")
}
#[derive(Serialize, Deserialize, Default)]
struct FileConfig {
ai: Option<AISection>,
}
#[derive(Serialize, Deserialize, Default)]
struct AISection {
provider: String,
model: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn validate_ollama_defaults_ok() {
let cfg = Config {
ai_provider: "ollama".to_string(),
ai_model: "llama3:8b".to_string(),
};
assert!(cfg.validate_production().await.is_ok());
}
#[tokio::test]
async fn unknown_provider_rejected() {
let cfg = Config {
ai_provider: "unknown".to_string(),
ai_model: "x".to_string(),
};
let err = cfg.validate_production().await.unwrap_err();
assert!(format!("{}", err).contains("Unsupported AI provider"));
}
}