1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::Deserialize;
5
6use crate::{
7 error::{CommitGenError, Result},
8 types::{
9 CategoryConfig, TypeConfig, default_categories, default_classifier_hint, default_types,
10 },
11};
12
13#[derive(Debug, Clone, Copy, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub enum ApiMode {
16 Auto,
17 ChatCompletions,
18 AnthropicMessages,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ResolvedApiMode {
23 ChatCompletions,
24 AnthropicMessages,
25}
26
27#[derive(Debug, Clone, Deserialize)]
28#[serde(default)]
29pub struct CommitConfig {
30 pub api_base_url: String,
31
32 #[serde(default = "default_api_mode")]
34 pub api_mode: ApiMode,
35
36 pub api_key: Option<String>,
39
40 pub request_timeout_secs: u64,
42
43 pub connect_timeout_secs: u64,
45
46 #[serde(default = "default_disable_git_background_features")]
49 pub disable_git_background_features: bool,
50
51 pub compose_max_rounds: usize,
53
54 pub summary_guideline: usize,
55 pub summary_soft_limit: usize,
56 pub summary_hard_limit: usize,
57 pub max_retries: u32,
58 pub initial_backoff_ms: u64,
59 #[serde(default = "default_auto_fast_threshold_lines")]
60 pub auto_fast_threshold_lines: usize,
61 pub max_diff_length: usize,
62 pub max_diff_tokens: usize,
63 pub wide_change_threshold: f32,
64 pub temperature: f32,
65 #[serde(default = "default_analysis_model")]
66 pub analysis_model: String,
67 #[serde(default = "default_summary_model")]
68 pub summary_model: String,
69 #[serde(default, rename = "model")]
73 pub legacy_model: Option<String>,
74 pub excluded_files: Vec<String>,
75 pub low_priority_extensions: Vec<String>,
76
77 pub max_detail_tokens: usize,
80
81 #[serde(default = "default_analysis_prompt_variant")]
83 pub analysis_prompt_variant: String,
84
85 #[serde(default = "default_summary_prompt_variant")]
87 pub summary_prompt_variant: String,
88
89 #[serde(default = "default_wide_change_abstract")]
91 pub wide_change_abstract: bool,
92
93 #[serde(default = "default_exclude_old_message")]
96 pub exclude_old_message: bool,
97
98 #[serde(default = "default_gpg_sign")]
100 pub gpg_sign: bool,
101
102 #[serde(default = "default_signoff")]
105 pub signoff: bool,
106
107 #[serde(default = "default_types")]
109 pub types: IndexMap<String, TypeConfig>,
110
111 #[serde(default = "default_classifier_hint")]
113 pub classifier_hint: String,
114
115 #[serde(default = "default_categories")]
117 pub categories: Vec<CategoryConfig>,
118
119 #[serde(default = "default_changelog_enabled")]
121 pub changelog_enabled: bool,
122
123 #[serde(default = "default_map_reduce_enabled")]
125 pub map_reduce_enabled: bool,
126
127 #[serde(default = "default_map_reduce_threshold")]
129 pub map_reduce_threshold: usize,
130
131 #[serde(default = "default_cache_enabled")]
134 pub cache_enabled: bool,
135
136 #[serde(default = "default_cache_ttl_days")]
139 pub cache_ttl_days: u32,
140
141 #[serde(default)]
144 pub cache_dir: Option<String>,
145
146 #[serde(skip)]
148 pub analysis_prompt: String,
149
150 #[serde(skip)]
152 pub summary_prompt: String,
153}
154
155fn default_analysis_prompt_variant() -> String {
156 "default".to_string()
157}
158
159const fn default_api_mode() -> ApiMode {
160 ApiMode::Auto
161}
162
163const fn default_disable_git_background_features() -> bool {
164 true
165}
166
167fn default_summary_prompt_variant() -> String {
168 "default".to_string()
169}
170
171fn default_analysis_model() -> String {
172 "claude-opus-4.5".to_string()
173}
174
175fn default_summary_model() -> String {
176 "claude-haiku-4-5".to_string()
177}
178
179const fn default_wide_change_abstract() -> bool {
180 true
181}
182
183const fn default_exclude_old_message() -> bool {
184 true
185}
186
187const fn default_gpg_sign() -> bool {
188 false
189}
190
191const fn default_signoff() -> bool {
192 false
193}
194
195const fn default_cache_enabled() -> bool {
196 true
197}
198
199const fn default_cache_ttl_days() -> u32 {
200 14
201}
202
203const fn default_changelog_enabled() -> bool {
204 true
205}
206
207const fn default_map_reduce_enabled() -> bool {
208 true
209}
210
211const fn default_map_reduce_threshold() -> usize {
212 30000 }
214
215const fn default_auto_fast_threshold_lines() -> usize {
216 200
217}
218
219fn parse_api_mode(value: &str) -> ApiMode {
220 match value.trim().to_lowercase().as_str() {
221 "auto" => ApiMode::Auto,
222 "chat" | "chat-completions" | "chat_completions" => ApiMode::ChatCompletions,
223 "anthropic" | "messages" | "anthropic-messages" | "anthropic_messages" => {
224 ApiMode::AnthropicMessages
225 },
226 _ => ApiMode::Auto,
227 }
228}
229
230impl Default for CommitConfig {
231 fn default() -> Self {
232 Self {
233 api_base_url: "http://localhost:4000".to_string(),
234 api_mode: default_api_mode(),
235 api_key: None,
236 request_timeout_secs: 120,
237 connect_timeout_secs: 30,
238 disable_git_background_features: default_disable_git_background_features(),
239 compose_max_rounds: 5,
240 summary_guideline: 72,
241 summary_soft_limit: 96,
242 summary_hard_limit: 128,
243 max_retries: 3,
244 initial_backoff_ms: 1000,
245 auto_fast_threshold_lines: default_auto_fast_threshold_lines(),
246 max_diff_length: 100000, max_diff_tokens: 25000, wide_change_threshold: 0.50,
249 temperature: 0.2, analysis_model: default_analysis_model(),
251 summary_model: default_summary_model(),
252 legacy_model: None,
253 excluded_files: vec![
254 "Cargo.lock".to_string(),
256 "package-lock.json".to_string(),
258 "npm-shrinkwrap.json".to_string(),
259 "yarn.lock".to_string(),
260 "pnpm-lock.yaml".to_string(),
261 "shrinkwrap.yaml".to_string(),
262 "bun.lock".to_string(),
263 "bun.lockb".to_string(),
264 "deno.lock".to_string(),
265 "composer.lock".to_string(),
267 "Gemfile.lock".to_string(),
269 "poetry.lock".to_string(),
271 "Pipfile.lock".to_string(),
272 "pdm.lock".to_string(),
273 "uv.lock".to_string(),
274 "go.sum".to_string(),
276 "flake.lock".to_string(),
278 "pubspec.lock".to_string(),
280 "Podfile.lock".to_string(),
282 "Packages.resolved".to_string(),
283 "mix.lock".to_string(),
285 "packages.lock.json".to_string(),
287 "gradle.lockfile".to_string(),
289 ],
290 low_priority_extensions: vec![
291 ".lock".to_string(),
292 ".sum".to_string(),
293 ".toml".to_string(),
294 ".yaml".to_string(),
295 ".yml".to_string(),
296 ".json".to_string(),
297 ".md".to_string(),
298 ".txt".to_string(),
299 ".log".to_string(),
300 ".tmp".to_string(),
301 ".bak".to_string(),
302 ],
303 max_detail_tokens: 200,
304 analysis_prompt_variant: default_analysis_prompt_variant(),
305 summary_prompt_variant: default_summary_prompt_variant(),
306 wide_change_abstract: default_wide_change_abstract(),
307 exclude_old_message: default_exclude_old_message(),
308 gpg_sign: default_gpg_sign(),
309 signoff: default_signoff(),
310 types: default_types(),
311 classifier_hint: default_classifier_hint(),
312 categories: default_categories(),
313 changelog_enabled: default_changelog_enabled(),
314 map_reduce_enabled: default_map_reduce_enabled(),
315 map_reduce_threshold: default_map_reduce_threshold(),
316 cache_enabled: default_cache_enabled(),
317 cache_ttl_days: default_cache_ttl_days(),
318 cache_dir: None,
319 analysis_prompt: String::new(),
320 summary_prompt: String::new(),
321 }
322 }
323}
324
325fn expand_tilde(raw: &str) -> std::path::PathBuf {
326 if let Some(rest) = raw.strip_prefix("~/")
327 && let Ok(home) = std::env::var("HOME")
328 {
329 return Path::new(&home).join(rest);
330 }
331 PathBuf::from(raw)
332}
333
334fn resolve_command_value(cmd: &str) -> Result<String> {
341 let trimmed = cmd.trim();
342 if let Some(rest) = trimmed.strip_prefix("cat ") {
343 let path = expand_tilde(rest.trim().trim_matches(|c| c == '\'' || c == '"'));
344 let contents = std::fs::read_to_string(&path).map_err(|e| {
345 CommitGenError::Other(format!("api_key `!cat` failed to read {}: {e}", path.display()))
346 })?;
347 return Ok(contents.trim().to_string());
348 }
349 let output = std::process::Command::new("sh")
350 .arg("-c")
351 .arg(trimmed)
352 .output()
353 .map_err(|e| CommitGenError::Other(format!("api_key `!{trimmed}` failed to spawn: {e}")))?;
354 if !output.status.success() {
355 let stderr = String::from_utf8_lossy(&output.stderr);
356 return Err(CommitGenError::Other(format!(
357 "api_key `!{trimmed}` exited with status {:?}: {stderr}",
358 output.status.code()
359 )));
360 }
361 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
362}
363
364impl CommitConfig {
365 pub fn resolved_api_mode(&self, _model_name: &str) -> ResolvedApiMode {
366 match self.api_mode {
367 ApiMode::ChatCompletions => ResolvedApiMode::ChatCompletions,
368 ApiMode::AnthropicMessages => ResolvedApiMode::AnthropicMessages,
369 ApiMode::Auto => {
370 let base = self.api_base_url.to_lowercase();
371 if base.contains("anthropic") {
372 ResolvedApiMode::AnthropicMessages
373 } else {
374 ResolvedApiMode::ChatCompletions
375 }
376 },
377 }
378 }
379
380 pub fn load() -> Result<Self> {
387 let config_path = if let Ok(custom_path) = std::env::var("LLM_GIT_CONFIG") {
388 PathBuf::from(custom_path)
389 } else {
390 Self::default_config_path().unwrap_or_else(|_| PathBuf::new())
391 };
392
393 let mut config = if config_path.exists() {
394 Self::from_file(&config_path)?
395 } else {
396 Self::default()
397 };
398
399 Self::apply_env_overrides(&mut config);
401 config.normalize_models();
402
403 if let Some(raw) = config.api_key.as_deref()
408 && let Some(rest) = raw.strip_prefix('!')
409 {
410 let resolved = resolve_command_value(rest.trim())?;
411 config.api_key = Some(resolved);
412 }
413
414 config.load_prompts()?;
415 Ok(config)
416 }
417
418 fn apply_env_overrides(config: &mut Self) {
420 if let Ok(api_url) = std::env::var("LLM_GIT_API_URL") {
421 config.api_base_url = api_url;
422 }
423
424 if let Ok(api_key) = std::env::var("LLM_GIT_API_KEY") {
425 config.api_key = Some(api_key);
426 }
427
428 if let Ok(api_mode) = std::env::var("LLM_GIT_API_MODE") {
429 config.api_mode = parse_api_mode(&api_mode);
430 }
431
432 if let Ok(value) = std::env::var("LLM_GIT_DISABLE_GIT_BACKGROUND_FEATURES") {
433 match value.trim().to_ascii_lowercase().as_str() {
434 "1" | "true" | "yes" | "on" => config.disable_git_background_features = true,
435 "0" | "false" | "no" | "off" => config.disable_git_background_features = false,
436 _ => {},
437 }
438 }
439
440 if let Ok(value) = std::env::var("LLM_GIT_CACHE_DISABLED") {
441 let trimmed = value.trim().to_ascii_lowercase();
442 if matches!(trimmed.as_str(), "1" | "true" | "yes" | "on") {
443 config.cache_enabled = false;
444 }
445 }
446
447 if let Ok(value) = std::env::var("LLM_GIT_CACHE_TTL_DAYS")
448 && let Ok(days) = value.trim().parse::<u32>()
449 {
450 config.cache_ttl_days = days;
451 }
452
453 if let Ok(value) = std::env::var("LLM_GIT_CACHE_DIR") {
454 let trimmed = value.trim();
455 config.cache_dir = (!trimmed.is_empty()).then(|| trimmed.to_string());
456 }
457 }
458
459 pub fn from_file(path: &Path) -> Result<Self> {
461 let contents = std::fs::read_to_string(path)
462 .map_err(|e| CommitGenError::Other(format!("Failed to read config: {e}")))?;
463 let mut config: Self = toml::from_str(&contents)
464 .map_err(|e| CommitGenError::Other(format!("Failed to parse config: {e}")))?;
465
466 Self::apply_env_overrides(&mut config);
468 config.normalize_models();
469
470 config.load_prompts()?;
471 Ok(config)
472 }
473
474 fn normalize_models(&mut self) {
475 if let Some(model) = self.legacy_model.as_ref() {
476 self.analysis_model = model.clone();
477 if self.summary_model == default_summary_model() {
478 self.summary_model = model.clone();
479 }
480 }
481 }
482
483 fn load_prompts(&mut self) -> Result<()> {
486 crate::templates::ensure_prompts_dir()?;
488
489 self.analysis_prompt = String::new();
491 self.summary_prompt = String::new();
492 Ok(())
493 }
494
495 pub fn default_config_path() -> Result<PathBuf> {
498 if let Ok(home) = std::env::var("HOME") {
500 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
501 }
502
503 if let Ok(home) = std::env::var("USERPROFILE") {
505 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
506 }
507
508 Err(CommitGenError::Other("No home directory found (tried HOME and USERPROFILE)".to_string()))
509 }
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515
516 #[test]
517 fn test_normalize_models_legacy_model_sets_summary_when_default() {
518 let mut config = CommitConfig {
519 legacy_model: Some("gpt-5.3-codex-spark".to_string()),
520 ..CommitConfig::default()
521 };
522
523 config.normalize_models();
524
525 assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
526 assert_eq!(config.summary_model, "gpt-5.3-codex-spark");
527 assert_eq!(config.legacy_model.as_deref(), Some("gpt-5.3-codex-spark"));
528 }
529
530 #[test]
531 fn test_normalize_models_preserves_explicit_summary_model() {
532 let mut config = CommitConfig {
533 summary_model: "gpt-5-mini".to_string(),
534 legacy_model: Some("gpt-5.3-codex-spark".to_string()),
535 ..CommitConfig::default()
536 };
537
538 config.normalize_models();
539
540 assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
541 assert_eq!(config.summary_model, "gpt-5-mini");
542 }
543}