use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::Deserialize;
use crate::{
error::{CommitGenError, Result},
types::{
CategoryConfig, TypeConfig, default_categories, default_classifier_hint, default_types,
},
};
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ApiMode {
Auto,
ChatCompletions,
AnthropicMessages,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedApiMode {
ChatCompletions,
AnthropicMessages,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CommitConfig {
pub api_base_url: String,
#[serde(default = "default_api_mode")]
pub api_mode: ApiMode,
pub api_key: Option<String>,
pub request_timeout_secs: u64,
pub connect_timeout_secs: u64,
#[serde(default = "default_disable_git_background_features")]
pub disable_git_background_features: bool,
pub compose_max_rounds: usize,
pub summary_guideline: usize,
pub summary_soft_limit: usize,
pub summary_hard_limit: usize,
pub max_retries: u32,
pub initial_backoff_ms: u64,
#[serde(default = "default_auto_fast_threshold_lines")]
pub auto_fast_threshold_lines: usize,
pub max_diff_length: usize,
pub max_diff_tokens: usize,
pub wide_change_threshold: f32,
#[serde(default = "default_analysis_model")]
pub analysis_model: String,
#[serde(default = "default_summary_model")]
pub summary_model: String,
#[serde(default, rename = "model")]
pub legacy_model: Option<String>,
pub excluded_files: Vec<String>,
pub low_priority_extensions: Vec<String>,
pub max_detail_tokens: usize,
#[serde(default = "default_analysis_prompt_variant")]
pub analysis_prompt_variant: String,
#[serde(default = "default_summary_prompt_variant")]
pub summary_prompt_variant: String,
#[serde(default = "default_wide_change_abstract")]
pub wide_change_abstract: bool,
#[serde(default = "default_markdown_output")]
pub markdown_output: bool,
#[serde(default = "default_exclude_old_message")]
pub exclude_old_message: bool,
#[serde(default = "default_gpg_sign")]
pub gpg_sign: bool,
#[serde(default = "default_signoff")]
pub signoff: bool,
#[serde(default = "default_types")]
pub types: IndexMap<String, TypeConfig>,
#[serde(default = "default_classifier_hint")]
pub classifier_hint: String,
#[serde(default = "default_categories")]
pub categories: Vec<CategoryConfig>,
#[serde(default = "default_changelog_enabled")]
pub changelog_enabled: bool,
#[serde(default = "default_map_reduce_enabled")]
pub map_reduce_enabled: bool,
#[serde(default = "default_map_reduce_threshold")]
pub map_reduce_threshold: usize,
#[serde(default = "default_map_batch_token_budget")]
pub map_batch_token_budget: usize,
#[serde(default = "default_cache_enabled")]
pub cache_enabled: bool,
#[serde(default = "default_cache_ttl_days")]
pub cache_ttl_days: u32,
#[serde(default)]
pub cache_dir: Option<String>,
#[serde(skip)]
pub analysis_prompt: String,
#[serde(skip)]
pub summary_prompt: String,
}
fn default_analysis_prompt_variant() -> String {
"default".to_string()
}
const fn default_api_mode() -> ApiMode {
ApiMode::Auto
}
const fn default_disable_git_background_features() -> bool {
true
}
fn default_summary_prompt_variant() -> String {
"default".to_string()
}
fn default_analysis_model() -> String {
"claude-opus-4.5".to_string()
}
fn default_summary_model() -> String {
"claude-haiku-4-5".to_string()
}
const fn default_wide_change_abstract() -> bool {
true
}
const fn default_markdown_output() -> bool {
true
}
const fn default_exclude_old_message() -> bool {
true
}
const fn default_gpg_sign() -> bool {
false
}
const fn default_signoff() -> bool {
false
}
const fn default_cache_enabled() -> bool {
true
}
const fn default_cache_ttl_days() -> u32 {
14
}
const fn default_changelog_enabled() -> bool {
true
}
const fn default_map_reduce_enabled() -> bool {
true
}
const fn default_map_reduce_threshold() -> usize {
5000 }
const fn default_map_batch_token_budget() -> usize {
16_000
}
const fn default_auto_fast_threshold_lines() -> usize {
200
}
fn parse_api_mode(value: &str) -> ApiMode {
match value.trim().to_lowercase().as_str() {
"auto" => ApiMode::Auto,
"chat" | "chat-completions" | "chat_completions" => ApiMode::ChatCompletions,
"anthropic" | "messages" | "anthropic-messages" | "anthropic_messages" => {
ApiMode::AnthropicMessages
},
_ => ApiMode::Auto,
}
}
impl Default for CommitConfig {
fn default() -> Self {
Self {
api_base_url: "http://localhost:4000".to_string(),
api_mode: default_api_mode(),
api_key: None,
request_timeout_secs: 120,
connect_timeout_secs: 30,
disable_git_background_features: default_disable_git_background_features(),
compose_max_rounds: 5,
summary_guideline: 72,
summary_soft_limit: 96,
summary_hard_limit: 128,
max_retries: 3,
initial_backoff_ms: 1000,
auto_fast_threshold_lines: default_auto_fast_threshold_lines(),
max_diff_length: 100000, max_diff_tokens: 25000, wide_change_threshold: 0.50,
analysis_model: default_analysis_model(),
summary_model: default_summary_model(),
legacy_model: None,
excluded_files: vec![
"Cargo.lock".to_string(),
"package-lock.json".to_string(),
"npm-shrinkwrap.json".to_string(),
"yarn.lock".to_string(),
"pnpm-lock.yaml".to_string(),
"shrinkwrap.yaml".to_string(),
"bun.lock".to_string(),
"bun.lockb".to_string(),
"deno.lock".to_string(),
"composer.lock".to_string(),
"Gemfile.lock".to_string(),
"poetry.lock".to_string(),
"Pipfile.lock".to_string(),
"pdm.lock".to_string(),
"uv.lock".to_string(),
"go.sum".to_string(),
"flake.lock".to_string(),
"pubspec.lock".to_string(),
"Podfile.lock".to_string(),
"Packages.resolved".to_string(),
"mix.lock".to_string(),
"packages.lock.json".to_string(),
"gradle.lockfile".to_string(),
],
low_priority_extensions: vec![
".lock".to_string(),
".sum".to_string(),
".toml".to_string(),
".yaml".to_string(),
".yml".to_string(),
".json".to_string(),
".md".to_string(),
".txt".to_string(),
".log".to_string(),
".tmp".to_string(),
".bak".to_string(),
],
max_detail_tokens: 200,
analysis_prompt_variant: default_analysis_prompt_variant(),
summary_prompt_variant: default_summary_prompt_variant(),
wide_change_abstract: default_wide_change_abstract(),
markdown_output: default_markdown_output(),
exclude_old_message: default_exclude_old_message(),
gpg_sign: default_gpg_sign(),
signoff: default_signoff(),
types: default_types(),
classifier_hint: default_classifier_hint(),
categories: default_categories(),
changelog_enabled: default_changelog_enabled(),
map_reduce_enabled: default_map_reduce_enabled(),
map_reduce_threshold: default_map_reduce_threshold(),
map_batch_token_budget: default_map_batch_token_budget(),
cache_enabled: default_cache_enabled(),
cache_ttl_days: default_cache_ttl_days(),
cache_dir: None,
analysis_prompt: String::new(),
summary_prompt: String::new(),
}
}
}
fn expand_tilde(raw: &str) -> std::path::PathBuf {
if let Some(rest) = raw.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return Path::new(&home).join(rest);
}
PathBuf::from(raw)
}
fn resolve_command_value(cmd: &str) -> Result<String> {
let trimmed = cmd.trim();
if let Some(rest) = trimmed.strip_prefix("cat ") {
let path = expand_tilde(rest.trim().trim_matches(|c| c == '\'' || c == '"'));
let contents = std::fs::read_to_string(&path).map_err(|e| {
CommitGenError::Other(format!("api_key `!cat` failed to read {}: {e}", path.display()))
})?;
return Ok(contents.trim().to_string());
}
let output = std::process::Command::new("sh")
.arg("-c")
.arg(trimmed)
.output()
.map_err(|e| CommitGenError::Other(format!("api_key `!{trimmed}` failed to spawn: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(CommitGenError::Other(format!(
"api_key `!{trimmed}` exited with status {:?}: {stderr}",
output.status.code()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
impl CommitConfig {
pub fn resolved_api_mode(&self, _model_name: &str) -> ResolvedApiMode {
match self.api_mode {
ApiMode::ChatCompletions => ResolvedApiMode::ChatCompletions,
ApiMode::AnthropicMessages => ResolvedApiMode::AnthropicMessages,
ApiMode::Auto => {
let base = self.api_base_url.to_lowercase();
if base.contains("anthropic") {
ResolvedApiMode::AnthropicMessages
} else {
ResolvedApiMode::ChatCompletions
}
},
}
}
pub fn load() -> Result<Self> {
let config_path = if let Ok(custom_path) = std::env::var("LLM_GIT_CONFIG") {
PathBuf::from(custom_path)
} else {
Self::default_config_path().unwrap_or_else(|_| PathBuf::new())
};
let mut config = if config_path.exists() {
Self::from_file(&config_path)?
} else {
Self::default()
};
Self::apply_env_overrides(&mut config);
config.normalize_models();
if let Some(raw) = config.api_key.as_deref()
&& let Some(rest) = raw.strip_prefix('!')
{
let resolved = resolve_command_value(rest.trim())?;
config.api_key = Some(resolved);
}
config.load_prompts()?;
Ok(config)
}
fn apply_env_overrides(config: &mut Self) {
if let Ok(api_url) = std::env::var("LLM_GIT_API_URL") {
config.api_base_url = api_url;
}
if let Ok(api_key) = std::env::var("LLM_GIT_API_KEY") {
config.api_key = Some(api_key);
}
if let Ok(api_mode) = std::env::var("LLM_GIT_API_MODE") {
config.api_mode = parse_api_mode(&api_mode);
}
if let Ok(value) = std::env::var("LLM_GIT_DISABLE_GIT_BACKGROUND_FEATURES") {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => config.disable_git_background_features = true,
"0" | "false" | "no" | "off" => config.disable_git_background_features = false,
_ => {},
}
}
if let Ok(value) = std::env::var("LLM_GIT_CACHE_DISABLED") {
let trimmed = value.trim().to_ascii_lowercase();
if matches!(trimmed.as_str(), "1" | "true" | "yes" | "on") {
config.cache_enabled = false;
}
}
if let Ok(value) = std::env::var("LLM_GIT_CACHE_TTL_DAYS")
&& let Ok(days) = value.trim().parse::<u32>()
{
config.cache_ttl_days = days;
}
if let Ok(value) = std::env::var("LLM_GIT_CACHE_DIR") {
let trimmed = value.trim();
config.cache_dir = (!trimmed.is_empty()).then(|| trimmed.to_string());
}
}
pub fn from_file(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.map_err(|e| CommitGenError::Other(format!("Failed to read config: {e}")))?;
let mut config: Self = toml::from_str(&contents)
.map_err(|e| CommitGenError::Other(format!("Failed to parse config: {e}")))?;
Self::apply_env_overrides(&mut config);
config.normalize_models();
config.load_prompts()?;
Ok(config)
}
fn normalize_models(&mut self) {
if let Some(model) = self.legacy_model.as_ref() {
self.analysis_model = model.clone();
if self.summary_model == default_summary_model() {
self.summary_model = model.clone();
}
}
}
fn load_prompts(&mut self) -> Result<()> {
crate::templates::ensure_prompts_dir()?;
self.analysis_prompt = String::new();
self.summary_prompt = String::new();
Ok(())
}
pub fn default_config_path() -> Result<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
}
if let Ok(home) = std::env::var("USERPROFILE") {
return Ok(PathBuf::from(home).join(".config/llm-git/config.toml"));
}
Err(CommitGenError::Other("No home directory found (tried HOME and USERPROFILE)".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_models_legacy_model_sets_summary_when_default() {
let mut config = CommitConfig {
legacy_model: Some("gpt-5.3-codex-spark".to_string()),
..CommitConfig::default()
};
config.normalize_models();
assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
assert_eq!(config.summary_model, "gpt-5.3-codex-spark");
assert_eq!(config.legacy_model.as_deref(), Some("gpt-5.3-codex-spark"));
}
#[test]
fn test_normalize_models_preserves_explicit_summary_model() {
let mut config = CommitConfig {
summary_model: "gpt-5-mini".to_string(),
legacy_model: Some("gpt-5.3-codex-spark".to_string()),
..CommitConfig::default()
};
config.normalize_models();
assert_eq!(config.analysis_model, "gpt-5.3-codex-spark");
assert_eq!(config.summary_model, "gpt-5-mini");
}
}