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   /// Character ceiling used by smart truncation after a diff stays on the
62   /// holistic path.
63   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   /// Legacy single-model config key. Parsed for backward compatibility and
72   /// normalized into `analysis_model`, and into `summary_model` when the
73   /// summary model was not set explicitly.
74   #[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   /// Maximum token budget for commit message detail points (approx 4
80   /// chars/token)
81   pub max_detail_tokens: usize,
82
83   /// Prompt variant for analysis phase (e.g., "default")
84   #[serde(default = "default_analysis_prompt_variant")]
85   pub analysis_prompt_variant: String,
86
87   /// Prompt variant for summary phase (e.g., "default")
88   #[serde(default = "default_summary_prompt_variant")]
89   pub summary_prompt_variant: String,
90
91   /// Enable abstract summaries for wide changes (cross-cutting refactors)
92   #[serde(default = "default_wide_change_abstract")]
93   pub wide_change_abstract: bool,
94
95   /// Exclude old commit message from context in commit mode (rewrite mode uses
96   /// this)
97   #[serde(default = "default_exclude_old_message")]
98   pub exclude_old_message: bool,
99
100   /// GPG sign commits by default (can be overridden by --sign CLI flag)
101   #[serde(default = "default_gpg_sign")]
102   pub gpg_sign: bool,
103
104   /// Add Signed-off-by trailer by default (can be overridden by --signoff CLI
105   /// flag)
106   #[serde(default = "default_signoff")]
107   pub signoff: bool,
108
109   /// Commit types with descriptions for AI prompts (order = priority)
110   #[serde(default = "default_types")]
111   pub types: IndexMap<String, TypeConfig>,
112
113   /// Global hint for cross-type disambiguation
114   #[serde(default = "default_classifier_hint")]
115   pub classifier_hint: String,
116
117   /// Changelog categories with matching rules (order = render order)
118   #[serde(default = "default_categories")]
119   pub categories: Vec<CategoryConfig>,
120
121   /// Enable automatic changelog updates (default: true)
122   #[serde(default = "default_changelog_enabled")]
123   pub changelog_enabled: bool,
124
125   /// Enable map-reduce for large diffs (default: true)
126   #[serde(default = "default_map_reduce_enabled")]
127   pub map_reduce_enabled: bool,
128
129   /// Token threshold for routing to map-reduce before holistic smart
130   /// truncation. Diffs below this stay in one analysis call and are bounded by
131   /// `max_diff_length` instead (default: 5000 tokens).
132   #[serde(default = "default_map_reduce_threshold")]
133   pub map_reduce_threshold: usize,
134
135   /// Token budget for each map-reduce map batch. Files are greedily packed up
136   /// to this budget before an LLM call; oversized files run alone.
137   #[serde(default = "default_map_batch_token_budget")]
138   pub map_batch_token_budget: usize,
139
140   /// Enable the on-disk LLM response cache (default: true). Cache survives
141   /// across runs so reruns reuse parsed call results when prompts match.
142   #[serde(default = "default_cache_enabled")]
143   pub cache_enabled: bool,
144
145   /// TTL in days for cached LLM responses (default: 14). Set to 0 to keep
146   /// entries forever.
147   #[serde(default = "default_cache_ttl_days")]
148   pub cache_ttl_days: u32,
149
150   /// Override directory for the LLM response cache. Defaults to
151   /// `$XDG_CACHE_HOME/llm-git` (or `~/.cache/llm-git`).
152   #[serde(default)]
153   pub cache_dir: Option<String>,
154
155   /// Loaded analysis prompt (not in config file)
156   #[serde(skip)]
157   pub analysis_prompt: String,
158
159   /// Loaded summary prompt (not in config file)
160   #[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 // ~5k tokens, roughly 20k characters
222}
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, // Increased to handle larger refactors better
260         max_diff_tokens: 25000,  // ~100K chars = 25K tokens (4 chars/token estimate)
261         wide_change_threshold: 0.50,
262         temperature: 0.2, // Low temperature for consistent structured output
263         analysis_model: default_analysis_model(),
264         summary_model: default_summary_model(),
265         legacy_model: None,
266         excluded_files: vec![
267            // Rust
268            "Cargo.lock".to_string(),
269            // JavaScript/Node
270            "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            // PHP
279            "composer.lock".to_string(),
280            // Ruby
281            "Gemfile.lock".to_string(),
282            // Python
283            "poetry.lock".to_string(),
284            "Pipfile.lock".to_string(),
285            "pdm.lock".to_string(),
286            "uv.lock".to_string(),
287            // Go
288            "go.sum".to_string(),
289            // Nix
290            "flake.lock".to_string(),
291            // Dart/Flutter
292            "pubspec.lock".to_string(),
293            // iOS/macOS
294            "Podfile.lock".to_string(),
295            "Packages.resolved".to_string(),
296            // Elixir
297            "mix.lock".to_string(),
298            // .NET
299            "packages.lock.json".to_string(),
300            // Gradle
301            "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
348/// Resolve a `!`-prefixed config value (already stripped of `!`).
349///
350/// Special-cases `cat <path>` to read the file directly without spawning a
351/// subprocess — the overwhelmingly common case for keys-from-file. Everything
352/// else runs through `/bin/sh -c` and captures stdout, mirroring omp's
353/// `resolveConfigValue`.
354fn 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   /// Load config from default location (~/.config/llm-git/config.toml)
395   /// Falls back to Default if file doesn't exist or can't determine home
396   /// directory Environment variables override config file values:
397   /// - `LLM_GIT_API_URL` overrides `api_base_url`
398   /// - `LLM_GIT_API_KEY` overrides `api_key`
399   /// - `LLM_GIT_API_MODE` overrides `api_mode`
400   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      // Apply environment variable overrides
414      Self::apply_env_overrides(&mut config);
415      config.normalize_models();
416
417      // Resolve `!command` / `!cat <path>` syntax in `api_key`. Mirrors omp's
418      // resolve-config-value behavior so users can pull credentials from a
419      // helper file (e.g. `api_key = "!cat ~/.omp/auth-gateway.token"`)
420      // without pasting the secret into config.toml verbatim.
421      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   /// Apply environment variable overrides to config
433   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   /// Load config from specific file
474   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      // Apply environment variable overrides
481      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   /// Load prompts - templates are now loaded dynamically via Tera
498   /// This method ensures prompts are initialized
499   fn load_prompts(&mut self) -> Result<()> {
500      // Ensure prompts directory exists and embedded templates are unpacked
501      crate::templates::ensure_prompts_dir()?;
502
503      // Templates loaded dynamically at render time
504      self.analysis_prompt = String::new();
505      self.summary_prompt = String::new();
506      Ok(())
507   }
508
509   /// Get default config path (platform-safe)
510   /// Tries HOME (Unix/Linux/macOS) then USERPROFILE (Windows)
511   pub fn default_config_path() -> Result<PathBuf> {
512      // Try HOME first (Unix/Linux/macOS)
513      if let Ok(home) = std::env::var("HOME") {
514         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
515      }
516
517      // Try USERPROFILE on Windows
518      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}