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   #[serde(default = "default_analysis_model")]
67   pub analysis_model:            String,
68   #[serde(default = "default_summary_model")]
69   pub summary_model:             String,
70   /// Legacy single-model config key. Parsed for backward compatibility and
71   /// normalized into `analysis_model`, and into `summary_model` when the
72   /// summary model was not set explicitly.
73   #[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   /// Maximum token budget for commit message detail points (approx 4
79   /// chars/token)
80   pub max_detail_tokens: usize,
81
82   /// Prompt variant for analysis phase (e.g., "default")
83   #[serde(default = "default_analysis_prompt_variant")]
84   pub analysis_prompt_variant: String,
85
86   /// Prompt variant for summary phase (e.g., "default")
87   #[serde(default = "default_summary_prompt_variant")]
88   pub summary_prompt_variant: String,
89
90   /// Enable abstract summaries for wide changes (cross-cutting refactors)
91   #[serde(default = "default_wide_change_abstract")]
92   pub wide_change_abstract: bool,
93
94   /// Emit LLM output as Markdown instead of JSON tool calls (default: true).
95   /// Set to false to use strict JSON tool/function calling instead.
96   #[serde(default = "default_markdown_output")]
97   pub markdown_output: bool,
98
99   /// Exclude old commit message from context in commit mode (rewrite mode uses
100   /// this)
101   #[serde(default = "default_exclude_old_message")]
102   pub exclude_old_message: bool,
103
104   /// GPG sign commits by default (can be overridden by --sign CLI flag)
105   #[serde(default = "default_gpg_sign")]
106   pub gpg_sign: bool,
107
108   /// Add Signed-off-by trailer by default (can be overridden by --signoff CLI
109   /// flag)
110   #[serde(default = "default_signoff")]
111   pub signoff: bool,
112
113   /// Commit types with descriptions for AI prompts (order = priority)
114   #[serde(default = "default_types")]
115   pub types: IndexMap<String, TypeConfig>,
116
117   /// Global hint for cross-type disambiguation
118   #[serde(default = "default_classifier_hint")]
119   pub classifier_hint: String,
120
121   /// Changelog categories with matching rules (order = render order)
122   #[serde(default = "default_categories")]
123   pub categories: Vec<CategoryConfig>,
124
125   /// Enable automatic changelog updates (default: true)
126   #[serde(default = "default_changelog_enabled")]
127   pub changelog_enabled: bool,
128
129   /// Enable map-reduce for large diffs (default: true)
130   #[serde(default = "default_map_reduce_enabled")]
131   pub map_reduce_enabled: bool,
132
133   /// Token threshold for routing to map-reduce before holistic smart
134   /// truncation. Diffs below this stay in one analysis call and are bounded by
135   /// `max_diff_length` instead (default: 5000 tokens).
136   #[serde(default = "default_map_reduce_threshold")]
137   pub map_reduce_threshold: usize,
138
139   /// Token budget for each map-reduce map batch. Files are greedily packed up
140   /// to this budget before an LLM call; oversized files run alone.
141   #[serde(default = "default_map_batch_token_budget")]
142   pub map_batch_token_budget: usize,
143
144   /// Enable the on-disk LLM response cache (default: true). Cache survives
145   /// across runs so reruns reuse parsed call results when prompts match.
146   #[serde(default = "default_cache_enabled")]
147   pub cache_enabled: bool,
148
149   /// TTL in days for cached LLM responses (default: 14). Set to 0 to keep
150   /// entries forever.
151   #[serde(default = "default_cache_ttl_days")]
152   pub cache_ttl_days: u32,
153
154   /// Override directory for the LLM response cache. Defaults to
155   /// `$XDG_CACHE_HOME/llm-git` (or `~/.cache/llm-git`).
156   #[serde(default)]
157   pub cache_dir: Option<String>,
158
159   /// Loaded analysis prompt (not in config file)
160   #[serde(skip)]
161   pub analysis_prompt: String,
162
163   /// Loaded summary prompt (not in config file)
164   #[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 // ~5k tokens, roughly 20k characters
230}
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, // Increased to handle larger refactors better
268         max_diff_tokens: 25000,  // ~100K chars = 25K tokens (4 chars/token estimate)
269         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            // Rust
275            "Cargo.lock".to_string(),
276            // JavaScript/Node
277            "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            // PHP
286            "composer.lock".to_string(),
287            // Ruby
288            "Gemfile.lock".to_string(),
289            // Python
290            "poetry.lock".to_string(),
291            "Pipfile.lock".to_string(),
292            "pdm.lock".to_string(),
293            "uv.lock".to_string(),
294            // Go
295            "go.sum".to_string(),
296            // Nix
297            "flake.lock".to_string(),
298            // Dart/Flutter
299            "pubspec.lock".to_string(),
300            // iOS/macOS
301            "Podfile.lock".to_string(),
302            "Packages.resolved".to_string(),
303            // Elixir
304            "mix.lock".to_string(),
305            // .NET
306            "packages.lock.json".to_string(),
307            // Gradle
308            "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
356/// Resolve a `!`-prefixed config value (already stripped of `!`).
357///
358/// Special-cases `cat <path>` to read the file directly without spawning a
359/// subprocess — the overwhelmingly common case for keys-from-file. Everything
360/// else runs through `/bin/sh -c` and captures stdout, mirroring omp's
361/// `resolveConfigValue`.
362fn 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   /// Load config from default location (~/.config/llm-git/config.toml)
403   /// Falls back to Default if file doesn't exist or can't determine home
404   /// directory Environment variables override config file values:
405   /// - `LLM_GIT_API_URL` overrides `api_base_url`
406   /// - `LLM_GIT_API_KEY` overrides `api_key`
407   /// - `LLM_GIT_API_MODE` overrides `api_mode`
408   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      // Apply environment variable overrides
422      Self::apply_env_overrides(&mut config);
423      config.normalize_models();
424
425      // Resolve `!command` / `!cat <path>` syntax in `api_key`. Mirrors omp's
426      // resolve-config-value behavior so users can pull credentials from a
427      // helper file (e.g. `api_key = "!cat ~/.omp/auth-gateway.token"`)
428      // without pasting the secret into config.toml verbatim.
429      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   /// Apply environment variable overrides to config
441   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   /// Load config from specific file
482   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      // Apply environment variable overrides
489      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   /// Load prompts - templates are now loaded dynamically via Tera
506   /// This method ensures prompts are initialized
507   fn load_prompts(&mut self) -> Result<()> {
508      // Ensure prompts directory exists and embedded templates are unpacked
509      crate::templates::ensure_prompts_dir()?;
510
511      // Templates loaded dynamically at render time
512      self.analysis_prompt = String::new();
513      self.summary_prompt = String::new();
514      Ok(())
515   }
516
517   /// Get default config path (platform-safe)
518   /// Tries HOME (Unix/Linux/macOS) then USERPROFILE (Windows)
519   pub fn default_config_path() -> Result<PathBuf> {
520      // Try HOME first (Unix/Linux/macOS)
521      if let Ok(home) = std::env::var("HOME") {
522         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
523      }
524
525      // Try USERPROFILE on Windows
526      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}