1use std::path::{Path, PathBuf};
2
3use serde::Deserialize;
4
5use crate::error::{CommitGenError, Result};
6
7#[derive(Debug, Clone, Deserialize)]
8#[serde(default)]
9pub struct CommitConfig {
10 pub api_base_url: String,
11
12 pub api_key: Option<String>,
15
16 pub request_timeout_secs: u64,
18
19 pub connect_timeout_secs: u64,
21
22 pub compose_max_rounds: usize,
24
25 pub summary_guideline: usize,
26 pub summary_soft_limit: usize,
27 pub summary_hard_limit: usize,
28 pub max_retries: u32,
29 pub initial_backoff_ms: u64,
30 pub max_diff_length: usize,
31 pub wide_change_threshold: f32,
32 pub temperature: f32,
33 pub analysis_model: String,
34 pub summary_model: String,
35 pub excluded_files: Vec<String>,
36 pub low_priority_extensions: Vec<String>,
37
38 #[serde(default = "default_analysis_prompt_variant")]
40 pub analysis_prompt_variant: String,
41
42 #[serde(default = "default_summary_prompt_variant")]
44 pub summary_prompt_variant: String,
45
46 #[serde(default = "default_exclude_old_message")]
49 pub exclude_old_message: bool,
50
51 #[serde(skip)]
53 pub analysis_prompt: String,
54
55 #[serde(skip)]
57 pub summary_prompt: String,
58}
59
60fn default_analysis_prompt_variant() -> String {
61 "default".to_string()
62}
63
64fn default_summary_prompt_variant() -> String {
65 "default".to_string()
66}
67
68const fn default_exclude_old_message() -> bool {
69 true
70}
71
72impl Default for CommitConfig {
73 fn default() -> Self {
74 Self {
75 api_base_url: "http://localhost:4000".to_string(),
76 api_key: None,
77 request_timeout_secs: 120,
78 connect_timeout_secs: 30,
79 compose_max_rounds: 5,
80 summary_guideline: 72,
81 summary_soft_limit: 96,
82 summary_hard_limit: 128,
83 max_retries: 3,
84 initial_backoff_ms: 1000,
85 max_diff_length: 100000, wide_change_threshold: 0.50,
87 temperature: 0.2, analysis_model: "claude-sonnet-4.5".to_string(),
89 summary_model: "claude-haiku-4-5".to_string(),
90 excluded_files: vec![
91 "Cargo.lock".to_string(),
92 "package-lock.json".to_string(),
93 "yarn.lock".to_string(),
94 "pnpm-lock.yaml".to_string(),
95 "composer.lock".to_string(),
96 "Gemfile.lock".to_string(),
97 "poetry.lock".to_string(),
98 "flake.lock".to_string(),
99 ".gitignore".to_string(),
100 ],
101 low_priority_extensions: vec![
102 ".lock".to_string(),
103 ".sum".to_string(),
104 ".toml".to_string(),
105 ".yaml".to_string(),
106 ".yml".to_string(),
107 ".json".to_string(),
108 ".md".to_string(),
109 ".txt".to_string(),
110 ".log".to_string(),
111 ".tmp".to_string(),
112 ".bak".to_string(),
113 ],
114 analysis_prompt_variant: default_analysis_prompt_variant(),
115 summary_prompt_variant: default_summary_prompt_variant(),
116 exclude_old_message: default_exclude_old_message(),
117 analysis_prompt: String::new(),
118 summary_prompt: String::new(),
119 }
120 }
121}
122
123impl CommitConfig {
124 pub fn load() -> Result<Self> {
130 let config_path = if let Ok(custom_path) = std::env::var("LLM_GIT_CONFIG") {
131 PathBuf::from(custom_path)
132 } else {
133 Self::default_config_path().unwrap_or_else(|_| PathBuf::new())
134 };
135
136 let mut config = if config_path.exists() {
137 Self::from_file(&config_path)?
138 } else {
139 Self::default()
140 };
141
142 Self::apply_env_overrides(&mut config);
144
145 config.load_prompts()?;
146 Ok(config)
147 }
148
149 fn apply_env_overrides(config: &mut Self) {
151 if let Ok(api_url) = std::env::var("LLM_GIT_API_URL") {
152 config.api_base_url = api_url;
153 }
154
155 if let Ok(api_key) = std::env::var("LLM_GIT_API_KEY") {
156 config.api_key = Some(api_key);
157 }
158 }
159
160 pub fn from_file(path: &Path) -> Result<Self> {
162 let contents = std::fs::read_to_string(path)
163 .map_err(|e| CommitGenError::Other(format!("Failed to read config: {e}")))?;
164 let mut config: Self = toml::from_str(&contents)
165 .map_err(|e| CommitGenError::Other(format!("Failed to parse config: {e}")))?;
166
167 Self::apply_env_overrides(&mut config);
169
170 config.load_prompts()?;
171 Ok(config)
172 }
173
174 fn load_prompts(&mut self) -> Result<()> {
177 crate::templates::ensure_prompts_dir()?;
179
180 self.analysis_prompt = String::new();
182 self.summary_prompt = String::new();
183 Ok(())
184 }
185
186 pub fn default_config_path() -> Result<PathBuf> {
189 if let Ok(home) = std::env::var("HOME") {
191 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
192 }
193
194 if let Ok(home) = std::env::var("USERPROFILE") {
196 return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
197 }
198
199 Err(CommitGenError::Other("No home directory found (tried HOME and USERPROFILE)".to_string()))
200 }
201}
202
203pub const PAST_TENSE_VERBS: &[&str] = &[
205 "added",
206 "fixed",
207 "updated",
208 "refactored",
209 "removed",
210 "replaced",
211 "improved",
212 "implemented",
213 "migrated",
214 "renamed",
215 "moved",
216 "merged",
217 "split",
218 "extracted",
219 "restructured",
220 "reorganized",
221 "consolidated",
222 "simplified",
223 "optimized",
224 "documented",
225 "tested",
226 "changed",
227 "introduced",
228 "deprecated",
229 "deleted",
230 "corrected",
231 "enhanced",
232 "reverted",
233];
234
235#[allow(dead_code, reason = "Defined in src/api/prompts.rs where it is used")]
236pub const CONVENTIONAL_ANALYSIS_PROMPT: &str = r#"
237Analyze git changes and classify as a conventional commit with detail points.
238
239OVERVIEW OF CHANGES:
240```
241{stat}
242```
243
244COMMIT TYPE (choose one):
245- feat: New public API, function, or user-facing capability (even with refactoring)
246- fix: Bug fix or correction
247- refactor: Code restructuring with SAME behavior (no new capability)
248- docs: Documentation-only changes
249- test: Test additions/modifications
250- chore: Tooling, dependencies, maintenance (no production code)
251- style: Formatting, whitespace (no logic change)
252- perf: Performance optimization
253- build: Build system, dependencies (Cargo.toml, package.json)
254- ci: CI/CD configuration (.github/workflows, etc)
255- revert: Reverts a previous commit
256
257TYPE CLASSIFICATION (CRITICAL):
258✓ feat: New public functions, API endpoints, features, capabilities users can invoke
259 - "Added TLS support with new builder API" → feat (new capability)
260 - "Implemented JSON-LD iterator traits" → feat (new API surface)
261✗ refactor: ONLY when behavior unchanged
262 - "Replaced polling with event model" → feat if new behavior; refactor if same output
263 - "Migrated from HTTP to gRPC" → feat (protocol change affects behavior)
264 - "Renamed internal functions" → refactor (no user-visible change)
265
266RULE: Be neutral between feat and refactor. Feat requires NEW capability/behavior. Refactor requires PROOF of unchanged behavior.
267
268CRITICAL REFACTOR vs FEAT DISTINCTION:
269When deciding between 'feat' and 'refactor', ask: "Can users observe different behavior?"
270
271- refactor: Same external behavior, different internal structure
272 ✗ "Migrated HTTP client to async" → feat (behavior change: now async)
273 ✓ "Reorganized HTTP client modules" → refactor (no behavior change)
274
275- feat: New behavior users can observe/invoke
276 ✓ "Added async HTTP client support" → feat (new capability)
277 ✓ "Implemented TLS transport layer" → feat (new feature)
278 ✓ "Migrated from polling to event-driven model" → feat (observable change)
279
280GUIDELINE: If the diff adds new public APIs, changes protocols, or enables new capabilities → feat
281If the diff just reorganizes code without changing what it does → refactor
282
283OTHER HEURISTICS:
284- Commit message starts with "Revert" → revert
285- Bug keywords, test fixes → fix
286- Only .md/doc comments → docs
287- Only test files → test
288- Lock files, configs, .gitignore → chore
289- Only formatting → style
290- Optimization (proven faster) → perf
291- Build scripts, dependency updates → build
292- CI config files → ci
293
294SCOPE EXTRACTION (optional):
295SCOPE SUGGESTIONS (derived from changed files with line-count weights): {scope_candidates}
296- You may use a suggested scope above, infer a more specific two-segment scope (e.g., core/utime), or omit when changes are broad
297- Scopes MUST reflect actual directories from the diff, not invented names
298- Use slash-separated paths (e.g., core/utime) when changes focus on a specific submodule
299- Omit scope when: multi-component changes, cross-cutting concerns, or unclear focus
300- Special cases (even if not suggested): "toolchain", "deps", "config"
301- Format: lowercase alphanumeric with `/`, `-`, or `_` only (max 2 segments)
302
303ISSUE REFERENCE EXTRACTION:
304- Extract issue numbers from context (e.g. #123, GH-456)
305- Return as array of strings or empty array if none
306
307DETAIL REQUIREMENTS (0-6 items, prefer 3-4):
3081. Past-tense verb ONLY: added, fixed, updated, refactored, removed, replaced,
309 improved, implemented, migrated, renamed, moved, merged, split, extracted,
310 restructured, reorganized, consolidated, simplified, optimized
3112. End with period
3123. Balance WHAT changed with WHY/HOW (not just "what")
3134. Abstraction levels (prefer higher):
314 - Level 3 (BEST): Architectural impact, user-facing change, performance gain
315 "Replaced polling with event-driven model for 10x throughput."
316 - Level 2 (GOOD): Component changes, API surface
317 "Consolidated three HTTP builders into unified API."
318 - Level 1 (AVOID): Low-level details, renames
319 "Renamed workspacePath to locate." ❌
3205. Group ≥3 similar changes: "Updated 5 test files for new API." not 5 bullets
3216. Prioritize: user-visible > performance/security > architecture > internal refactoring
3227. Empty array if no supporting details needed
323
324EXCLUDE FROM DETAILS:
325- Import/use statements
326- Whitespace/formatting/indentation
327- Trivial renames (unless part of larger API change)
328- Debug prints/temporary logging
329- Comment changes (unless substantial docs)
330- File moves without modification
331- Single-line tweaks/typo fixes
332- Internal implementation details invisible to users
333
334WRITING RULES:
335- Plain sentences only (bullets/numbering added during formatting)
336- Short, direct (120 chars max per detail)
337- Precise nouns (module/file/API names)
338- Group related changes
339- Include why or how validated when meaningful:
340 Added retry logic to handle transient network failures.
341 Migrated to async I/O to unblock event loop.
342- Avoid meta phrases (This commit, Updated code, etc)
343
344DETAILED DIFF:
345```diff
346{diff}
347```"#;
348
349#[allow(dead_code, reason = "Defined in src/api/prompts.rs where it is used")]
350pub const SUMMARY_PROMPT_TEMPLATE: &str = r#"
351Draft a conventional commit summary (WITHOUT type/scope prefix).
352
353COMMIT TYPE: {type}
354SCOPE: {scope}
355
356DETAIL POINTS:
357{details}
358
359DIFF STAT:
360```
361{stat}
362```
363
364SUMMARY REQUIREMENTS:
3651. Output ONLY the description part (after "type(scope): ")
3662. Maximum {chars} characters
3673. First word MUST be one of these past-tense verbs:
368 added, fixed, updated, removed, replaced, improved, implemented,
369 migrated, renamed, moved, merged, split, extracted, simplified,
370 optimized, documented, tested, changed, introduced, deprecated,
371 deleted, corrected, enhanced, restructured, reorganized, consolidated,
372 reverted
3734. Focus on primary change (single concept if scope is specific)
3745. NO trailing period (conventional commits style)
3756. NO leading adjectives before verb
376
377FORBIDDEN PATTERNS:
378- DO NOT repeat the commit type "{type}" in the summary
379- If type is "refactor", use: restructured, reorganized, migrated, simplified,
380 consolidated, extracted (NOT "refactored")
381- NO filler words: "comprehensive", "improved", "enhanced", "various", "several"
382- NO "and" conjunctions cramming multiple unrelated concepts
383
384GOOD EXAMPLES (type in parens):
385- (feat) "added TLS support with mutual authentication"
386- (refactor) "migrated HTTP transport to unified builder API"
387- (fix) "corrected race condition in connection pool"
388- (perf) "optimized batch processing to reduce allocations"
389
390BAD EXAMPLES:
391- (refactor) "refactor TLS configuration" ❌ (repeats type)
392- (feat) "add comprehensive support for..." ❌ (filler word)
393- (chore) "update deps and improve build" ❌ (multiple concepts)
394
395FULL FORMAT WILL BE: {type}({scope}): <your summary>
396
397BEFORE RESPONDING:
398✓ Summary ≤{chars} chars
399✓ Starts lowercase
400✓ First word is past-tense verb from list above
401✓ Does NOT repeat type "{type}"
402✓ NO trailing period
403✓ NO filler words
404✓ Single focused concept
405✓ Aligns with detail points and diff stat
406✓ Specific (names subsystem/artifact)
407"#;