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   /// Maximum rounds for compose mode multi-commit generation
47   pub compose_max_rounds: usize,
48
49   pub summary_guideline:       usize,
50   pub summary_soft_limit:      usize,
51   pub summary_hard_limit:      usize,
52   pub max_retries:             u32,
53   pub initial_backoff_ms:      u64,
54   pub max_diff_length:         usize,
55   pub max_diff_tokens:         usize,
56   pub wide_change_threshold:   f32,
57   pub temperature:             f32,
58   pub model:                   String,
59   pub excluded_files:          Vec<String>,
60   pub low_priority_extensions: Vec<String>,
61
62   /// Maximum token budget for commit message detail points (approx 4
63   /// chars/token)
64   pub max_detail_tokens: usize,
65
66   /// Prompt variant for analysis phase (e.g., "default")
67   #[serde(default = "default_analysis_prompt_variant")]
68   pub analysis_prompt_variant: String,
69
70   /// Prompt variant for summary phase (e.g., "default")
71   #[serde(default = "default_summary_prompt_variant")]
72   pub summary_prompt_variant: String,
73
74   /// Enable abstract summaries for wide changes (cross-cutting refactors)
75   #[serde(default = "default_wide_change_abstract")]
76   pub wide_change_abstract: bool,
77
78   /// Exclude old commit message from context in commit mode (rewrite mode uses
79   /// this)
80   #[serde(default = "default_exclude_old_message")]
81   pub exclude_old_message: bool,
82
83   /// GPG sign commits by default (can be overridden by --sign CLI flag)
84   #[serde(default = "default_gpg_sign")]
85   pub gpg_sign: bool,
86
87   /// Add Signed-off-by trailer by default (can be overridden by --signoff CLI
88   /// flag)
89   #[serde(default = "default_signoff")]
90   pub signoff: bool,
91
92   /// Commit types with descriptions for AI prompts (order = priority)
93   #[serde(default = "default_types")]
94   pub types: IndexMap<String, TypeConfig>,
95
96   /// Global hint for cross-type disambiguation
97   #[serde(default = "default_classifier_hint")]
98   pub classifier_hint: String,
99
100   /// Changelog categories with matching rules (order = render order)
101   #[serde(default = "default_categories")]
102   pub categories: Vec<CategoryConfig>,
103
104   /// Enable automatic changelog updates (default: true)
105   #[serde(default = "default_changelog_enabled")]
106   pub changelog_enabled: bool,
107
108   /// Enable map-reduce for large diffs (default: true)
109   #[serde(default = "default_map_reduce_enabled")]
110   pub map_reduce_enabled: bool,
111
112   /// Token threshold for triggering map-reduce (default: 30000 tokens)
113   #[serde(default = "default_map_reduce_threshold")]
114   pub map_reduce_threshold: usize,
115
116   /// Loaded analysis prompt (not in config file)
117   #[serde(skip)]
118   pub analysis_prompt: String,
119
120   /// Loaded summary prompt (not in config file)
121   #[serde(skip)]
122   pub summary_prompt: String,
123}
124
125fn default_analysis_prompt_variant() -> String {
126   "default".to_string()
127}
128
129const fn default_api_mode() -> ApiMode {
130   ApiMode::Auto
131}
132
133fn default_summary_prompt_variant() -> String {
134   "default".to_string()
135}
136
137const fn default_wide_change_abstract() -> bool {
138   true
139}
140
141const fn default_exclude_old_message() -> bool {
142   true
143}
144
145const fn default_gpg_sign() -> bool {
146   false
147}
148
149const fn default_signoff() -> bool {
150   false
151}
152
153const fn default_changelog_enabled() -> bool {
154   true
155}
156
157const fn default_map_reduce_enabled() -> bool {
158   true
159}
160
161const fn default_map_reduce_threshold() -> usize {
162   30000 // ~30k tokens, roughly 120k characters
163}
164
165fn parse_api_mode(value: &str) -> ApiMode {
166   match value.trim().to_lowercase().as_str() {
167      "auto" => ApiMode::Auto,
168      "chat" | "chat-completions" | "chat_completions" => ApiMode::ChatCompletions,
169      "anthropic" | "messages" | "anthropic-messages" | "anthropic_messages" => {
170         ApiMode::AnthropicMessages
171      },
172      _ => ApiMode::Auto,
173   }
174}
175
176impl Default for CommitConfig {
177   fn default() -> Self {
178      Self {
179         api_base_url:            "http://localhost:4000".to_string(),
180         api_mode:                default_api_mode(),
181         api_key:                 None,
182         request_timeout_secs:    120,
183         connect_timeout_secs:    30,
184         compose_max_rounds:      5,
185         summary_guideline:       72,
186         summary_soft_limit:      96,
187         summary_hard_limit:      128,
188         max_retries:             3,
189         initial_backoff_ms:      1000,
190         max_diff_length:         100000, // Increased to handle larger refactors better
191         max_diff_tokens:         25000,  // ~100K chars = 25K tokens (4 chars/token estimate)
192         wide_change_threshold:   0.50,
193         temperature:             0.2, // Low temperature for consistent structured output
194         model:                   "claude-opus-4.5".to_string(),
195         excluded_files:          vec![
196            // Rust
197            "Cargo.lock".to_string(),
198            // JavaScript/Node
199            "package-lock.json".to_string(),
200            "npm-shrinkwrap.json".to_string(),
201            "yarn.lock".to_string(),
202            "pnpm-lock.yaml".to_string(),
203            "shrinkwrap.yaml".to_string(),
204            "bun.lock".to_string(),
205            "bun.lockb".to_string(),
206            "deno.lock".to_string(),
207            // PHP
208            "composer.lock".to_string(),
209            // Ruby
210            "Gemfile.lock".to_string(),
211            // Python
212            "poetry.lock".to_string(),
213            "Pipfile.lock".to_string(),
214            "pdm.lock".to_string(),
215            "uv.lock".to_string(),
216            // Go
217            "go.sum".to_string(),
218            // Nix
219            "flake.lock".to_string(),
220            // Dart/Flutter
221            "pubspec.lock".to_string(),
222            // iOS/macOS
223            "Podfile.lock".to_string(),
224            "Packages.resolved".to_string(),
225            // Elixir
226            "mix.lock".to_string(),
227            // .NET
228            "packages.lock.json".to_string(),
229            // Gradle
230            "gradle.lockfile".to_string(),
231         ],
232         low_priority_extensions: vec![
233            ".lock".to_string(),
234            ".sum".to_string(),
235            ".toml".to_string(),
236            ".yaml".to_string(),
237            ".yml".to_string(),
238            ".json".to_string(),
239            ".md".to_string(),
240            ".txt".to_string(),
241            ".log".to_string(),
242            ".tmp".to_string(),
243            ".bak".to_string(),
244         ],
245         max_detail_tokens:       200,
246         analysis_prompt_variant: default_analysis_prompt_variant(),
247         summary_prompt_variant:  default_summary_prompt_variant(),
248         wide_change_abstract:    default_wide_change_abstract(),
249         exclude_old_message:     default_exclude_old_message(),
250         gpg_sign:                default_gpg_sign(),
251         signoff:                 default_signoff(),
252         types:                   default_types(),
253         classifier_hint:         default_classifier_hint(),
254         categories:              default_categories(),
255         changelog_enabled:       default_changelog_enabled(),
256         map_reduce_enabled:      default_map_reduce_enabled(),
257         map_reduce_threshold:    default_map_reduce_threshold(),
258         analysis_prompt:         String::new(),
259         summary_prompt:          String::new(),
260      }
261   }
262}
263
264impl CommitConfig {
265   pub fn resolved_api_mode(&self, _model_name: &str) -> ResolvedApiMode {
266      match self.api_mode {
267         ApiMode::ChatCompletions => ResolvedApiMode::ChatCompletions,
268         ApiMode::AnthropicMessages => ResolvedApiMode::AnthropicMessages,
269         ApiMode::Auto => {
270            let base = self.api_base_url.to_lowercase();
271            if base.contains("anthropic") {
272               ResolvedApiMode::AnthropicMessages
273            } else {
274               ResolvedApiMode::ChatCompletions
275            }
276         },
277      }
278   }
279
280   /// Load config from default location (~/.config/llm-git/config.toml)
281   /// Falls back to Default if file doesn't exist or can't determine home
282   /// directory Environment variables override config file values:
283   /// - `LLM_GIT_API_URL` overrides `api_base_url`
284   /// - `LLM_GIT_API_KEY` overrides `api_key`
285   /// - `LLM_GIT_API_MODE` overrides `api_mode`
286   pub fn load() -> Result<Self> {
287      let config_path = if let Ok(custom_path) = std::env::var("LLM_GIT_CONFIG") {
288         PathBuf::from(custom_path)
289      } else {
290         Self::default_config_path().unwrap_or_else(|_| PathBuf::new())
291      };
292
293      let mut config = if config_path.exists() {
294         Self::from_file(&config_path)?
295      } else {
296         Self::default()
297      };
298
299      // Apply environment variable overrides
300      Self::apply_env_overrides(&mut config);
301
302      config.load_prompts()?;
303      Ok(config)
304   }
305
306   /// Apply environment variable overrides to config
307   fn apply_env_overrides(config: &mut Self) {
308      if let Ok(api_url) = std::env::var("LLM_GIT_API_URL") {
309         config.api_base_url = api_url;
310      }
311
312      if let Ok(api_key) = std::env::var("LLM_GIT_API_KEY") {
313         config.api_key = Some(api_key);
314      }
315
316      if let Ok(api_mode) = std::env::var("LLM_GIT_API_MODE") {
317         config.api_mode = parse_api_mode(&api_mode);
318      }
319   }
320
321   /// Load config from specific file
322   pub fn from_file(path: &Path) -> Result<Self> {
323      let contents = std::fs::read_to_string(path)
324         .map_err(|e| CommitGenError::Other(format!("Failed to read config: {e}")))?;
325      let mut config: Self = toml::from_str(&contents)
326         .map_err(|e| CommitGenError::Other(format!("Failed to parse config: {e}")))?;
327
328      // Apply environment variable overrides
329      Self::apply_env_overrides(&mut config);
330
331      config.load_prompts()?;
332      Ok(config)
333   }
334
335   /// Load prompts - templates are now loaded dynamically via Tera
336   /// This method ensures prompts are initialized
337   fn load_prompts(&mut self) -> Result<()> {
338      // Ensure prompts directory exists and embedded templates are unpacked
339      crate::templates::ensure_prompts_dir()?;
340
341      // Templates loaded dynamically at render time
342      self.analysis_prompt = String::new();
343      self.summary_prompt = String::new();
344      Ok(())
345   }
346
347   /// Get default config path (platform-safe)
348   /// Tries HOME (Unix/Linux/macOS) then USERPROFILE (Windows)
349   pub fn default_config_path() -> Result<PathBuf> {
350      // Try HOME first (Unix/Linux/macOS)
351      if let Ok(home) = std::env::var("HOME") {
352         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
353      }
354
355      // Try USERPROFILE on Windows
356      if let Ok(home) = std::env::var("USERPROFILE") {
357         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
358      }
359
360      Err(CommitGenError::Other("No home directory found (tried HOME and USERPROFILE)".to_string()))
361   }
362}
363
364/// Valid past-tense verbs for commit messages
365pub const PAST_TENSE_VERBS: &[&str] = &[
366   "added",
367   "fixed",
368   "updated",
369   "refactored",
370   "removed",
371   "replaced",
372   "improved",
373   "implemented",
374   "migrated",
375   "renamed",
376   "moved",
377   "merged",
378   "split",
379   "extracted",
380   "restructured",
381   "reorganized",
382   "consolidated",
383   "simplified",
384   "optimized",
385   "documented",
386   "tested",
387   "changed",
388   "introduced",
389   "deprecated",
390   "deleted",
391   "corrected",
392   "enhanced",
393   "reverted",
394];
395
396#[allow(dead_code, reason = "Defined in src/api/prompts.rs where it is used")]
397pub const CONVENTIONAL_ANALYSIS_PROMPT: &str = r#"
398Analyze git changes and classify as a conventional commit with detail points.
399
400OVERVIEW OF CHANGES:
401```
402{stat}
403```
404
405COMMIT TYPE (choose one):
406- feat: New public API, function, or user-facing capability (even with refactoring)
407- fix: Bug fix or correction
408- refactor: Code restructuring with SAME behavior (no new capability)
409- docs: Documentation-only changes
410- test: Test additions/modifications
411- chore: Tooling, dependencies, maintenance (no production code)
412- style: Formatting, whitespace (no logic change)
413- perf: Performance optimization
414- build: Build system, dependencies (Cargo.toml, package.json)
415- ci: CI/CD configuration (.github/workflows, etc)
416- revert: Reverts a previous commit
417
418TYPE CLASSIFICATION (CRITICAL):
419✓ feat: New public functions, API endpoints, features, capabilities users can invoke
420  - "Added TLS support with new builder API" → feat (new capability)
421  - "Implemented JSON-LD iterator traits" → feat (new API surface)
422✗ refactor: ONLY when behavior unchanged
423  - "Replaced polling with event model" → feat if new behavior; refactor if same output
424  - "Migrated from HTTP to gRPC" → feat (protocol change affects behavior)
425  - "Renamed internal functions" → refactor (no user-visible change)
426
427RULE: Be neutral between feat and refactor. Feat requires NEW capability/behavior. Refactor requires PROOF of unchanged behavior.
428
429CRITICAL REFACTOR vs FEAT DISTINCTION:
430When deciding between 'feat' and 'refactor', ask: "Can users observe different behavior?"
431
432- refactor: Same external behavior, different internal structure
433  ✗ "Migrated HTTP client to async" → feat (behavior change: now async)
434  ✓ "Reorganized HTTP client modules" → refactor (no behavior change)
435
436- feat: New behavior users can observe/invoke
437  ✓ "Added async HTTP client support" → feat (new capability)
438  ✓ "Implemented TLS transport layer" → feat (new feature)
439  ✓ "Migrated from polling to event-driven model" → feat (observable change)
440
441GUIDELINE: If the diff adds new public APIs, changes protocols, or enables new capabilities → feat
442If the diff just reorganizes code without changing what it does → refactor
443
444OTHER HEURISTICS:
445- Commit message starts with "Revert" → revert
446- Bug keywords, test fixes → fix
447- Only .md/doc comments → docs
448- Only test files → test
449- Lock files, configs, .gitignore → chore
450- Only formatting → style
451- Optimization (proven faster) → perf
452- Build scripts, dependency updates → build
453- CI config files → ci
454
455SCOPE EXTRACTION (optional):
456SCOPE SUGGESTIONS (derived from changed files with line-count weights): {scope_candidates}
457- You may use a suggested scope above, infer a more specific two-segment scope (e.g., core/utime), or omit when changes are broad
458- Scopes MUST reflect actual directories from the diff, not invented names
459- Use slash-separated paths (e.g., core/utime) when changes focus on a specific submodule
460- Omit scope when: multi-component changes, cross-cutting concerns, or unclear focus
461- Special cases (even if not suggested): "toolchain", "deps", "config"
462- Format: lowercase alphanumeric with `/`, `-`, or `_` only (max 2 segments)
463
464ISSUE REFERENCE EXTRACTION:
465- Extract issue numbers from context (e.g. #123, GH-456)
466- Return as array of strings or empty array if none
467
468DETAIL REQUIREMENTS (0-6 items, prefer 3-4):
4691. Past-tense verb ONLY: added, fixed, updated, refactored, removed, replaced,
470   improved, implemented, migrated, renamed, moved, merged, split, extracted,
471   restructured, reorganized, consolidated, simplified, optimized
4722. End with period
4733. Balance WHAT changed with WHY/HOW (not just "what")
4744. Abstraction levels (prefer higher):
475   - Level 3 (BEST): Architectural impact, user-facing change, performance gain
476     "Replaced polling with event-driven model for 10x throughput."
477   - Level 2 (GOOD): Component changes, API surface
478     "Consolidated three HTTP builders into unified API."
479   - Level 1 (AVOID): Low-level details, renames
480     "Renamed workspacePath to locate." ❌
4815. Group ≥3 similar changes: "Updated 5 test files for new API." not 5 bullets
4826. Prioritize: user-visible > performance/security > architecture > internal refactoring
4837. Empty array if no supporting details needed
484
485EXCLUDE FROM DETAILS:
486- Import/use statements
487- Whitespace/formatting/indentation
488- Trivial renames (unless part of larger API change)
489- Debug prints/temporary logging
490- Comment changes (unless substantial docs)
491- File moves without modification
492- Single-line tweaks/typo fixes
493- Internal implementation details invisible to users
494
495WRITING RULES:
496- Plain sentences only (bullets/numbering added during formatting)
497- Short, direct (120 chars max per detail)
498- Precise nouns (module/file/API names)
499- Group related changes
500- Include why or how validated when meaningful:
501  Added retry logic to handle transient network failures.
502  Migrated to async I/O to unblock event loop.
503- Avoid meta phrases (This commit, Updated code, etc)
504
505DETAILED DIFF:
506```diff
507{diff}
508```"#;
509
510#[allow(dead_code, reason = "Defined in src/api/prompts.rs where it is used")]
511pub const SUMMARY_PROMPT_TEMPLATE: &str = r#"
512Draft a conventional commit summary (WITHOUT type/scope prefix).
513
514COMMIT TYPE: {type}
515SCOPE: {scope}
516
517DETAIL POINTS:
518{details}
519
520DIFF STAT:
521```
522{stat}
523```
524
525SUMMARY REQUIREMENTS:
5261. Output ONLY the description part (after "type(scope): ")
5272. Maximum {chars} characters
5283. First word MUST be one of these past-tense verbs:
529   added, fixed, updated, removed, replaced, improved, implemented,
530   migrated, renamed, moved, merged, split, extracted, simplified,
531   optimized, documented, tested, changed, introduced, deprecated,
532   deleted, corrected, enhanced, restructured, reorganized, consolidated,
533   reverted
5344. Focus on primary change (single concept if scope is specific)
5355. NO trailing period (conventional commits style)
5366. NO leading adjectives before verb
537
538FORBIDDEN PATTERNS:
539- DO NOT repeat the commit type "{type}" in the summary
540- If type is "refactor", use: restructured, reorganized, migrated, simplified,
541  consolidated, extracted (NOT "refactored")
542- NO filler words: "comprehensive", "improved", "enhanced", "various", "several"
543- NO "and" conjunctions cramming multiple unrelated concepts
544
545GOOD EXAMPLES (type in parens):
546- (feat) "added TLS support with mutual authentication"
547- (refactor) "migrated HTTP transport to unified builder API"
548- (fix) "corrected race condition in connection pool"
549- (perf) "optimized batch processing to reduce allocations"
550
551BAD EXAMPLES:
552- (refactor) "refactor TLS configuration" ❌ (repeats type)
553- (feat) "add comprehensive support for..." ❌ (filler word)
554- (chore) "update deps and improve build" ❌ (multiple concepts)
555
556FULL FORMAT WILL BE: {type}({scope}): <your summary>
557
558BEFORE RESPONDING:
559✓ Summary ≤{chars} chars
560✓ Starts lowercase
561✓ First word is past-tense verb from list above
562✓ Does NOT repeat type "{type}"
563✓ NO trailing period
564✓ NO filler words
565✓ Single focused concept
566✓ Aligns with detail points and diff stat
567✓ Specific (names subsystem/artifact)
568"#;