1use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::Deserialize;
25
26use crate::error::AptuError;
27
28#[derive(Debug, Default, Deserialize)]
30#[serde(default)]
31pub struct AppConfig {
32 pub user: UserConfig,
34 pub ai: AiConfig,
36 pub github: GitHubConfig,
38 pub ui: UiConfig,
40 pub cache: CacheConfig,
42 pub repos: ReposConfig,
44}
45
46#[derive(Debug, Deserialize, Default)]
48#[serde(default)]
49pub struct UserConfig {
50 pub default_repo: Option<String>,
52}
53
54#[derive(Debug, Deserialize)]
56#[serde(default)]
57pub struct AiConfig {
58 pub provider: String,
60 pub model: String,
62 pub timeout_seconds: u64,
64 pub allow_paid_models: bool,
66 pub max_tokens: u32,
68 pub temperature: f32,
70 pub circuit_breaker_threshold: u32,
72 pub circuit_breaker_reset_seconds: u64,
74}
75
76impl Default for AiConfig {
77 fn default() -> Self {
78 Self {
79 provider: "gemini".to_string(),
80 model: "gemini-3-flash-preview".to_string(),
81 timeout_seconds: 30,
82 allow_paid_models: false,
83 max_tokens: 2048,
84 temperature: 0.3,
85 circuit_breaker_threshold: 3,
86 circuit_breaker_reset_seconds: 60,
87 }
88 }
89}
90
91#[derive(Debug, Deserialize)]
93#[serde(default)]
94pub struct GitHubConfig {
95 pub api_timeout_seconds: u64,
97}
98
99impl Default for GitHubConfig {
100 fn default() -> Self {
101 Self {
102 api_timeout_seconds: 10,
103 }
104 }
105}
106
107#[derive(Debug, Deserialize)]
109#[serde(default)]
110pub struct UiConfig {
111 pub color: bool,
113 pub progress_bars: bool,
115 pub confirm_before_post: bool,
117}
118
119impl Default for UiConfig {
120 fn default() -> Self {
121 Self {
122 color: true,
123 progress_bars: true,
124 confirm_before_post: true,
125 }
126 }
127}
128
129#[derive(Debug, Deserialize)]
131#[serde(default)]
132pub struct CacheConfig {
133 pub issue_ttl_minutes: u64,
135 pub repo_ttl_hours: u64,
137 pub curated_repos_url: String,
139}
140
141impl Default for CacheConfig {
142 fn default() -> Self {
143 Self {
144 issue_ttl_minutes: 60,
145 repo_ttl_hours: 24,
146 curated_repos_url:
147 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
148 .to_string(),
149 }
150 }
151}
152
153#[derive(Debug, Deserialize)]
155#[serde(default)]
156pub struct ReposConfig {
157 pub curated: bool,
159}
160
161impl Default for ReposConfig {
162 fn default() -> Self {
163 Self { curated: true }
164 }
165}
166
167#[must_use]
173pub fn config_dir() -> PathBuf {
174 dirs::config_dir()
175 .expect("Could not determine config directory - is HOME set?")
176 .join("aptu")
177}
178
179#[must_use]
185pub fn data_dir() -> PathBuf {
186 dirs::data_dir()
187 .expect("Could not determine data directory - is HOME set?")
188 .join("aptu")
189}
190
191#[must_use]
193pub fn config_file_path() -> PathBuf {
194 config_dir().join("config.toml")
195}
196
197pub fn load_config() -> Result<AppConfig, AptuError> {
207 let config_path = config_file_path();
208
209 let config = Config::builder()
210 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
212 .add_source(
214 Environment::with_prefix("APTU")
215 .separator("__")
216 .try_parsing(true),
217 )
218 .build()?;
219
220 let app_config: AppConfig = config.try_deserialize()?;
221
222 Ok(app_config)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_load_config_defaults() {
231 let config = load_config().expect("should load with defaults");
233
234 assert_eq!(config.ai.provider, "gemini");
235 assert_eq!(config.ai.model, "gemini-3-flash-preview");
236 assert_eq!(config.ai.timeout_seconds, 30);
237 assert_eq!(config.ai.max_tokens, 2048);
238 assert_eq!(config.ai.temperature, 0.3);
239 assert_eq!(config.github.api_timeout_seconds, 10);
240 assert!(config.ui.color);
241 assert!(config.ui.confirm_before_post);
242 assert_eq!(config.cache.issue_ttl_minutes, 60);
243 }
244
245 #[test]
246 fn test_config_dir_exists() {
247 let dir = config_dir();
248 assert!(dir.ends_with("aptu"));
249 }
250
251 #[test]
252 fn test_data_dir_exists() {
253 let dir = data_dir();
254 assert!(dir.ends_with("aptu"));
255 }
256
257 #[test]
258 fn test_config_file_path() {
259 let path = config_file_path();
260 assert!(path.ends_with("config.toml"));
261 }
262}