Skip to main content

llm_git/
config.rs

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   /// API mode for model endpoints (auto/chat-completions/anthropic-messages)
33   #[serde(default = "default_api_mode")]
34   pub api_mode: ApiMode,
35
36   /// Optional API key for authentication (overridden by `LLM_GIT_API_KEY` env
37   /// var)
38   pub api_key: Option<String>,
39
40   /// HTTP request timeout in seconds
41   pub request_timeout_secs: u64,
42
43   /// HTTP connection timeout in seconds
44   pub connect_timeout_secs: u64,
45
46   /// Disable git background/index features that are slow for short-lived CLI
47   /// subprocesses.
48   #[serde(default = "default_disable_git_background_features")]
49   pub disable_git_background_features: bool,
50
51   /// Maximum rounds for compose mode multi-commit generation
52   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   /// Legacy single-model config key. Parsed for backward compatibility and
70   /// normalized into `analysis_model`, and into `summary_model` when the
71   /// summary model was not set explicitly.
72   #[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   /// Maximum token budget for commit message detail points (approx 4
78   /// chars/token)
79   pub max_detail_tokens: usize,
80
81   /// Prompt variant for analysis phase (e.g., "default")
82   #[serde(default = "default_analysis_prompt_variant")]
83   pub analysis_prompt_variant: String,
84
85   /// Prompt variant for summary phase (e.g., "default")
86   #[serde(default = "default_summary_prompt_variant")]
87   pub summary_prompt_variant: String,
88
89   /// Enable abstract summaries for wide changes (cross-cutting refactors)
90   #[serde(default = "default_wide_change_abstract")]
91   pub wide_change_abstract: bool,
92
93   /// Exclude old commit message from context in commit mode (rewrite mode uses
94   /// this)
95   #[serde(default = "default_exclude_old_message")]
96   pub exclude_old_message: bool,
97
98   /// GPG sign commits by default (can be overridden by --sign CLI flag)
99   #[serde(default = "default_gpg_sign")]
100   pub gpg_sign: bool,
101
102   /// Add Signed-off-by trailer by default (can be overridden by --signoff CLI
103   /// flag)
104   #[serde(default = "default_signoff")]
105   pub signoff: bool,
106
107   /// Commit types with descriptions for AI prompts (order = priority)
108   #[serde(default = "default_types")]
109   pub types: IndexMap<String, TypeConfig>,
110
111   /// Global hint for cross-type disambiguation
112   #[serde(default = "default_classifier_hint")]
113   pub classifier_hint: String,
114
115   /// Changelog categories with matching rules (order = render order)
116   #[serde(default = "default_categories")]
117   pub categories: Vec<CategoryConfig>,
118
119   /// Enable automatic changelog updates (default: true)
120   #[serde(default = "default_changelog_enabled")]
121   pub changelog_enabled: bool,
122
123   /// Enable map-reduce for large diffs (default: true)
124   #[serde(default = "default_map_reduce_enabled")]
125   pub map_reduce_enabled: bool,
126
127   /// Token threshold for triggering map-reduce (default: 30000 tokens)
128   #[serde(default = "default_map_reduce_threshold")]
129   pub map_reduce_threshold: usize,
130
131   /// Enable the on-disk LLM response cache (default: true). Cache survives
132   /// across runs so reruns reuse parsed call results when prompts match.
133   #[serde(default = "default_cache_enabled")]
134   pub cache_enabled: bool,
135
136   /// TTL in days for cached LLM responses (default: 14). Set to 0 to keep
137   /// entries forever.
138   #[serde(default = "default_cache_ttl_days")]
139   pub cache_ttl_days: u32,
140
141   /// Override directory for the LLM response cache. Defaults to
142   /// `$XDG_CACHE_HOME/llm-git` (or `~/.cache/llm-git`).
143   #[serde(default)]
144   pub cache_dir: Option<String>,
145
146   /// Loaded analysis prompt (not in config file)
147   #[serde(skip)]
148   pub analysis_prompt: String,
149
150   /// Loaded summary prompt (not in config file)
151   #[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 // ~30k tokens, roughly 120k characters
213}
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, // Increased to handle larger refactors better
247         max_diff_tokens: 25000,  // ~100K chars = 25K tokens (4 chars/token estimate)
248         wide_change_threshold: 0.50,
249         temperature: 0.2, // Low temperature for consistent structured output
250         analysis_model: default_analysis_model(),
251         summary_model: default_summary_model(),
252         legacy_model: None,
253         excluded_files: vec![
254            // Rust
255            "Cargo.lock".to_string(),
256            // JavaScript/Node
257            "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            // PHP
266            "composer.lock".to_string(),
267            // Ruby
268            "Gemfile.lock".to_string(),
269            // Python
270            "poetry.lock".to_string(),
271            "Pipfile.lock".to_string(),
272            "pdm.lock".to_string(),
273            "uv.lock".to_string(),
274            // Go
275            "go.sum".to_string(),
276            // Nix
277            "flake.lock".to_string(),
278            // Dart/Flutter
279            "pubspec.lock".to_string(),
280            // iOS/macOS
281            "Podfile.lock".to_string(),
282            "Packages.resolved".to_string(),
283            // Elixir
284            "mix.lock".to_string(),
285            // .NET
286            "packages.lock.json".to_string(),
287            // Gradle
288            "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
334/// Resolve a `!`-prefixed config value (already stripped of `!`).
335///
336/// Special-cases `cat <path>` to read the file directly without spawning a
337/// subprocess — the overwhelmingly common case for keys-from-file. Everything
338/// else runs through `/bin/sh -c` and captures stdout, mirroring omp's
339/// `resolveConfigValue`.
340fn 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   /// Load config from default location (~/.config/llm-git/config.toml)
381   /// Falls back to Default if file doesn't exist or can't determine home
382   /// directory Environment variables override config file values:
383   /// - `LLM_GIT_API_URL` overrides `api_base_url`
384   /// - `LLM_GIT_API_KEY` overrides `api_key`
385   /// - `LLM_GIT_API_MODE` overrides `api_mode`
386   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      // Apply environment variable overrides
400      Self::apply_env_overrides(&mut config);
401      config.normalize_models();
402
403      // Resolve `!command` / `!cat <path>` syntax in `api_key`. Mirrors omp's
404      // resolve-config-value behavior so users can pull credentials from a
405      // helper file (e.g. `api_key = "!cat ~/.omp/auth-gateway.token"`)
406      // without pasting the secret into config.toml verbatim.
407      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   /// Apply environment variable overrides to config
419   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   /// Load config from specific file
460   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      // Apply environment variable overrides
467      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   /// Load prompts - templates are now loaded dynamically via Tera
484   /// This method ensures prompts are initialized
485   fn load_prompts(&mut self) -> Result<()> {
486      // Ensure prompts directory exists and embedded templates are unpacked
487      crate::templates::ensure_prompts_dir()?;
488
489      // Templates loaded dynamically at render time
490      self.analysis_prompt = String::new();
491      self.summary_prompt = String::new();
492      Ok(())
493   }
494
495   /// Get default config path (platform-safe)
496   /// Tries HOME (Unix/Linux/macOS) then USERPROFILE (Windows)
497   pub fn default_config_path() -> Result<PathBuf> {
498      // Try HOME first (Unix/Linux/macOS)
499      if let Ok(home) = std::env::var("HOME") {
500         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
501      }
502
503      // Try USERPROFILE on Windows
504      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}