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 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 pub max_detail_tokens: usize,
65
66 #[serde(default = "default_analysis_prompt_variant")]
68 pub analysis_prompt_variant: String,
69
70 #[serde(default = "default_summary_prompt_variant")]
72 pub summary_prompt_variant: String,
73
74 #[serde(default = "default_wide_change_abstract")]
76 pub wide_change_abstract: bool,
77
78 #[serde(default = "default_exclude_old_message")]
81 pub exclude_old_message: bool,
82
83 #[serde(default = "default_gpg_sign")]
85 pub gpg_sign: bool,
86
87 #[serde(default = "default_signoff")]
90 pub signoff: bool,
91
92 #[serde(default = "default_types")]
94 pub types: IndexMap<String, TypeConfig>,
95
96 #[serde(default = "default_classifier_hint")]
98 pub classifier_hint: String,
99
100 #[serde(default = "default_categories")]
102 pub categories: Vec<CategoryConfig>,
103
104 #[serde(default = "default_changelog_enabled")]
106 pub changelog_enabled: bool,
107
108 #[serde(default = "default_map_reduce_enabled")]
110 pub map_reduce_enabled: bool,
111
112 #[serde(default = "default_map_reduce_threshold")]
114 pub map_reduce_threshold: usize,
115
116 #[serde(skip)]
118 pub analysis_prompt: String,
119
120 #[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 }
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, max_diff_tokens: 25000, wide_change_threshold: 0.50,
193 temperature: 0.2, model: "claude-opus-4.5".to_string(),
195 excluded_files: vec![
196 "Cargo.lock".to_string(),
198 "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 "composer.lock".to_string(),
209 "Gemfile.lock".to_string(),
211 "poetry.lock".to_string(),
213 "Pipfile.lock".to_string(),
214 "pdm.lock".to_string(),
215 "uv.lock".to_string(),
216 "go.sum".to_string(),
218 "flake.lock".to_string(),
220 "pubspec.lock".to_string(),
222 "Podfile.lock".to_string(),
224 "Packages.resolved".to_string(),
225 "mix.lock".to_string(),
227 "packages.lock.json".to_string(),
229 "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 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 Self::apply_env_overrides(&mut config);
301
302 config.load_prompts()?;
303 Ok(config)
304 }
305
306 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 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 Self::apply_env_overrides(&mut config);
330
331 config.load_prompts()?;
332 Ok(config)
333 }
334
335 fn load_prompts(&mut self) -> Result<()> {
338 crate::templates::ensure_prompts_dir()?;
340
341 self.analysis_prompt = String::new();
343 self.summary_prompt = String::new();
344 Ok(())
345 }
346
347 pub fn default_config_path() -> Result<PathBuf> {
350 if let Ok(home) = std::env::var("HOME") {
352 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
353 }
354
355 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
364pub 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"#;