auto_commit/config/
mod.rs1use anyhow::{Context, Result};
2use std::{fs, path::Path};
3
4use crate::api::Provider;
5
6const 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
16pub 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 DEFAULT_GITMESSAGE_TEMPLATE.to_string()
37}
38
39impl Config {
40 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 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 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 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 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 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 let template = load_gitmessage_template();
256
257 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 assert!(!DEFAULT_GITMESSAGE_TEMPLATE.is_empty());
267 assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Prefix"));
268 assert!(DEFAULT_GITMESSAGE_TEMPLATE.contains("Emojis"));
269 }
270}