use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::{api::types::AnthropicConfig, Error, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
pub database: DatabaseConfig,
pub api: ApiConfig,
pub cache: CacheConfig,
pub stats: StatsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
pub max_connections: u32,
pub timeout_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiConfig {
pub base_url: Option<String>,
pub timeout_seconds: u64,
pub retry_attempts: u32,
pub anthropic: Option<AnthropicConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub cache_dir: PathBuf,
pub max_size_bytes: u64,
pub ttl_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatsConfig {
pub default_metrics: Vec<String>,
pub sampling_rate: f64,
pub aggregation_window_seconds: u64,
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: "sqlite:./cstats.db".to_string(),
max_connections: 10,
timeout_seconds: 30,
}
}
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
base_url: None,
timeout_seconds: 30,
retry_attempts: 3,
anthropic: None,
}
}
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
cache_dir: dirs::cache_dir()
.unwrap_or_else(std::env::temp_dir)
.join("cstats"),
max_size_bytes: 100 * 1024 * 1024, ttl_seconds: 3600, }
}
}
impl Default for StatsConfig {
fn default() -> Self {
Self {
default_metrics: vec![
"execution_time".to_string(),
"memory_usage".to_string(),
"cpu_usage".to_string(),
],
sampling_rate: 1.0,
aggregation_window_seconds: 300, }
}
}
#[cfg(test)]
mod tests;
impl Config {
pub fn default_config_dir() -> Result<PathBuf> {
dirs::home_dir()
.map(|home| home.join(".cstats"))
.ok_or_else(|| Error::config("Unable to determine home directory"))
}
pub fn default_config_path() -> Result<PathBuf> {
Ok(Self::default_config_dir()?.join("config.json"))
}
pub async fn load_from_file(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(Error::config(format!(
"Configuration file does not exist: {}",
path.display()
)));
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| Error::config(format!("Failed to read config file: {}", e)))?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| Error::config(format!("Failed to parse config file: {}", e)))?;
Ok(config)
}
pub async fn save_to_file(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| Error::config(format!("Failed to create config directory: {}", e)))?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| Error::config(format!("Failed to serialize config: {}", e)))?;
tokio::fs::write(path, content)
.await
.map_err(|e| Error::config(format!("Failed to write config file: {}", e)))?;
Ok(())
}
pub async fn load() -> Result<Self> {
let mut config = Self::default();
if let Ok(config_path) = Self::default_config_path() {
if config_path.exists() {
match Self::load_from_file(&config_path).await {
Ok(file_config) => {
config = file_config;
}
Err(e) => {
tracing::warn!(
"Failed to load config from {}: {}",
config_path.display(),
e
);
}
}
}
}
config.apply_env_overrides();
Ok(config)
}
pub async fn load_from_path(path: &Path) -> Result<Self> {
let mut config = if path.exists() {
Self::load_from_file(path).await?
} else {
Self::default()
};
config.apply_env_overrides();
Ok(config)
}
fn apply_env_overrides(&mut self) {
if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
if !api_key.is_empty() {
let mut anthropic_config = self.api.anthropic.clone().unwrap_or_default();
anthropic_config.api_key = api_key;
self.api.anthropic = Some(anthropic_config);
}
}
if let Ok(db_url) = std::env::var("CSTATS_DATABASE_URL") {
self.database.url = db_url;
}
if let Ok(base_url) = std::env::var("CSTATS_API_BASE_URL") {
self.api.base_url = Some(base_url);
}
}
pub fn get_anthropic_api_key(&self) -> Option<&str> {
self.api
.anthropic
.as_ref()
.map(|config| config.api_key.as_str())
}
pub fn has_anthropic_api_key(&self) -> bool {
if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
if !api_key.is_empty() {
return true;
}
}
self.api
.anthropic
.as_ref()
.map(|config| !config.api_key.is_empty())
.unwrap_or(false)
}
pub fn effective_anthropic_api_key(&self) -> Option<String> {
if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
if !api_key.is_empty() {
return Some(api_key);
}
}
self.api.anthropic.as_ref().and_then(|config| {
if config.api_key.is_empty() {
None
} else {
Some(config.api_key.clone())
}
})
}
pub fn validate(&self) -> Result<()> {
let mut errors = Vec::new();
if self.database.url.is_empty() {
errors.push("Database URL cannot be empty".to_string());
}
if self.database.max_connections == 0 {
errors.push("Database max_connections must be greater than 0".to_string());
}
if self.database.timeout_seconds == 0 {
errors.push("Database timeout_seconds must be greater than 0".to_string());
}
if self.cache.max_size_bytes == 0 {
errors.push("Cache max_size_bytes must be greater than 0".to_string());
}
if self.cache.ttl_seconds == 0 {
errors.push("Cache ttl_seconds must be greater than 0".to_string());
}
if !(0.0..=1.0).contains(&self.stats.sampling_rate) {
errors.push("Stats sampling_rate must be between 0.0 and 1.0".to_string());
}
if self.stats.aggregation_window_seconds == 0 {
errors.push("Stats aggregation_window_seconds must be greater than 0".to_string());
}
if let Some(ref anthropic) = self.api.anthropic {
if anthropic.timeout_seconds == 0 {
errors.push("Anthropic timeout_seconds must be greater than 0".to_string());
}
if anthropic.max_retry_delay_ms < anthropic.initial_retry_delay_ms {
errors.push(
"Anthropic max_retry_delay_ms must be >= initial_retry_delay_ms".to_string(),
);
}
}
if !errors.is_empty() {
return Err(Error::config(format!(
"Configuration validation failed:\n - {}",
errors.join("\n - ")
)));
}
Ok(())
}
pub fn from_env() -> Result<Self> {
let mut config = Self::default();
config.apply_env_overrides();
Ok(config)
}
}