cstats_core/config/
mod.rs

1//! Configuration management for cstats
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::{api::types::AnthropicConfig, Error, Result};
8
9/// Main configuration structure for cstats
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11pub struct Config {
12    /// Database configuration
13    pub database: DatabaseConfig,
14
15    /// API configuration
16    pub api: ApiConfig,
17
18    /// Cache configuration
19    pub cache: CacheConfig,
20
21    /// Statistics configuration
22    pub stats: StatsConfig,
23}
24
25/// Database configuration
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct DatabaseConfig {
28    /// Database URL or path
29    pub url: String,
30
31    /// Maximum number of connections
32    pub max_connections: u32,
33
34    /// Connection timeout in seconds
35    pub timeout_seconds: u64,
36}
37
38/// API configuration
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ApiConfig {
41    /// Base URL for API endpoints
42    pub base_url: Option<String>,
43
44    /// API timeout in seconds
45    pub timeout_seconds: u64,
46
47    /// Request retry attempts
48    pub retry_attempts: u32,
49
50    /// Anthropic API configuration
51    pub anthropic: Option<AnthropicConfig>,
52}
53
54/// Cache configuration
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CacheConfig {
57    /// Cache directory path
58    pub cache_dir: PathBuf,
59
60    /// Maximum cache size in bytes
61    pub max_size_bytes: u64,
62
63    /// Cache TTL in seconds
64    pub ttl_seconds: u64,
65}
66
67/// Statistics configuration
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct StatsConfig {
70    /// Default statistics to collect
71    pub default_metrics: Vec<String>,
72
73    /// Sampling rate for statistics
74    pub sampling_rate: f64,
75
76    /// Aggregation window in seconds
77    pub aggregation_window_seconds: u64,
78}
79
80impl Default for DatabaseConfig {
81    fn default() -> Self {
82        Self {
83            url: "sqlite:./cstats.db".to_string(),
84            max_connections: 10,
85            timeout_seconds: 30,
86        }
87    }
88}
89
90impl Default for ApiConfig {
91    fn default() -> Self {
92        Self {
93            base_url: None,
94            timeout_seconds: 30,
95            retry_attempts: 3,
96            anthropic: None,
97        }
98    }
99}
100
101impl Default for CacheConfig {
102    fn default() -> Self {
103        Self {
104            cache_dir: dirs::cache_dir()
105                .unwrap_or_else(std::env::temp_dir)
106                .join("cstats"),
107            max_size_bytes: 100 * 1024 * 1024, // 100MB
108            ttl_seconds: 3600,                 // 1 hour
109        }
110    }
111}
112
113impl Default for StatsConfig {
114    fn default() -> Self {
115        Self {
116            default_metrics: vec![
117                "execution_time".to_string(),
118                "memory_usage".to_string(),
119                "cpu_usage".to_string(),
120            ],
121            sampling_rate: 1.0,
122            aggregation_window_seconds: 300, // 5 minutes
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests;
129
130impl Config {
131    /// Get the default configuration directory path
132    pub fn default_config_dir() -> Result<PathBuf> {
133        dirs::home_dir()
134            .map(|home| home.join(".cstats"))
135            .ok_or_else(|| Error::config("Unable to determine home directory"))
136    }
137
138    /// Get the default configuration file path
139    pub fn default_config_path() -> Result<PathBuf> {
140        Ok(Self::default_config_dir()?.join("config.json"))
141    }
142
143    /// Load configuration from file
144    pub async fn load_from_file(path: &Path) -> Result<Self> {
145        if !path.exists() {
146            return Err(Error::config(format!(
147                "Configuration file does not exist: {}",
148                path.display()
149            )));
150        }
151
152        let content = tokio::fs::read_to_string(path)
153            .await
154            .map_err(|e| Error::config(format!("Failed to read config file: {}", e)))?;
155
156        let config: Config = serde_json::from_str(&content)
157            .map_err(|e| Error::config(format!("Failed to parse config file: {}", e)))?;
158
159        Ok(config)
160    }
161
162    /// Save configuration to file
163    pub async fn save_to_file(&self, path: &Path) -> Result<()> {
164        // Create parent directory if it doesn't exist
165        if let Some(parent) = path.parent() {
166            tokio::fs::create_dir_all(parent)
167                .await
168                .map_err(|e| Error::config(format!("Failed to create config directory: {}", e)))?;
169        }
170
171        let content = serde_json::to_string_pretty(self)
172            .map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?;
173
174        tokio::fs::write(path, content)
175            .await
176            .map_err(|e| Error::config(format!("Failed to write config file: {}", e)))?;
177
178        Ok(())
179    }
180
181    /// Load configuration with priority: env vars > config file > defaults
182    pub async fn load() -> Result<Self> {
183        // Start with defaults
184        let mut config = Self::default();
185
186        // Try to load from default config file
187        if let Ok(config_path) = Self::default_config_path() {
188            if config_path.exists() {
189                match Self::load_from_file(&config_path).await {
190                    Ok(file_config) => {
191                        config = file_config;
192                    }
193                    Err(e) => {
194                        tracing::warn!(
195                            "Failed to load config from {}: {}",
196                            config_path.display(),
197                            e
198                        );
199                    }
200                }
201            }
202        }
203
204        // Override with environment variables
205        config.apply_env_overrides();
206
207        Ok(config)
208    }
209
210    /// Load configuration from a specific file path with env overrides
211    pub async fn load_from_path(path: &Path) -> Result<Self> {
212        let mut config = if path.exists() {
213            Self::load_from_file(path).await?
214        } else {
215            Self::default()
216        };
217
218        // Apply environment variable overrides
219        config.apply_env_overrides();
220
221        Ok(config)
222    }
223
224    /// Apply environment variable overrides to the configuration
225    fn apply_env_overrides(&mut self) {
226        // Override Anthropic API key from environment
227        if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
228            if !api_key.is_empty() {
229                let mut anthropic_config = self.api.anthropic.clone().unwrap_or_default();
230                anthropic_config.api_key = api_key;
231                self.api.anthropic = Some(anthropic_config);
232            }
233        }
234
235        // Override database URL if set
236        if let Ok(db_url) = std::env::var("CSTATS_DATABASE_URL") {
237            self.database.url = db_url;
238        }
239
240        // Override base URL if set
241        if let Ok(base_url) = std::env::var("CSTATS_API_BASE_URL") {
242            self.api.base_url = Some(base_url);
243        }
244    }
245
246    /// Get the Anthropic API key from config (use effective_anthropic_api_key for env priority)
247    pub fn get_anthropic_api_key(&self) -> Option<&str> {
248        // Return config file API key (use effective_anthropic_api_key to include env vars)
249        self.api
250            .anthropic
251            .as_ref()
252            .map(|config| config.api_key.as_str())
253    }
254
255    /// Check if the configuration has a valid Anthropic API key
256    pub fn has_anthropic_api_key(&self) -> bool {
257        // Check environment variable first
258        if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
259            if !api_key.is_empty() {
260                return true;
261            }
262        }
263
264        // Then check config
265        self.api
266            .anthropic
267            .as_ref()
268            .map(|config| !config.api_key.is_empty())
269            .unwrap_or(false)
270    }
271
272    /// Get the effective Anthropic API key (env var takes precedence)
273    pub fn effective_anthropic_api_key(&self) -> Option<String> {
274        // Environment variable takes precedence
275        if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
276            if !api_key.is_empty() {
277                return Some(api_key);
278            }
279        }
280
281        // Fall back to config file
282        self.api.anthropic.as_ref().and_then(|config| {
283            if config.api_key.is_empty() {
284                None
285            } else {
286                Some(config.api_key.clone())
287            }
288        })
289    }
290
291    /// Validate the configuration
292    pub fn validate(&self) -> Result<()> {
293        let mut errors = Vec::new();
294
295        // Validate database config
296        if self.database.url.is_empty() {
297            errors.push("Database URL cannot be empty".to_string());
298        }
299
300        if self.database.max_connections == 0 {
301            errors.push("Database max_connections must be greater than 0".to_string());
302        }
303
304        if self.database.timeout_seconds == 0 {
305            errors.push("Database timeout_seconds must be greater than 0".to_string());
306        }
307
308        // Validate cache config
309        if self.cache.max_size_bytes == 0 {
310            errors.push("Cache max_size_bytes must be greater than 0".to_string());
311        }
312
313        if self.cache.ttl_seconds == 0 {
314            errors.push("Cache ttl_seconds must be greater than 0".to_string());
315        }
316
317        // Validate stats config
318        if !(0.0..=1.0).contains(&self.stats.sampling_rate) {
319            errors.push("Stats sampling_rate must be between 0.0 and 1.0".to_string());
320        }
321
322        if self.stats.aggregation_window_seconds == 0 {
323            errors.push("Stats aggregation_window_seconds must be greater than 0".to_string());
324        }
325
326        // Validate API config
327        if let Some(ref anthropic) = self.api.anthropic {
328            if anthropic.timeout_seconds == 0 {
329                errors.push("Anthropic timeout_seconds must be greater than 0".to_string());
330            }
331
332            if anthropic.max_retry_delay_ms < anthropic.initial_retry_delay_ms {
333                errors.push(
334                    "Anthropic max_retry_delay_ms must be >= initial_retry_delay_ms".to_string(),
335                );
336            }
337        }
338
339        if !errors.is_empty() {
340            return Err(Error::config(format!(
341                "Configuration validation failed:\n  - {}",
342                errors.join("\n  - ")
343            )));
344        }
345
346        Ok(())
347    }
348
349    /// Load configuration from environment variables and defaults
350    pub fn from_env() -> Result<Self> {
351        let mut config = Self::default();
352        config.apply_env_overrides();
353        Ok(config)
354    }
355}