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}
43
44/// User preferences.
45#[derive(Debug, Deserialize, Default)]
46#[serde(default)]
47pub struct UserConfig {
48    /// Default repository to use (skip repo selection).
49    pub default_repo: Option<String>,
50}
51
52/// AI provider settings.
53#[derive(Debug, Deserialize)]
54#[serde(default)]
55pub struct AiConfig {
56    /// AI provider: "openrouter" or "ollama".
57    pub provider: String,
58    /// Model identifier.
59    pub model: String,
60    /// Request timeout in seconds.
61    pub timeout_seconds: u64,
62    /// Allow paid models (default: false for cost control).
63    pub allow_paid_models: bool,
64    /// Maximum tokens for API responses.
65    pub max_tokens: u32,
66    /// Temperature for API requests (0.0-1.0).
67    pub temperature: f32,
68    /// Circuit breaker failure threshold before opening (default: 3).
69    pub circuit_breaker_threshold: u32,
70    /// Circuit breaker reset timeout in seconds (default: 60).
71    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/// GitHub API settings.
90#[derive(Debug, Deserialize)]
91#[serde(default)]
92pub struct GitHubConfig {
93    /// API request timeout in seconds.
94    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/// UI preferences.
106#[derive(Debug, Deserialize)]
107#[serde(default)]
108pub struct UiConfig {
109    /// Enable colored output.
110    pub color: bool,
111    /// Show progress bars.
112    pub progress_bars: bool,
113    /// Always confirm before posting comments.
114    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/// Cache settings.
128#[derive(Debug, Deserialize)]
129#[serde(default)]
130pub struct CacheConfig {
131    /// Issue cache TTL in minutes.
132    pub issue_ttl_minutes: u64,
133    /// Repository metadata cache TTL in hours.
134    pub repo_ttl_hours: u64,
135    /// URL to fetch curated repositories from.
136    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/// Returns the Aptu configuration directory.
152///
153/// - Linux: `~/.config/aptu`
154/// - macOS: `~/Library/Application Support/aptu`
155/// - Windows: `C:\Users\<User>\AppData\Roaming\aptu`
156#[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/// Returns the Aptu data directory.
164///
165/// - Linux: `~/.local/share/aptu`
166/// - macOS: `~/Library/Application Support/aptu`
167/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
168#[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/// Returns the path to the configuration file.
176#[must_use]
177pub fn config_file_path() -> PathBuf {
178    config_dir().join("config.toml")
179}
180
181/// Load application configuration.
182///
183/// Loads from config file (if exists) and environment variables.
184/// Environment variables use the prefix `APTU_` and double underscore
185/// for nested keys (e.g., `APTU_AI__MODEL`).
186///
187/// # Errors
188///
189/// Returns `AptuError::Config` if the config file exists but is invalid.
190pub fn load_config() -> Result<AppConfig, AptuError> {
191    let config_path = config_file_path();
192
193    let config = Config::builder()
194        // Load from config file (optional - may not exist)
195        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
196        // Override with environment variables
197        .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        // Without any config file or env vars, should return defaults
216        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}