auto_commit/config/
mod.rs

1use anyhow::{Context, Result};
2use std::{fs, path::Path};
3
4use crate::api::Provider;
5
6/// Default .gitmessage template embedded at compile time
7const DEFAULT_GITMESSAGE_TEMPLATE: &str = include_str!("../../docs/.gitmessage");
8
9#[derive(Debug, Clone)]
10pub struct Config {
11    pub provider: Provider,
12    pub api_key: String,
13    pub gitmessage_template: String,
14}
15
16/// Load .gitmessage template from various locations
17/// Search order:
18/// 1. ~/.gitmessage (user custom) - highest priority
19/// 2. ./.gitmessage (project root)
20/// 3. Embedded default template (from docs/.gitmessage at build time)
21pub fn load_gitmessage_template() -> String {
22    let search_paths = [
23        dirs::home_dir().map(|p| p.join(".gitmessage")),
24        Some(Path::new(".gitmessage").to_path_buf()),
25    ];
26
27    for path_opt in search_paths.iter().flatten() {
28        if path_opt.exists() {
29            if let Ok(content) = fs::read_to_string(path_opt) {
30                return content;
31            }
32        }
33    }
34
35    // Fall back to embedded default template
36    DEFAULT_GITMESSAGE_TEMPLATE.to_string()
37}
38
39impl Config {
40    /// Load config from environment variables
41    /// Detects provider automatically based on which API key is set
42    /// Priority: OPENAI_API_KEY > DEEPSEEK_API_KEY > GEMINI_API_KEY
43    pub fn from_env() -> Result<Self> {
44        let (provider, api_key) = Provider::detect()
45            .context("No API key found. Set OPENAI_API_KEY, DEEPSEEK_API_KEY, or GEMINI_API_KEY")?;
46
47        let gitmessage_template = load_gitmessage_template();
48
49        Ok(Self {
50            provider,
51            api_key,
52            gitmessage_template,
53        })
54    }
55
56    /// Load config from .env file
57    /// Supports multiple API keys with same priority as from_env()
58    pub fn from_env_file(path: &Path) -> Result<Self> {
59        let content = fs::read_to_string(path).context("Failed to read .env file")?;
60
61        let mut openai_key = None;
62        let mut deepseek_key = None;
63        let mut gemini_key = None;
64
65        for line in content.lines() {
66            let line = line.trim();
67            if line.is_empty() || line.starts_with('#') {
68                continue;
69            }
70
71            // Handle both regular and export prefixed
72            let line = line.strip_prefix("export ").unwrap_or(line);
73
74            if let Some(value) = extract_env_value(line, "OPENAI_API_KEY=") {
75                openai_key = Some(value);
76            } else if let Some(value) = extract_env_value(line, "DEEPSEEK_API_KEY=") {
77                deepseek_key = Some(value);
78            } else if let Some(value) = extract_env_value(line, "GEMINI_API_KEY=") {
79                gemini_key = Some(value);
80            }
81        }
82
83        // Priority: OpenAI > DeepSeek > Gemini
84        let (provider, api_key) = if let Some(key) = openai_key {
85            (Provider::OpenAi, key)
86        } else if let Some(key) = deepseek_key {
87            (Provider::DeepSeek, key)
88        } else if let Some(key) = gemini_key {
89            (Provider::Gemini, key)
90        } else {
91            anyhow::bail!(
92                "No API key found in .env file. Set OPENAI_API_KEY, DEEPSEEK_API_KEY, or GEMINI_API_KEY"
93            );
94        };
95
96        let gitmessage_template = load_gitmessage_template();
97
98        Ok(Self {
99            provider,
100            api_key,
101            gitmessage_template,
102        })
103    }
104}
105
106fn extract_env_value(line: &str, prefix: &str) -> Option<String> {
107    line.strip_prefix(prefix)
108        .map(|v| v.trim_matches('\'').trim_matches('"').to_string())
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use serial_test::serial;
115    use std::env;
116    use std::io::Write;
117    use tempfile::NamedTempFile;
118
119    fn clear_env_keys() {
120        env::remove_var("OPENAI_API_KEY");
121        env::remove_var("DEEPSEEK_API_KEY");
122        env::remove_var("GEMINI_API_KEY");
123    }
124
125    #[test]
126    #[serial]
127    fn test_config_from_env_openai() {
128        clear_env_keys();
129        env::set_var("OPENAI_API_KEY", "sk-openai-test");
130
131        let config = Config::from_env().unwrap();
132
133        assert_eq!(config.provider, Provider::OpenAi);
134        assert_eq!(config.api_key, "sk-openai-test");
135
136        clear_env_keys();
137    }
138
139    #[test]
140    #[serial]
141    fn test_config_from_env_deepseek() {
142        clear_env_keys();
143        env::set_var("DEEPSEEK_API_KEY", "sk-deepseek-test");
144
145        let config = Config::from_env().unwrap();
146
147        assert_eq!(config.provider, Provider::DeepSeek);
148        assert_eq!(config.api_key, "sk-deepseek-test");
149
150        clear_env_keys();
151    }
152
153    #[test]
154    #[serial]
155    fn test_config_from_env_gemini() {
156        clear_env_keys();
157        env::set_var("GEMINI_API_KEY", "AIza-gemini-test");
158
159        let config = Config::from_env().unwrap();
160
161        assert_eq!(config.provider, Provider::Gemini);
162        assert_eq!(config.api_key, "AIza-gemini-test");
163
164        clear_env_keys();
165    }
166
167    #[test]
168    #[serial]
169    fn test_config_from_env_priority() {
170        clear_env_keys();
171        // Set all keys - OpenAI should win
172        env::set_var("OPENAI_API_KEY", "openai");
173        env::set_var("DEEPSEEK_API_KEY", "deepseek");
174        env::set_var("GEMINI_API_KEY", "gemini");
175
176        let config = Config::from_env().unwrap();
177
178        assert_eq!(config.provider, Provider::OpenAi);
179        assert_eq!(config.api_key, "openai");
180
181        clear_env_keys();
182    }
183
184    #[test]
185    #[serial]
186    fn test_config_from_env_missing_key() {
187        clear_env_keys();
188
189        let result = Config::from_env();
190
191        assert!(result.is_err());
192    }
193
194    #[test]
195    fn test_config_from_env_file_openai() {
196        let mut temp_file = NamedTempFile::new().unwrap();
197        writeln!(temp_file, "OPENAI_API_KEY='sk-test123'").unwrap();
198
199        let config = Config::from_env_file(temp_file.path()).unwrap();
200
201        assert_eq!(config.provider, Provider::OpenAi);
202        assert_eq!(config.api_key, "sk-test123");
203    }
204
205    #[test]
206    fn test_config_from_env_file_deepseek() {
207        let mut temp_file = NamedTempFile::new().unwrap();
208        writeln!(temp_file, "DEEPSEEK_API_KEY='sk-deepseek'").unwrap();
209
210        let config = Config::from_env_file(temp_file.path()).unwrap();
211
212        assert_eq!(config.provider, Provider::DeepSeek);
213        assert_eq!(config.api_key, "sk-deepseek");
214    }
215
216    #[test]
217    fn test_config_from_env_file_gemini() {
218        let mut temp_file = NamedTempFile::new().unwrap();
219        writeln!(temp_file, "GEMINI_API_KEY='AIza-test'").unwrap();
220
221        let config = Config::from_env_file(temp_file.path()).unwrap();
222
223        assert_eq!(config.provider, Provider::Gemini);
224        assert_eq!(config.api_key, "AIza-test");
225    }
226
227    #[test]
228    fn test_config_from_env_file_with_export() {
229        let mut temp_file = NamedTempFile::new().unwrap();
230        writeln!(temp_file, "export OPENAI_API_KEY='sk-test456'").unwrap();
231
232        let config = Config::from_env_file(temp_file.path()).unwrap();
233
234        assert_eq!(config.provider, Provider::OpenAi);
235        assert_eq!(config.api_key, "sk-test456");
236    }
237
238    #[test]
239    fn test_config_from_env_file_priority() {
240        let mut temp_file = NamedTempFile::new().unwrap();
241        writeln!(temp_file, "GEMINI_API_KEY='gemini'").unwrap();
242        writeln!(temp_file, "OPENAI_API_KEY='openai'").unwrap();
243        writeln!(temp_file, "DEEPSEEK_API_KEY='deepseek'").unwrap();
244
245        let config = Config::from_env_file(temp_file.path()).unwrap();
246
247        // OpenAI should be selected (priority)
248        assert_eq!(config.provider, Provider::OpenAi);
249        assert_eq!(config.api_key, "openai");
250    }
251
252    #[test]
253    fn test_load_gitmessage_template_default() {
254        // Should always return a template (embedded default if no custom file)
255        let template = load_gitmessage_template();
256
257        // Verify embedded template contains expected content
258        assert!(template.contains("Commit Message Template"));
259        assert!(template.contains("feat:"));
260        assert!(template.contains("fix:"));
261    }
262
263    #[test]
264    fn test_default_template_is_embedded() {
265        // Verify the default template is properly embedded at compile time
266        assert!(!DEFAULT_GITMESSAGE_TEMPLATE.is_empty());
267        assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Prefix"));
268        assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Emojis"));
269    }
270}