llm_git/
config.rs

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   /// Optional API key for authentication (overridden by `LLM_GIT_API_KEY` env
13   /// var)
14   pub api_key: Option<String>,
15
16   /// HTTP request timeout in seconds
17   pub request_timeout_secs: u64,
18
19   /// HTTP connection timeout in seconds
20   pub connect_timeout_secs: u64,
21
22   /// Maximum rounds for compose mode multi-commit generation
23   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   /// Prompt variant for analysis phase (e.g., "default")
39   #[serde(default = "default_analysis_prompt_variant")]
40   pub analysis_prompt_variant: String,
41
42   /// Prompt variant for summary phase (e.g., "default")
43   #[serde(default = "default_summary_prompt_variant")]
44   pub summary_prompt_variant: String,
45
46   /// Exclude old commit message from context in commit mode (rewrite mode uses
47   /// this)
48   #[serde(default = "default_exclude_old_message")]
49   pub exclude_old_message: bool,
50
51   /// Loaded analysis prompt (not in config file)
52   #[serde(skip)]
53   pub analysis_prompt: String,
54
55   /// Loaded summary prompt (not in config file)
56   #[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, // Increased to handle larger refactors better
86         wide_change_threshold:   0.50,
87         temperature:             0.2, // Low temperature for consistent structured output
88         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   /// Load config from default location (~/.config/llm-git/config.toml)
125   /// Falls back to Default if file doesn't exist or can't determine home
126   /// directory Environment variables override config file values:
127   /// - `LLM_GIT_API_URL` overrides `api_base_url`
128   /// - `LLM_GIT_API_KEY` overrides `api_key`
129   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      // Apply environment variable overrides
143      Self::apply_env_overrides(&mut config);
144
145      config.load_prompts()?;
146      Ok(config)
147   }
148
149   /// Apply environment variable overrides to config
150   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   /// Load config from specific file
161   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      // Apply environment variable overrides
168      Self::apply_env_overrides(&mut config);
169
170      config.load_prompts()?;
171      Ok(config)
172   }
173
174   /// Load prompts - templates are now loaded dynamically via Tera
175   /// This method ensures prompts are initialized
176   fn load_prompts(&mut self) -> Result<()> {
177      // Ensure prompts directory exists and embedded templates are unpacked
178      crate::templates::ensure_prompts_dir()?;
179
180      // Templates loaded dynamically at render time
181      self.analysis_prompt = String::new();
182      self.summary_prompt = String::new();
183      Ok(())
184   }
185
186   /// Get default config path (platform-safe)
187   /// Tries HOME (Unix/Linux/macOS) then USERPROFILE (Windows)
188   pub fn default_config_path() -> Result<PathBuf> {
189      // Try HOME first (Unix/Linux/macOS)
190      if let Ok(home) = std::env::var("HOME") {
191         return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
192      }
193
194      // Try USERPROFILE on Windows
195      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
203/// Valid past-tense verbs for commit messages
204pub 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"#;