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}
43
44#[derive(Debug, Deserialize, Default)]
46#[serde(default)]
47pub struct UserConfig {
48 pub default_repo: Option<String>,
50}
51
52#[derive(Debug, Deserialize)]
54#[serde(default)]
55pub struct AiConfig {
56 pub provider: String,
58 pub model: String,
60 pub timeout_seconds: u64,
62 pub allow_paid_models: bool,
64 pub max_tokens: u32,
66 pub temperature: f32,
68 pub circuit_breaker_threshold: u32,
70 pub circuit_breaker_reset_seconds: u64,
72}
73
74impl Default for AiConfig {
75 fn default() -> Self {
76 Self {
77 provider: "gemini".to_string(),
78 model: "gemini-3-flash-preview".to_string(),
79 timeout_seconds: 30,
80 allow_paid_models: false,
81 max_tokens: 2048,
82 temperature: 0.3,
83 circuit_breaker_threshold: 3,
84 circuit_breaker_reset_seconds: 60,
85 }
86 }
87}
88
89#[derive(Debug, Deserialize)]
91#[serde(default)]
92pub struct GitHubConfig {
93 pub api_timeout_seconds: u64,
95}
96
97impl Default for GitHubConfig {
98 fn default() -> Self {
99 Self {
100 api_timeout_seconds: 10,
101 }
102 }
103}
104
105#[derive(Debug, Deserialize)]
107#[serde(default)]
108pub struct UiConfig {
109 pub color: bool,
111 pub progress_bars: bool,
113 pub confirm_before_post: bool,
115}
116
117impl Default for UiConfig {
118 fn default() -> Self {
119 Self {
120 color: true,
121 progress_bars: true,
122 confirm_before_post: true,
123 }
124 }
125}
126
127#[derive(Debug, Deserialize)]
129#[serde(default)]
130pub struct CacheConfig {
131 pub issue_ttl_minutes: u64,
133 pub repo_ttl_hours: u64,
135 pub curated_repos_url: String,
137}
138
139impl Default for CacheConfig {
140 fn default() -> Self {
141 Self {
142 issue_ttl_minutes: 60,
143 repo_ttl_hours: 24,
144 curated_repos_url:
145 "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
146 .to_string(),
147 }
148 }
149}
150
151#[must_use]
157pub fn config_dir() -> PathBuf {
158 dirs::config_dir()
159 .expect("Could not determine config directory - is HOME set?")
160 .join("aptu")
161}
162
163#[must_use]
169pub fn data_dir() -> PathBuf {
170 dirs::data_dir()
171 .expect("Could not determine data directory - is HOME set?")
172 .join("aptu")
173}
174
175#[must_use]
177pub fn config_file_path() -> PathBuf {
178 config_dir().join("config.toml")
179}
180
181pub fn load_config() -> Result<AppConfig, AptuError> {
191 let config_path = config_file_path();
192
193 let config = Config::builder()
194 .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
196 .add_source(
198 Environment::with_prefix("APTU")
199 .separator("__")
200 .try_parsing(true),
201 )
202 .build()?;
203
204 let app_config: AppConfig = config.try_deserialize()?;
205
206 Ok(app_config)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_load_config_defaults() {
215 let config = load_config().expect("should load with defaults");
217
218 assert_eq!(config.ai.provider, "gemini");
219 assert_eq!(config.ai.model, "gemini-3-flash-preview");
220 assert_eq!(config.ai.timeout_seconds, 30);
221 assert_eq!(config.ai.max_tokens, 2048);
222 assert_eq!(config.ai.temperature, 0.3);
223 assert_eq!(config.github.api_timeout_seconds, 10);
224 assert!(config.ui.color);
225 assert!(config.ui.confirm_before_post);
226 assert_eq!(config.cache.issue_ttl_minutes, 60);
227 }
228
229 #[test]
230 fn test_config_dir_exists() {
231 let dir = config_dir();
232 assert!(dir.ends_with("aptu"));
233 }
234
235 #[test]
236 fn test_data_dir_exists() {
237 let dir = data_dir();
238 assert!(dir.ends_with("aptu"));
239 }
240
241 #[test]
242 fn test_config_file_path() {
243 let path = config_file_path();
244 assert!(path.ends_with("config.toml"));
245 }
246}