Skip to main content

si_git_commit/
config.rs

1use crate::error::{Result, SiError};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::{env, fs};
5
6/// AI provider used to generate commit messages.
7///
8/// Requests are built with each provider's *current* parameter names.
9/// In particular, OpenAI requests use `max_completion_tokens` (never the
10/// legacy `max_tokens`) and omit `temperature`, so current reasoning and
11/// GPT-5-class models accept them without `unsupported_parameter` errors.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum Provider {
15    #[default]
16    Openai,
17    Anthropic,
18    /// Any OpenAI-compatible Chat Completions endpoint (custom `base_url`),
19    /// e.g. OpenRouter, a local server, or an internal gateway.
20    #[serde(rename = "openai-compatible")]
21    OpenAiCompatible,
22}
23
24impl Provider {
25    pub fn as_str(&self) -> &'static str {
26        match self {
27            Provider::Openai => "openai",
28            Provider::Anthropic => "anthropic",
29            Provider::OpenAiCompatible => "openai-compatible",
30        }
31    }
32
33    pub fn display_name(&self) -> &'static str {
34        match self {
35            Provider::Openai => "OpenAI",
36            Provider::Anthropic => "Anthropic",
37            Provider::OpenAiCompatible => "OpenAI-compatible",
38        }
39    }
40
41    pub fn env_key_name(&self) -> &'static str {
42        match self {
43            Provider::Openai | Provider::OpenAiCompatible => "OPENAI_API_KEY",
44            Provider::Anthropic => "ANTHROPIC_API_KEY",
45        }
46    }
47
48    pub fn default_base_url(&self) -> &'static str {
49        match self {
50            Provider::Openai | Provider::OpenAiCompatible => "https://api.openai.com/v1",
51            Provider::Anthropic => "https://api.anthropic.com/v1",
52        }
53    }
54}
55
56impl std::str::FromStr for Provider {
57    type Err = SiError;
58
59    fn from_str(s: &str) -> Result<Self> {
60        match s.trim().to_lowercase().as_str() {
61            "openai" => Ok(Provider::Openai),
62            "anthropic" | "claude" => Ok(Provider::Anthropic),
63            "openai-compatible" | "compatible" | "custom" => Ok(Provider::OpenAiCompatible),
64            other => Err(SiError::Config(format!(
65                "unknown provider `{other}` (expected: openai, anthropic, openai-compatible)"
66            ))),
67        }
68    }
69}
70
71/// Commit message verbosity.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum MessageLevel {
75    /// One-line subject only.
76    Quiet,
77    /// Subject + short body (default).
78    #[default]
79    Normal,
80    /// Detailed subject + explanatory body.
81    Verbose,
82}
83
84impl MessageLevel {
85    pub fn as_str(&self) -> &'static str {
86        match self {
87            MessageLevel::Quiet => "quiet",
88            MessageLevel::Normal => "normal",
89            MessageLevel::Verbose => "verbose",
90        }
91    }
92}
93
94impl std::str::FromStr for MessageLevel {
95    type Err = SiError;
96
97    fn from_str(s: &str) -> Result<Self> {
98        match s.trim().to_lowercase().as_str() {
99            "quiet" => Ok(MessageLevel::Quiet),
100            "normal" => Ok(MessageLevel::Normal),
101            "verbose" => Ok(MessageLevel::Verbose),
102            other => Err(SiError::Config(format!(
103                "unknown message_level `{other}` (expected: quiet, normal, verbose)"
104            ))),
105        }
106    }
107}
108
109/// Modern default model. Small, fast, and cheap — ideal for commit messages.
110/// Change it any time with `ghsi config model <name>`; no file hunting needed.
111pub const DEFAULT_MODEL: &str = "gpt-5-mini";
112
113/// Suggested current models shown by `ghsi config model --list`.
114/// Only current-generation models are listed — deprecated ids are omitted
115/// on purpose so new users never copy a model that is about to disappear.
116pub fn suggested_models(provider: Provider) -> &'static [&'static str] {
117    match provider {
118        Provider::Openai | Provider::OpenAiCompatible => {
119            &["gpt-5-mini", "gpt-5.6-luna", "gpt-5", "gpt-4.1-mini"]
120        }
121        Provider::Anthropic => &["claude-sonnet-4-6", "claude-haiku-4-5"],
122    }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct Config {
127    #[serde(default)]
128    pub provider: Provider,
129
130    #[serde(default = "default_model")]
131    pub model: String,
132
133    /// API key. Prefer the provider env var (`OPENAI_API_KEY` /
134    /// `ANTHROPIC_API_KEY`) and leave this empty in the file.
135    #[serde(default)]
136    pub api_key: String,
137
138    #[serde(default)]
139    pub message_level: MessageLevel,
140
141    /// Token budget for the generated message. Sent to OpenAI as
142    /// `max_completion_tokens` and to Anthropic as `max_tokens`
143    /// (each provider's current parameter name).
144    #[serde(default = "default_max_completion_tokens")]
145    pub max_completion_tokens: u32,
146
147    /// Optional API base URL override (no trailing slash needed).
148    /// Useful for OpenAI-compatible endpoints.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub base_url: Option<String>,
151}
152
153fn default_model() -> String {
154    DEFAULT_MODEL.to_string()
155}
156
157fn default_max_completion_tokens() -> u32 {
158    800
159}
160
161impl Default for Config {
162    fn default() -> Self {
163        Self {
164            provider: Provider::default(),
165            model: default_model(),
166            api_key: String::new(),
167            message_level: MessageLevel::default(),
168            max_completion_tokens: default_max_completion_tokens(),
169            base_url: None,
170        }
171    }
172}
173
174impl Config {
175    /// Canonical config file path (used for creation and display).
176    ///
177    /// Resolution: `$SI_GIT_COMMIT_CONFIG`, otherwise
178    /// `<config_dir>/si-git-commit/config.toml` (e.g.
179    /// `~/.config/si-git-commit/config.toml` on Linux,
180    /// `~/Library/Application Support/si-git-commit/config.toml` on macOS).
181    pub fn config_path() -> PathBuf {
182        if let Ok(p) = env::var("SI_GIT_COMMIT_CONFIG") {
183            if !p.trim().is_empty() {
184                return PathBuf::from(p);
185            }
186        }
187        let base = dirs::config_dir().unwrap_or_else(|| {
188            dirs::home_dir()
189                .unwrap_or_else(|| PathBuf::from("."))
190                .join(".config")
191        });
192        base.join("si-git-commit").join("config.toml")
193    }
194
195    /// Candidate paths searched when loading, in priority order.
196    /// Includes the legacy-style `~/.si-git-commit.toml` so a hand-created
197    /// file in the home directory is still discovered.
198    pub fn candidate_paths() -> Vec<PathBuf> {
199        let mut paths = vec![Self::config_path()];
200        if let Some(home) = dirs::home_dir() {
201            let legacy = home.join(".si-git-commit.toml");
202            if !paths.contains(&legacy) {
203                paths.push(legacy);
204            }
205            let xdg_style = home
206                .join(".config")
207                .join("si-git-commit")
208                .join("config.toml");
209            if !paths.contains(&xdg_style) {
210                paths.push(xdg_style);
211            }
212        }
213        paths
214    }
215
216    pub fn find_existing_path() -> Option<PathBuf> {
217        Self::candidate_paths().into_iter().find(|p| p.exists())
218    }
219
220    /// Human-readable reference to the settings file, for help and errors.
221    pub fn settings_reference() -> String {
222        match Self::find_existing_path() {
223            Some(p) => p.display().to_string(),
224            None => Self::config_path().display().to_string(),
225        }
226    }
227
228    pub fn load() -> Result<Self> {
229        let path = Self::find_existing_path().ok_or_else(|| {
230            SiError::Config(format!(
231                "no config file found. Expected settings TOML at: {}\n\
232                 Run `ghsi config init` to create one, then `ghsi config model` to pick a model.\n\
233                 Tip: run `ghsi config path` any time to see this location.",
234                Self::config_path().display()
235            ))
236        })?;
237        Self::load_from(&path)
238    }
239
240    /// Load if a config file exists, otherwise return defaults.
241    /// Used by `ghsi config` display commands so they work before `init`.
242    pub fn load_or_default() -> Self {
243        match Self::find_existing_path() {
244            Some(p) => Self::load_from(&p).unwrap_or_default(),
245            None => Self::default(),
246        }
247    }
248
249    pub fn load_from(path: &PathBuf) -> Result<Self> {
250        let raw = fs::read_to_string(path)
251            .map_err(|e| SiError::Config(format!("failed to read {}: {e}", path.display())))?;
252        let mut cfg: Config = toml::from_str(&raw)?;
253        cfg.apply_env_overrides();
254        Ok(cfg)
255    }
256
257    /// Environment overrides: `SI_GIT_COMMIT_MODEL`,
258    /// `SI_GIT_COMMIT_PROVIDER`, and the provider API-key env vars.
259    pub fn apply_env_overrides(&mut self) {
260        if let Ok(m) = env::var("SI_GIT_COMMIT_MODEL") {
261            if !m.trim().is_empty() {
262                self.model = m.trim().to_string();
263            }
264        }
265        if let Ok(p) = env::var("SI_GIT_COMMIT_PROVIDER") {
266            if let Ok(parsed) = p.parse::<Provider>() {
267                self.provider = parsed;
268            }
269        }
270        if self.api_key.trim().is_empty() {
271            if let Ok(k) = env::var("SI_GIT_COMMIT_API_KEY") {
272                if !k.trim().is_empty() {
273                    self.api_key = k;
274                    return;
275                }
276            }
277            if let Ok(k) = env::var(self.provider.env_key_name()) {
278                if !k.trim().is_empty() {
279                    self.api_key = k;
280                }
281            }
282        }
283    }
284
285    /// API key found in the environment, as `(variable name, value)`.
286    /// Checks `SI_GIT_COMMIT_API_KEY` first, then the provider variable
287    /// (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`). This is how a system-wide
288    /// key exported from your shell profile is picked up automatically.
289    pub fn env_api_key(&self) -> Option<(String, String)> {
290        for name in ["SI_GIT_COMMIT_API_KEY", self.provider.env_key_name()] {
291            if let Ok(v) = env::var(name) {
292                let v = v.trim().to_string();
293                if !v.is_empty() {
294                    return Some((name.to_string(), v));
295                }
296            }
297        }
298        None
299    }
300
301    /// API key stored in the settings file itself, ignoring the
302    /// environment. `None` when there is no file or no key in it.
303    pub fn stored_api_key() -> Option<String> {
304        let path = Self::find_existing_path()?;
305        let raw = fs::read_to_string(path).ok()?;
306        let cfg: Config = toml::from_str(&raw).ok()?;
307        let key = cfg.api_key.trim().to_string();
308        if key.is_empty() {
309            None
310        } else {
311            Some(key)
312        }
313    }
314
315    /// Resolve the effective API key or return an error that points at
316    /// both the settings file and the env var.
317    pub fn require_api_key(&self) -> Result<String> {
318        let key = self.api_key.trim().to_string();
319        if !key.is_empty() {
320            return Ok(key);
321        }
322        Err(SiError::Config(format!(
323            "no API key configured for provider `{}`.\n\
324             Settings file: {}\n\
325             Set `api_key` in that file (see `ghsi config`) or export {}=<your-key>.\n\
326             Change the model with `ghsi config model <name>` — no manual file hunting needed.",
327            self.provider.as_str(),
328            Self::settings_reference(),
329            self.provider.env_key_name()
330        )))
331    }
332
333    pub fn effective_base_url(&self) -> String {
334        match &self.base_url {
335            Some(u) if !u.trim().is_empty() => u.trim().trim_end_matches('/').to_string(),
336            _ => self.provider.default_base_url().to_string(),
337        }
338    }
339
340    pub fn save(&self) -> Result<PathBuf> {
341        let path = Self::config_path();
342        self.save_to(&path)?;
343        Ok(path)
344    }
345
346    pub fn save_to(&self, path: &PathBuf) -> Result<()> {
347        if let Some(parent) = path.parent() {
348            fs::create_dir_all(parent)?;
349        }
350        let body = toml::to_string_pretty(self)?;
351        let header =
352            "# si-git-commit (ghsi) settings — https://github.com/encapsa-ai/si-git-commit\n\
353                      # Change the model without editing this file: `ghsi config model <name>`\n\
354                      # Show this file's location any time: `ghsi config path`\n\n";
355        fs::write(path, format!("{header}{body}"))?;
356        #[cfg(unix)]
357        {
358            use std::os::unix::fs::PermissionsExt;
359            let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
360        }
361        Ok(())
362    }
363
364    /// Create the config file with defaults if it does not exist.
365    /// Returns `(path, created)`.
366    pub fn init_if_missing() -> Result<(PathBuf, bool)> {
367        if let Some(existing) = Self::find_existing_path() {
368            return Ok((existing, false));
369        }
370        let cfg = Self::default();
371        let path = cfg.save()?;
372        Ok((path, true))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn default_uses_modern_model() {
382        let cfg = Config::default();
383        assert_eq!(cfg.model, DEFAULT_MODEL);
384        assert_eq!(cfg.provider, Provider::Openai);
385        assert_eq!(cfg.max_completion_tokens, 800);
386    }
387
388    #[test]
389    fn parses_minimal_toml_with_defaults() {
390        let cfg: Config = toml::from_str("model = \"gpt-5.6-luna\"\napi_key = \"sk-test\"\n")
391            .expect("toml should parse");
392        assert_eq!(cfg.model, "gpt-5.6-luna");
393        assert_eq!(cfg.provider, Provider::Openai);
394        assert_eq!(cfg.message_level, MessageLevel::Normal);
395    }
396
397    #[test]
398    fn parses_full_toml() {
399        let raw = r#"
400provider = "anthropic"
401model = "claude-sonnet-4-6"
402api_key = "sk-ant-test"
403message_level = "verbose"
404max_completion_tokens = 1200
405"#;
406        let cfg: Config = toml::from_str(raw).expect("toml should parse");
407        assert_eq!(cfg.provider, Provider::Anthropic);
408        assert_eq!(cfg.message_level, MessageLevel::Verbose);
409        assert_eq!(cfg.max_completion_tokens, 1200);
410    }
411
412    #[test]
413    fn provider_from_str_accepts_aliases() {
414        assert_eq!("openai".parse::<Provider>().unwrap(), Provider::Openai);
415        assert_eq!("claude".parse::<Provider>().unwrap(), Provider::Anthropic);
416        assert!("nope".parse::<Provider>().is_err());
417    }
418
419    #[test]
420    fn save_load_roundtrip() {
421        let dir = std::env::temp_dir().join(format!("ghsi-test-{}", std::process::id()));
422        let path = dir.join("config.toml");
423        let cfg = Config {
424            model: "gpt-5.6-luna".to_string(),
425            ..Config::default()
426        };
427        cfg.save_to(&path).expect("save should succeed");
428        let loaded = Config::load_from(&path).expect("load should succeed");
429        assert_eq!(loaded.model, "gpt-5.6-luna");
430        let _ = std::fs::remove_dir_all(&dir);
431    }
432}