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    /// Maximum tokens for API responses.
68    pub max_tokens: u32,
69    /// Temperature for API requests (0.0-1.0).
70    pub temperature: f32,
71}
72
73impl Default for AiConfig {
74    fn default() -> Self {
75        Self {
76            provider: "gemini".to_string(),
77            model: "gemini-3-flash-preview".to_string(),
78            timeout_seconds: 30,
79            allow_paid_models: false,
80            max_tokens: 2048,
81            temperature: 0.3,
82        }
83    }
84}
85
86/// GitHub API settings.
87#[derive(Debug, Deserialize)]
88#[serde(default)]
89pub struct GitHubConfig {
90    /// API request timeout in seconds.
91    pub api_timeout_seconds: u64,
92}
93
94impl Default for GitHubConfig {
95    fn default() -> Self {
96        Self {
97            api_timeout_seconds: 10,
98        }
99    }
100}
101
102/// UI preferences.
103#[derive(Debug, Deserialize)]
104#[serde(default)]
105pub struct UiConfig {
106    /// Enable colored output.
107    pub color: bool,
108    /// Show progress bars.
109    pub progress_bars: bool,
110    /// Always confirm before posting comments.
111    pub confirm_before_post: bool,
112}
113
114impl Default for UiConfig {
115    fn default() -> Self {
116        Self {
117            color: true,
118            progress_bars: true,
119            confirm_before_post: true,
120        }
121    }
122}
123
124/// Cache settings.
125#[derive(Debug, Deserialize)]
126#[serde(default)]
127pub struct CacheConfig {
128    /// Issue cache TTL in minutes.
129    pub issue_ttl_minutes: u64,
130    /// Repository metadata cache TTL in hours.
131    pub repo_ttl_hours: u64,
132    /// URL to fetch curated repositories from.
133    pub curated_repos_url: String,
134}
135
136impl Default for CacheConfig {
137    fn default() -> Self {
138        Self {
139            issue_ttl_minutes: 60,
140            repo_ttl_hours: 24,
141            curated_repos_url:
142                "https://raw.githubusercontent.com/clouatre-labs/aptu/main/data/curated-repos.json"
143                    .to_string(),
144        }
145    }
146}
147
148/// Returns the Aptu configuration directory.
149///
150/// - Linux: `~/.config/aptu`
151/// - macOS: `~/Library/Application Support/aptu`
152/// - Windows: `C:\Users\<User>\AppData\Roaming\aptu`
153#[must_use]
154pub fn config_dir() -> PathBuf {
155    dirs::config_dir()
156        .expect("Could not determine config directory - is HOME set?")
157        .join("aptu")
158}
159
160/// Returns the Aptu data directory.
161///
162/// - Linux: `~/.local/share/aptu`
163/// - macOS: `~/Library/Application Support/aptu`
164/// - Windows: `C:\Users\<User>\AppData\Local\aptu`
165#[must_use]
166pub fn data_dir() -> PathBuf {
167    dirs::data_dir()
168        .expect("Could not determine data directory - is HOME set?")
169        .join("aptu")
170}
171
172/// Returns the path to the configuration file.
173#[must_use]
174pub fn config_file_path() -> PathBuf {
175    config_dir().join("config.toml")
176}
177
178/// Load application configuration.
179///
180/// Loads from config file (if exists) and environment variables.
181/// Environment variables use the prefix `APTU_` and double underscore
182/// for nested keys (e.g., `APTU_AI__MODEL`).
183///
184/// # Errors
185///
186/// Returns `AptuError::Config` if the config file exists but is invalid.
187pub fn load_config() -> Result<AppConfig, AptuError> {
188    let config_path = config_file_path();
189
190    let config = Config::builder()
191        // Load from config file (optional - may not exist)
192        .add_source(File::with_name(config_path.to_string_lossy().as_ref()).required(false))
193        // Override with environment variables
194        .add_source(
195            Environment::with_prefix("APTU")
196                .separator("__")
197                .try_parsing(true),
198        )
199        .build()?;
200
201    let app_config: AppConfig = config.try_deserialize()?;
202
203    Ok(app_config)
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn test_load_config_defaults() {
212        // Without any config file or env vars, should return defaults
213        let config = load_config().expect("should load with defaults");
214
215        assert_eq!(config.ai.provider, "gemini");
216        assert_eq!(config.ai.model, "gemini-3-flash-preview");
217        assert_eq!(config.ai.timeout_seconds, 30);
218        assert_eq!(config.ai.max_tokens, 2048);
219        assert_eq!(config.ai.temperature, 0.3);
220        assert_eq!(config.github.api_timeout_seconds, 10);
221        assert!(config.ui.color);
222        assert!(config.ui.confirm_before_post);
223        assert_eq!(config.cache.issue_ttl_minutes, 60);
224    }
225
226    #[test]
227    fn test_config_dir_exists() {
228        let dir = config_dir();
229        assert!(dir.ends_with("aptu"));
230    }
231
232    #[test]
233    fn test_data_dir_exists() {
234        let dir = data_dir();
235        assert!(dir.ends_with("aptu"));
236    }
237
238    #[test]
239    fn test_config_file_path() {
240        let path = config_file_path();
241        assert!(path.ends_with("config.toml"));
242    }
243}