1use std::io::Write;
2use std::path::PathBuf;
3use std::fs::File;
4
5use serde::{Deserialize, Serialize};
6use config::{Config, FileFormat};
7use anyhow::{Context, Result};
8use lazy_static::lazy_static;
9use console::Emoji;
10
11const DEFAULT_TIMEOUT: i64 = 30;
13const DEFAULT_MAX_COMMIT_LENGTH: i64 = 72;
14const DEFAULT_MAX_TOKENS: i64 = 2024;
15const DEFAULT_MODEL: &str = "gpt-4.1-mini"; const DEFAULT_API_KEY: &str = "<PLACE HOLDER FOR YOUR API KEY>";
17
18#[derive(Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
19pub struct AppConfig {
20 pub openai_api_key: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
24 pub openai_base_url: Option<String>,
25 pub model: Option<String>,
26 pub max_tokens: Option<usize>,
27 pub max_commit_length: Option<usize>,
28 pub timeout: Option<usize>
29}
30
31#[derive(Debug)]
32pub struct ConfigPaths {
33 pub dir: PathBuf,
34 pub file: PathBuf
35}
36
37lazy_static! {
38 static ref PATHS: ConfigPaths = ConfigPaths::new();
39 pub static ref APP_CONFIG: AppConfig = AppConfig::new().expect("Failed to load config");
40}
41
42impl ConfigPaths {
43 fn new() -> Self {
44 let dir = home::home_dir()
45 .expect("Failed to determine home directory")
46 .join(".config/git-ai");
47 let file = dir.join("config.ini");
48 Self { dir, file }
49 }
50
51 fn ensure_exists(&self) -> Result<()> {
52 if !self.dir.exists() {
53 std::fs::create_dir_all(&self.dir).with_context(|| format!("Failed to create config directory at {:?}", self.dir))?;
54 }
55 if !self.file.exists() {
56 File::create(&self.file).with_context(|| format!("Failed to create config file at {:?}", self.file))?;
57 }
58 Ok(())
59 }
60}
61
62impl AppConfig {
63 pub fn new() -> Result<Self> {
64 dotenv::dotenv().ok();
65 PATHS.ensure_exists()?;
66
67 let config = Config::builder()
68 .add_source(config::Environment::with_prefix("APP").try_parsing(true))
69 .add_source(config::File::new(PATHS.file.to_string_lossy().as_ref(), FileFormat::Ini))
70 .set_default("language", "en")?
71 .set_default("timeout", DEFAULT_TIMEOUT)?
72 .set_default("max_commit_length", DEFAULT_MAX_COMMIT_LENGTH)?
73 .set_default("max_tokens", DEFAULT_MAX_TOKENS)?
74 .set_default("model", DEFAULT_MODEL)?
75 .set_default("openai_api_key", DEFAULT_API_KEY)?
76 .build()?;
77
78 config
79 .try_deserialize()
80 .context("Failed to deserialize existing config. Please run `git ai config reset` and try again")
81 }
82
83 pub fn save(&self) -> Result<()> {
84 let contents = serde_ini::to_string(&self).context(format!("Failed to serialize config: {self:?}"))?;
85 let mut file = File::create(&PATHS.file).with_context(|| format!("Failed to create config file at {:?}", PATHS.file))?;
86 file
87 .write_all(contents.as_bytes())
88 .context("Failed to write config file")
89 }
90
91 pub fn update_model(&mut self, value: String) -> Result<()> {
92 self.model = Some(value);
93 self.save_with_message("model")
94 }
95
96 pub fn update_max_tokens(&mut self, value: usize) -> Result<()> {
97 self.max_tokens = Some(value);
98 self.save_with_message("max-tokens")
99 }
100
101 pub fn update_max_commit_length(&mut self, value: usize) -> Result<()> {
102 self.max_commit_length = Some(value);
103 self.save_with_message("max-commit-length")
104 }
105
106 pub fn update_openai_api_key(&mut self, value: String) -> Result<()> {
107 self.openai_api_key = Some(value);
108 self.save_with_message("openai-api-key")
109 }
110
111 pub fn update_openai_base_url(&mut self, value: String) -> Result<()> {
112 self.openai_base_url = Some(value);
113 self.save_with_message("openai-base-url")
114 }
115
116 fn save_with_message(&self, option: &str) -> Result<()> {
117 println!("{} Configuration option {} updated!", Emoji("✨", ":-)"), option);
118 self.save()
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
128 fn test_openai_base_url_ini_round_trip() {
129 let config = AppConfig {
130 openai_api_key: Some("sk-test".to_string()),
131 openai_base_url: Some("http://localhost:11434/v1".to_string()),
132 model: Some("gpt-4.1-mini".to_string()),
133 max_tokens: Some(1024),
134 max_commit_length: Some(72),
135 timeout: Some(30)
136 };
137
138 let ini = serde_ini::to_string(&config).expect("serialize");
139 let parsed: AppConfig = serde_ini::from_str(&ini).expect("deserialize");
140 assert_eq!(parsed.openai_base_url, Some("http://localhost:11434/v1".to_string()));
141 assert_eq!(parsed, config);
142 }
143
144 #[test]
146 fn test_openai_base_url_absent_round_trip() {
147 let config = AppConfig {
148 openai_api_key: Some("sk-test".to_string()),
149 openai_base_url: None,
150 model: Some("gpt-4.1-mini".to_string()),
151 max_tokens: Some(1024),
152 max_commit_length: Some(72),
153 timeout: Some(30)
154 };
155
156 let ini = serde_ini::to_string(&config).expect("serialize");
157 let parsed: AppConfig = serde_ini::from_str(&ini).expect("deserialize");
158 assert_eq!(parsed.openai_base_url, None);
159 }
160}