use anyhow::{Context, Result};
use std::{fs, path::Path};
use crate::api::Provider;
const DEFAULT_GITMESSAGE_TEMPLATE: &str = include_str!("../../docs/.gitmessage");
#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
pub api_key: String,
pub gitmessage_template: String,
}
pub fn load_gitmessage_template() -> String {
let search_paths = [
dirs::home_dir().map(|p| p.join(".gitmessage")),
Some(Path::new(".gitmessage").to_path_buf()),
];
for path_opt in search_paths.iter().flatten() {
if path_opt.exists() {
if let Ok(content) = fs::read_to_string(path_opt) {
return content;
}
}
}
DEFAULT_GITMESSAGE_TEMPLATE.to_string()
}
impl Config {
pub fn from_env() -> Result<Self> {
let (provider, api_key) = Provider::detect()
.context("No API key found. Set OPENAI_API_KEY, DEEPSEEK_API_KEY, or GEMINI_API_KEY")?;
let gitmessage_template = load_gitmessage_template();
Ok(Self {
provider,
api_key,
gitmessage_template,
})
}
pub fn from_env_file(path: &Path) -> Result<Self> {
let content = fs::read_to_string(path).context("Failed to read .env file")?;
let mut openai_key = None;
let mut deepseek_key = None;
let mut gemini_key = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
if let Some(value) = extract_env_value(line, "OPENAI_API_KEY=") {
openai_key = Some(value);
} else if let Some(value) = extract_env_value(line, "DEEPSEEK_API_KEY=") {
deepseek_key = Some(value);
} else if let Some(value) = extract_env_value(line, "GEMINI_API_KEY=") {
gemini_key = Some(value);
}
}
let (provider, api_key) = if let Some(key) = openai_key {
(Provider::OpenAi, key)
} else if let Some(key) = deepseek_key {
(Provider::DeepSeek, key)
} else if let Some(key) = gemini_key {
(Provider::Gemini, key)
} else {
anyhow::bail!(
"No API key found in .env file. Set OPENAI_API_KEY, DEEPSEEK_API_KEY, or GEMINI_API_KEY"
);
};
let gitmessage_template = load_gitmessage_template();
Ok(Self {
provider,
api_key,
gitmessage_template,
})
}
}
fn extract_env_value(line: &str, prefix: &str) -> Option<String> {
line.strip_prefix(prefix)
.map(|v| v.trim_matches('\'').trim_matches('"').to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use std::io::Write;
use tempfile::NamedTempFile;
fn clear_env_keys() {
env::remove_var("OPENAI_API_KEY");
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("GEMINI_API_KEY");
}
#[test]
#[serial]
fn test_config_from_env_openai() {
clear_env_keys();
env::set_var("OPENAI_API_KEY", "sk-openai-test");
let config = Config::from_env().unwrap();
assert_eq!(config.provider, Provider::OpenAi);
assert_eq!(config.api_key, "sk-openai-test");
clear_env_keys();
}
#[test]
#[serial]
fn test_config_from_env_deepseek() {
clear_env_keys();
env::set_var("DEEPSEEK_API_KEY", "sk-deepseek-test");
let config = Config::from_env().unwrap();
assert_eq!(config.provider, Provider::DeepSeek);
assert_eq!(config.api_key, "sk-deepseek-test");
clear_env_keys();
}
#[test]
#[serial]
fn test_config_from_env_gemini() {
clear_env_keys();
env::set_var("GEMINI_API_KEY", "AIza-gemini-test");
let config = Config::from_env().unwrap();
assert_eq!(config.provider, Provider::Gemini);
assert_eq!(config.api_key, "AIza-gemini-test");
clear_env_keys();
}
#[test]
#[serial]
fn test_config_from_env_priority() {
clear_env_keys();
env::set_var("OPENAI_API_KEY", "openai");
env::set_var("DEEPSEEK_API_KEY", "deepseek");
env::set_var("GEMINI_API_KEY", "gemini");
let config = Config::from_env().unwrap();
assert_eq!(config.provider, Provider::OpenAi);
assert_eq!(config.api_key, "openai");
clear_env_keys();
}
#[test]
#[serial]
fn test_config_from_env_missing_key() {
clear_env_keys();
let result = Config::from_env();
assert!(result.is_err());
}
#[test]
fn test_config_from_env_file_openai() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "OPENAI_API_KEY='sk-test123'").unwrap();
let config = Config::from_env_file(temp_file.path()).unwrap();
assert_eq!(config.provider, Provider::OpenAi);
assert_eq!(config.api_key, "sk-test123");
}
#[test]
fn test_config_from_env_file_deepseek() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "DEEPSEEK_API_KEY='sk-deepseek'").unwrap();
let config = Config::from_env_file(temp_file.path()).unwrap();
assert_eq!(config.provider, Provider::DeepSeek);
assert_eq!(config.api_key, "sk-deepseek");
}
#[test]
fn test_config_from_env_file_gemini() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "GEMINI_API_KEY='AIza-test'").unwrap();
let config = Config::from_env_file(temp_file.path()).unwrap();
assert_eq!(config.provider, Provider::Gemini);
assert_eq!(config.api_key, "AIza-test");
}
#[test]
fn test_config_from_env_file_with_export() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "export OPENAI_API_KEY='sk-test456'").unwrap();
let config = Config::from_env_file(temp_file.path()).unwrap();
assert_eq!(config.provider, Provider::OpenAi);
assert_eq!(config.api_key, "sk-test456");
}
#[test]
fn test_config_from_env_file_priority() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "GEMINI_API_KEY='gemini'").unwrap();
writeln!(temp_file, "OPENAI_API_KEY='openai'").unwrap();
writeln!(temp_file, "DEEPSEEK_API_KEY='deepseek'").unwrap();
let config = Config::from_env_file(temp_file.path()).unwrap();
assert_eq!(config.provider, Provider::OpenAi);
assert_eq!(config.api_key, "openai");
}
#[test]
fn test_load_gitmessage_template_default() {
let template = load_gitmessage_template();
assert!(template.contains("Commit Message Template"));
assert!(template.contains("feat:"));
assert!(template.contains("fix:"));
}
#[test]
fn test_default_template_is_embedded() {
assert!(!DEFAULT_GITMESSAGE_TEMPLATE.is_empty());
assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Prefix"));
assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Emojis"));
}
}