1use crate::error::{Result, SiError};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::{env, fs};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum Provider {
15 #[default]
16 Openai,
17 Anthropic,
18 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum MessageLevel {
75 Quiet,
77 #[default]
79 Normal,
80 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
109pub const DEFAULT_MODEL: &str = "gpt-5-mini";
112
113pub 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 #[serde(default)]
136 pub api_key: String,
137
138 #[serde(default)]
139 pub message_level: MessageLevel,
140
141 #[serde(default = "default_max_completion_tokens")]
145 pub max_completion_tokens: u32,
146
147 #[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 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 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 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 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 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 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 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 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 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}