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