aptu_core/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Configuration management for the Aptu CLI.
4//!
5// Allow dead_code - module will be used in PR 6 (CLI scaffolding)
6#![allow(dead_code)]
7//!
8//! Provides layered configuration from files and environment variables.
9//! Uses XDG-compliant paths via the `dirs` crate.
10//!
11//! # Configuration Sources (in priority order)
12//!
13//! 1. Environment variables (prefix: `APTU_`)
14//! 2. Config file: `~/.config/aptu/config.toml` (or platform equivalent)
15//! 3. Built-in defaults
16//!
17//! # Examples
18//!
19//! ```bash
20//! # Override AI model via environment variable
21//! APTU_AI__MODEL=mistral-small cargo run
22//! ```
23
24use std::path::PathBuf;
25
26use config::{Config, Environment, File};
27use serde::Deserialize;
28
29use crate::error::AptuError;
30
31/// Application configuration.
32#[derive(Debug, Default, Deserialize)]
33#[serde(default)]
34pub struct AppConfig {
35    /// User preferences.
36    pub user: UserConfig,
37    /// AI provider settings.
38    pub ai: AiConfig,
39    /// GitHub API settings.
40    pub github: GitHubConfig,
41    /// UI preferences.
42    pub ui: UiConfig,
43    /// Cache settings.
44    pub cache: CacheConfig,
45}
46
47/// User preferences.
48#[derive(Debug, Deserialize, Default)]
49#[serde(default)]
50pub struct UserConfig {
51    /// Default repository to use (skip repo selection).
52    pub default_repo: Option<String>,
53}
54
55/// AI provider settings.
56#[derive(Debug, Deserialize)]
57#[serde(default)]
58pub struct AiConfig {
59    /// AI provider: "openrouter" or "ollama".
60    pub provider: String,
61    /// Model identifier.
62    pub model: String,
63    /// Request timeout in seconds.
64    pub timeout_seconds: u64,
65    /// Allow paid models (default: false for cost control).
66    pub allow_paid_models: bool,
67}
68
69impl Default for AiConfig {
70    fn default() -> Self {
71        Self {
72            provider: "openrouter".to_string(),
73            model: "mistralai/devstral-2512:free".to_string(),
74            timeout_seconds: 30,
75            allow_paid_models: false,
76        }
77    }
78}
79
80/// GitHub API settings.
81#[derive(Debug, Deserialize)]
82#[serde(default)]
83pub struct GitHubConfig {
84    /// API request timeout in seconds.
85    pub api_timeout_seconds: u64,
86}
87
88impl Default for GitHubConfig {
89    fn default() -> Self {
90        Self {
91            api_timeout_seconds: 10,
92        }
93    }
94}
95
96/// UI preferences.
97#[derive(Debug, Deserialize)]
98#[serde(default)]
99pub struct UiConfig {
100    /// Enable colored output.
101    pub color: bool,
102    /// Show progress bars.
103    pub progress_bars: bool,
104    /// Always confirm before posting comments.
105    pub confirm_before_post: bool,
106}
107
108impl Default for UiConfig {
109    fn default() -> Self {
110        Self {
111            color: true,
112            progress_bars: true,
113            confirm_before_post: true,
114        }
115    }
116}
117
118/// Cache settings.
119#[derive(Debug, Deserialize)]
120#[serde(default)]
121pub struct CacheConfig {
122    /// Issue cache TTL in minutes.
123    pub issue_ttl_minutes: u64,
124    /// Repository metadata cache TTL in hours.
125    pub repo_ttl_hours: u64,
126}
127
128impl Default for CacheConfig {
129    fn default() -> Self {
130        Self {
131            issue_ttl_minutes: 60,
132            repo_ttl_hours: 24,
133        }
134    }
135}
136
137/// Returns the Aptu configuration directory.
138///
139/// - Linux: `~/.config/aptu`
140/// - macOS: `~/Library/Application Support/aptu`
141/// - Windows: `C:\Users\<User>\AppData\Roaming\aptu`
142#[must_use]
143pub fn config_dir() -> PathBuf {
144    dirs::config_dir()
145        .expect("Could not determine config directory - is HOME set?")
146        .join("aptu")
147}
148
149/// Returns the Aptu data directory.
150///
151/// - Linux: `~/.local/share/aptu`
152/// - macOS: `~/Library/Application Support/aptu`
153/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
154#[must_use]
155pub fn data_dir() -> PathBuf {
156    dirs::data_dir()
157        .expect("Could not determine data directory - is HOME set?")
158        .join("aptu")
159}
160
161/// Returns the path to the configuration file.
162#[must_use]
163pub fn config_file_path() -> PathBuf {
164    config_dir().join("config.toml")
165}
166
167/// Load application configuration.
168///
169/// Loads from config file (if exists) and environment variables.
170/// Environment variables use the prefix `APTU_` and double underscore
171/// for nested keys (e.g., `APTU_AI__MODEL`).
172///
173/// # Errors
174///
175/// Returns `AptuError::Config` if the config file exists but is invalid.
176pub fn load_config() -> Result<AppConfig, AptuError> {
177    let config_path = config_file_path();
178
179    let config = Config::builder()
180        // Load from config file (optional - may not exist)
181        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
182        // Override with environment variables
183        .add_source(
184            Environment::with_prefix("APTU")
185                .separator("__")
186                .try_parsing(true),
187        )
188        .build()?;
189
190    let app_config: AppConfig = config.try_deserialize()?;
191
192    Ok(app_config)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_load_config_defaults() {
201        // Without any config file or env vars, should return defaults
202        let config = load_config().expect("should load with defaults");
203
204        assert_eq!(config.ai.provider, "openrouter");
205        assert_eq!(config.ai.model, "mistralai/devstral-2512:free");
206        assert_eq!(config.ai.timeout_seconds, 30);
207        assert_eq!(config.github.api_timeout_seconds, 10);
208        assert!(config.ui.color);
209        assert!(config.ui.confirm_before_post);
210        assert_eq!(config.cache.issue_ttl_minutes, 60);
211    }
212
213    #[test]
214    fn test_config_dir_exists() {
215        let dir = config_dir();
216        assert!(dir.ends_with("aptu"));
217    }
218
219    #[test]
220    fn test_data_dir_exists() {
221        let dir = data_dir();
222        assert!(dir.ends_with("aptu"));
223    }
224
225    #[test]
226    fn test_config_file_path() {
227        let path = config_file_path();
228        assert!(path.ends_with("config.toml"));
229    }
230}