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,
64 pub max_diff_tokens: usize,
65 pub wide_change_threshold: f32,
66 pub temperature: f32,
67 #[serde(default = "default_analysis_model")]
68 pub analysis_model: String,
69 #[serde(default = "default_summary_model")]
70 pub summary_model: String,
71 #[serde(default, rename = "model")]
75 pub legacy_model: Option<String>,
76 pub excluded_files: Vec<String>,
77 pub low_priority_extensions: Vec<String>,
78
79 pub max_detail_tokens: usize,
82
83 #[serde(default = "default_analysis_prompt_variant")]
85 pub analysis_prompt_variant: String,
86
87 #[serde(default = "default_summary_prompt_variant")]
89 pub summary_prompt_variant: String,
90
91 #[serde(default = "default_wide_change_abstract")]
93 pub wide_change_abstract: bool,
94
95 #[serde(default = "default_exclude_old_message")]
98 pub exclude_old_message: bool,
99
100 #[serde(default = "default_gpg_sign")]
102 pub gpg_sign: bool,
103
104 #[serde(default = "default_signoff")]
107 pub signoff: bool,
108
109 #[serde(default = "default_types")]
111 pub types: IndexMap<String, TypeConfig>,
112
113 #[serde(default = "default_classifier_hint")]
115 pub classifier_hint: String,
116
117 #[serde(default = "default_categories")]
119 pub categories: Vec<CategoryConfig>,
120
121 #[serde(default = "default_changelog_enabled")]
123 pub changelog_enabled: bool,
124
125 #[serde(default = "default_map_reduce_enabled")]
127 pub map_reduce_enabled: bool,
128
129 #[serde(default = "default_map_reduce_threshold")]
133 pub map_reduce_threshold: usize,
134
135 #[serde(default = "default_map_batch_token_budget")]
138 pub map_batch_token_budget: usize,
139
140 #[serde(default = "default_cache_enabled")]
143 pub cache_enabled: bool,
144
145 #[serde(default = "default_cache_ttl_days")]
148 pub cache_ttl_days: u32,
149
150 #[serde(default)]
153 pub cache_dir: Option<String>,
154
155 #[serde(skip)]
157 pub analysis_prompt: String,
158
159 #[serde(skip)]
161 pub summary_prompt: String,
162}
163
164fn default_analysis_prompt_variant() -> String {
165 "default".to_string()
166}
167
168const fn default_api_mode() -> ApiMode {
169 ApiMode::Auto
170}
171
172const fn default_disable_git_background_features() -> bool {
173 true
174}
175
176fn default_summary_prompt_variant() -> String {
177 "default".to_string()
178}
179
180fn default_analysis_model() -> String {
181 "claude-opus-4.5".to_string()
182}
183
184fn default_summary_model() -> String {
185 "claude-haiku-4-5".to_string()
186}
187
188const fn default_wide_change_abstract() -> bool {
189 true
190}
191
192const fn default_exclude_old_message() -> bool {
193 true
194}
195
196const fn default_gpg_sign() -> bool {
197 false
198}
199
200const fn default_signoff() -> bool {
201 false
202}
203
204const fn default_cache_enabled() -> bool {
205 true
206}
207
208const fn default_cache_ttl_days() -> u32 {
209 14
210}
211
212const fn default_changelog_enabled() -> bool {
213 true
214}
215
216const fn default_map_reduce_enabled() -> bool {
217 true
218}
219
220const fn default_map_reduce_threshold() -> usize {
221 5000 }
223
224const fn default_map_batch_token_budget() -> usize {
225 16_000
226}
227
228const fn default_auto_fast_threshold_lines() -> usize {
229 200
230}
231
232fn parse_api_mode(value: &str) -> ApiMode {
233 match value.trim().to_lowercase().as_str() {
234 "auto" => ApiMode::Auto,
235 "chat" | "chat-completions" | "chat_completions" => ApiMode::ChatCompletions,
236 "anthropic" | "messages" | "anthropic-messages" | "anthropic_messages" => {
237 ApiMode::AnthropicMessages
238 },
239 _ => ApiMode::Auto,
240 }
241}
242
243impl Default for CommitConfig {
244 fn default() -> Self {
245 Self {
246 api_base_url: "http://localhost:4000".to_string(),
247 api_mode: default_api_mode(),
248 api_key: None,
249 request_timeout_secs: 120,
250 connect_timeout_secs: 30,
251 disable_git_background_features: default_disable_git_background_features(),
252 compose_max_rounds: 5,
253 summary_guideline: 72,
254 summary_soft_limit: 96,
255 summary_hard_limit: 128,
256 max_retries: 3,
257 initial_backoff_ms: 1000,
258 auto_fast_threshold_lines: default_auto_fast_threshold_lines(),
259 max_diff_length: 100000, max_diff_tokens: 25000, wide_change_threshold: 0.50,
262 temperature: 0.2, analysis_model: default_analysis_model(),
264 summary_model: default_summary_model(),
265 legacy_model: None,
266 excluded_files: vec![
267 "Cargo.lock".to_string(),
269 "package-lock.json".to_string(),
271 "npm-shrinkwrap.json".to_string(),
272 "yarn.lock".to_string(),
273 "pnpm-lock.yaml".to_string(),
274 "shrinkwrap.yaml".to_string(),
275 "bun.lock".to_string(),
276 "bun.lockb".to_string(),
277 "deno.lock".to_string(),
278 "composer.lock".to_string(),
280 "Gemfile.lock".to_string(),
282 "poetry.lock".to_string(),
284 "Pipfile.lock".to_string(),
285 "pdm.lock".to_string(),
286 "uv.lock".to_string(),
287 "go.sum".to_string(),
289 "flake.lock".to_string(),
291 "pubspec.lock".to_string(),
293 "Podfile.lock".to_string(),
295 "Packages.resolved".to_string(),
296 "mix.lock".to_string(),
298 "packages.lock.json".to_string(),
300 "gradle.lockfile".to_string(),
302 ],
303 low_priority_extensions: vec![
304 ".lock".to_string(),
305 ".sum".to_string(),
306 ".toml".to_string(),
307 ".yaml".to_string(),
308 ".yml".to_string(),
309 ".json".to_string(),
310 ".md".to_string(),
311 ".txt".to_string(),
312 ".log".to_string(),
313 ".tmp".to_string(),
314 ".bak".to_string(),
315 ],
316 max_detail_tokens: 200,
317 analysis_prompt_variant: default_analysis_prompt_variant(),
318 summary_prompt_variant: default_summary_prompt_variant(),
319 wide_change_abstract: default_wide_change_abstract(),
320 exclude_old_message: default_exclude_old_message(),
321 gpg_sign: default_gpg_sign(),
322 signoff: default_signoff(),
323 types: default_types(),
324 classifier_hint: default_classifier_hint(),
325 categories: default_categories(),
326 changelog_enabled: default_changelog_enabled(),
327 map_reduce_enabled: default_map_reduce_enabled(),
328 map_reduce_threshold: default_map_reduce_threshold(),
329 map_batch_token_budget: default_map_batch_token_budget(),
330 cache_enabled: default_cache_enabled(),
331 cache_ttl_days: default_cache_ttl_days(),
332 cache_dir: None,
333 analysis_prompt: String::new(),
334 summary_prompt: String::new(),
335 }
336 }
337}
338
339fn expand_tilde(raw: &str) -> std::path::PathBuf {
340 if let Some(rest) = raw.strip_prefix("~/")
341 && let Ok(home) = std::env::var("HOME")
342 {
343 return Path::new(&home).join(rest);
344 }
345 PathBuf::from(raw)
346}
347
348fn resolve_command_value(cmd: &str) -> Result<String> {
355 let trimmed = cmd.trim();
356 if let Some(rest) = trimmed.strip_prefix("cat ") {
357 let path = expand_tilde(rest.trim().trim_matches(|c| c == '\'' || c == '"'));
358 let contents = std::fs::read_to_string(&path).map_err(|e| {
359 CommitGenError::Other(format!("api_key `!cat` failed to read {}: {e}", path.display()))
360 })?;
361 return Ok(contents.trim().to_string());
362 }
363 let output = std::process::Command::new("sh")
364 .arg("-c")
365 .arg(trimmed)
366 .output()
367 .map_err(|e| CommitGenError::Other(format!("api_key `!{trimmed}` failed to spawn: {e}")))?;
368 if !output.status.success() {
369 let stderr = String::from_utf8_lossy(&output.stderr);
370 return Err(CommitGenError::Other(format!(
371 "api_key `!{trimmed}` exited with status {:?}: {stderr}",
372 output.status.code()
373 )));
374 }
375 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
376}
377
378impl CommitConfig {
379 pub fn resolved_api_mode(&self, _model_name: &str) -> ResolvedApiMode {
380 match self.api_mode {
381 ApiMode::ChatCompletions => ResolvedApiMode::ChatCompletions,
382 ApiMode::AnthropicMessages => ResolvedApiMode::AnthropicMessages,
383 ApiMode::Auto => {
384 let base = self.api_base_url.to_lowercase();
385 if base.contains("anthropic") {
386 ResolvedApiMode::AnthropicMessages
387 } else {
388 ResolvedApiMode::ChatCompletions
389 }
390 },
391 }
392 }
393
394 pub fn load() -> Result<Self> {
401 let config_path = if let Ok(custom_path) = std::env::var("LLM_GIT_CONFIG") {
402 PathBuf::from(custom_path)
403 } else {
404 Self::default_config_path().unwrap_or_else(|_| PathBuf::new())
405 };
406
407 let mut config = if config_path.exists() {
408 Self::from_file(&config_path)?
409 } else {
410 Self::default()
411 };
412
413 Self::apply_env_overrides(&mut config);
415 config.normalize_models();
416
417 if let Some(raw) = config.api_key.as_deref()
422 && let Some(rest) = raw.strip_prefix('!')
423 {
424 let resolved = resolve_command_value(rest.trim())?;
425 config.api_key = Some(resolved);
426 }
427
428 config.load_prompts()?;
429 Ok(config)
430 }
431
432 fn apply_env_overrides(config: &mut Self) {
434 if let Ok(api_url) = std::env::var("LLM_GIT_API_URL") {
435 config.api_base_url = api_url;
436 }
437
438 if let Ok(api_key) = std::env::var("LLM_GIT_API_KEY") {
439 config.api_key = Some(api_key);
440 }
441
442 if let Ok(api_mode) = std::env::var("LLM_GIT_API_MODE") {
443 config.api_mode = parse_api_mode(&api_mode);
444 }
445
446 if let Ok(value) = std::env::var("LLM_GIT_DISABLE_GIT_BACKGROUND_FEATURES") {
447 match value.trim().to_ascii_lowercase().as_str() {
448 "1" | "true" | "yes" | "on" => config.disable_git_background_features = true,
449 "0" | "false" | "no" | "off" => config.disable_git_background_features = false,
450 _ => {},
451 }
452 }
453
454 if let Ok(value) = std::env::var("LLM_GIT_CACHE_DISABLED") {
455 let trimmed = value.trim().to_ascii_lowercase();
456 if matches!(trimmed.as_str(), "1" | "true" | "yes" | "on") {
457 config.cache_enabled = false;
458 }
459 }
460
461 if let Ok(value) = std::env::var("LLM_GIT_CACHE_TTL_DAYS")
462 && let Ok(days) = value.trim().parse::<u32>()
463 {
464 config.cache_ttl_days = days;
465 }
466
467 if let Ok(value) = std::env::var("LLM_GIT_CACHE_DIR") {
468 let trimmed = value.trim();
469 config.cache_dir = (!trimmed.is_empty()).then(|| trimmed.to_string());
470 }
471 }
472
473 pub fn from_file(path: &Path) -> Result<Self> {
475 let contents = std::fs::read_to_string(path)
476 .map_err(|e| CommitGenError::Other(format!("Failed to read config: {e}")))?;
477 let mut config: Self = toml::from_str(&contents)
478 .map_err(|e| CommitGenError::Other(format!("Failed to parse config: {e}")))?;
479
480 Self::apply_env_overrides(&mut config);
482 config.normalize_models();
483
484 config.load_prompts()?;
485 Ok(config)
486 }
487
488 fn normalize_models(&mut self) {
489 if let Some(model) = self.legacy_model.as_ref() {
490 self.analysis_model = model.clone();
491 if self.summary_model == default_summary_model() {
492 self.summary_model = model.clone();
493 }
494 }
495 }
496
497 fn load_prompts(&mut self) -> Result<()> {
500 crate::templates::ensure_prompts_dir()?;
502
503 self.analysis_prompt = String::new();
505 self.summary_prompt = String::new();
506 Ok(())
507 }
508
509 pub fn default_config_path() -> Result<PathBuf> {
512 if let Ok(home) = std::env::var("HOME") {
514 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
515 }
516
517 if let Ok(home) = std::env::var("USERPROFILE") {
519 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
520 }
521
522 Err(CommitGenError::Other("No home directory found (tried HOME and USERPROFILE)".to_string()))
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_normalize_models_legacy_model_sets_summary_when_default() {
532 let mut config = CommitConfig {
533 legacy_model: Some("gpt-5.3-codex-spark".to_string()),
534 ..CommitConfig::default()
535 };
536
537 config.normalize_models();
538
539 assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
540 assert_eq!(config.summary_model, "gpt-5.3-codex-spark");
541 assert_eq!(config.legacy_model.as_deref(), Some("gpt-5.3-codex-spark"));
542 }
543
544 #[test]
545 fn test_normalize_models_preserves_explicit_summary_model() {
546 let mut config = CommitConfig {
547 summary_model: "gpt-5-mini".to_string(),
548 legacy_model: Some("gpt-5.3-codex-spark".to_string()),
549 ..CommitConfig::default()
550 };
551
552 config.normalize_models();
553
554 assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
555 assert_eq!(config.summary_model, "gpt-5-mini");
556 }
557}