aptu_core/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Configuration management for the Aptu CLI.
4//!
5//! Provides layered configuration from files and environment variables.
6//! Uses XDG-compliant paths via the `dirs` crate.
7//!
8//! # Configuration Sources (in priority order)
9//!
10//! 1. Environment variables (prefix: `APTU_`)
11//! 2. Config file: `~/.config/aptu/config.toml` (or platform equivalent)
12//! 3. Built-in defaults
13//!
14//! # Examples
15//!
16//! ```bash
17//! # Override AI model via environment variable
18//! APTU_AI__MODEL=mistral-small cargo run
19//! ```
20
21use std::path::PathBuf;
22
23use config::{Config, Environment, File};
24use serde::Deserialize;
25
26use crate::error::AptuError;
27
28/// Application configuration.
29#[derive(Debug, Default, Deserialize)]
30#[serde(default)]
31pub struct AppConfig {
32    /// User preferences.
33    pub user: UserConfig,
34    /// AI provider settings.
35    pub ai: AiConfig,
36    /// GitHub API settings.
37    pub github: GitHubConfig,
38    /// UI preferences.
39    pub ui: UiConfig,
40    /// Cache settings.
41    pub cache: CacheConfig,
42    /// Repository settings.
43    pub repos: ReposConfig,
44}
45
46/// User preferences.
47#[derive(Debug, Deserialize, Default)]
48#[serde(default)]
49pub struct UserConfig {
50    /// Default repository to use (skip repo selection).
51    pub default_repo: Option<String>,
52}
53
54/// AI provider settings.
55#[derive(Debug, Deserialize)]
56#[serde(default)]
57pub struct AiConfig {
58    /// AI provider: "openrouter" or "ollama".
59    pub provider: String,
60    /// Model identifier.
61    pub model: String,
62    /// Request timeout in seconds.
63    pub timeout_seconds: u64,
64    /// Allow paid models (default: false for cost control).
65    pub allow_paid_models: bool,
66    /// Maximum tokens for API responses.
67    pub max_tokens: u32,
68    /// Temperature for API requests (0.0-1.0).
69    pub temperature: f32,
70    /// Circuit breaker failure threshold before opening (default: 3).
71    pub circuit_breaker_threshold: u32,
72    /// Circuit breaker reset timeout in seconds (default: 60).
73    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/// GitHub API settings.
92#[derive(Debug, Deserialize)]
93#[serde(default)]
94pub struct GitHubConfig {
95    /// API request timeout in seconds.
96    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/// UI preferences.
108#[derive(Debug, Deserialize)]
109#[serde(default)]
110pub struct UiConfig {
111    /// Enable colored output.
112    pub color: bool,
113    /// Show progress bars.
114    pub progress_bars: bool,
115    /// Always confirm before posting comments.
116    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/// Cache settings.
130#[derive(Debug, Deserialize)]
131#[serde(default)]
132pub struct CacheConfig {
133    /// Issue cache TTL in minutes.
134    pub issue_ttl_minutes: u64,
135    /// Repository metadata cache TTL in hours.
136    pub repo_ttl_hours: u64,
137    /// URL to fetch curated repositories from.
138    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/// Repository settings.
154#[derive(Debug, Deserialize)]
155#[serde(default)]
156pub struct ReposConfig {
157    /// Include curated repositories (default: true).
158    pub curated: bool,
159}
160
161impl Default for ReposConfig {
162    fn default() -> Self {
163        Self { curated: true }
164    }
165}
166
167/// Returns the Aptu configuration directory.
168///
169/// - Linux: `~/.config/aptu`
170/// - macOS: `~/Library/Application Support/aptu`
171/// - Windows: `C:\Users\<User>\AppData\Roaming\aptu`
172#[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/// Returns the Aptu data directory.
180///
181/// - Linux: `~/.local/share/aptu`
182/// - macOS: `~/Library/Application Support/aptu`
183/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
184#[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/// Returns the path to the configuration file.
192#[must_use]
193pub fn config_file_path() -> PathBuf {
194    config_dir().join("config.toml")
195}
196
197/// Load application configuration.
198///
199/// Loads from config file (if exists) and environment variables.
200/// Environment variables use the prefix `APTU_` and double underscore
201/// for nested keys (e.g., `APTU_AI__MODEL`).
202///
203/// # Errors
204///
205/// Returns `AptuError::Config` if the config file exists but is invalid.
206pub fn load_config() -> Result<AppConfig, AptuError> {
207    let config_path = config_file_path();
208
209    let config = Config::builder()
210        // Load from config file (optional - may not exist)
211        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
212        // Override with environment variables
213        .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        // Without any config file or env vars, should return defaults
232        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}