Skip to main content

datasynth_server/
config_loader.rs

1//! External config loading for multi-instance deployments.
2//!
3//! Supports loading configuration from file, URL, inline string, or defaults.
4
5use datasynth_config::schema::GeneratorConfig;
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use tracing::{info, warn};
11
12/// Source for loading configuration.
13#[derive(Debug, Clone, Default, Serialize, Deserialize)]
14#[serde(tag = "type", rename_all = "snake_case")]
15pub enum ConfigSource {
16    /// Load from a YAML file.
17    File { path: PathBuf },
18    /// Load from a URL (HTTP GET) - requires an external fetch.
19    Url { url: String },
20    /// Inline YAML/JSON string.
21    Inline { content: String },
22    /// Use default configuration.
23    #[default]
24    Default,
25}
26
27/// Loads a GeneratorConfig from the specified source.
28pub async fn load_config(source: &ConfigSource) -> Result<GeneratorConfig, ConfigLoadError> {
29    match source {
30        ConfigSource::File { path } => {
31            info!("Loading config from file: {}", path.display());
32            let content = tokio::fs::read_to_string(path).await.map_err(|e| {
33                ConfigLoadError::Io(format!("Failed to read {}: {}", path.display(), e))
34            })?;
35            let config: GeneratorConfig = serde_yaml::from_str(&content)
36                .map_err(|e| ConfigLoadError::Parse(format!("Failed to parse YAML: {}", e)))?;
37            Ok(config)
38        }
39        ConfigSource::Url { url } => {
40            warn!(
41                "URL config loading not yet supported (requires reqwest dependency). URL: {}",
42                url
43            );
44            Err(ConfigLoadError::Io(format!(
45                "URL config loading not yet supported. Use file or inline config instead. URL: {}",
46                url
47            )))
48        }
49        ConfigSource::Inline { content } => {
50            info!("Loading inline config ({} bytes)", content.len());
51            let config: GeneratorConfig = serde_yaml::from_str(content)
52                .map_err(|e| ConfigLoadError::Parse(format!("Failed to parse YAML: {}", e)))?;
53            Ok(config)
54        }
55        ConfigSource::Default => {
56            info!("Using default generator config");
57            Ok(crate::grpc::service::default_generator_config())
58        }
59    }
60}
61
62/// Reloads configuration from a source into shared state.
63pub async fn reload_config(
64    source: &ConfigSource,
65    config_lock: &Arc<RwLock<GeneratorConfig>>,
66) -> Result<(), ConfigLoadError> {
67    let new_config = load_config(source).await?;
68    let mut config = config_lock.write().await;
69    *config = new_config;
70    info!("Configuration reloaded successfully");
71    Ok(())
72}
73
74/// Error type for config loading.
75#[derive(Debug, Clone)]
76pub enum ConfigLoadError {
77    /// I/O error (file not found, network error).
78    Io(String),
79    /// Parse error (invalid YAML/JSON).
80    Parse(String),
81}
82
83impl std::fmt::Display for ConfigLoadError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::Io(msg) => write!(f, "Config I/O error: {}", msg),
87            Self::Parse(msg) => write!(f, "Config parse error: {}", msg),
88        }
89    }
90}
91
92impl std::error::Error for ConfigLoadError {}
93
94#[cfg(test)]
95#[allow(clippy::unwrap_used)]
96mod tests {
97    use super::*;
98
99    #[tokio::test]
100    async fn test_load_default_config() {
101        let config = load_config(&ConfigSource::Default).await.unwrap();
102        assert!(!config.companies.is_empty());
103    }
104
105    #[tokio::test]
106    async fn test_load_inline_config() {
107        let yaml = r#"
108global:
109  industry: manufacturing
110  start_date: "2024-01-01"
111  period_months: 1
112  seed: 42
113  parallel: false
114  group_currency: USD
115  worker_threads: 1
116  memory_limit_mb: 512
117companies:
118  - code: TEST
119    name: Test Company
120    currency: USD
121    country: US
122    annual_transaction_volume: ten_k
123    volume_weight: 1.0
124    fiscal_year_variant: K4
125chart_of_accounts:
126  complexity: small
127output:
128  output_directory: ./output
129"#;
130        let source = ConfigSource::Inline {
131            content: yaml.to_string(),
132        };
133        let config = load_config(&source).await.unwrap();
134        assert_eq!(config.companies[0].code, "TEST");
135    }
136
137    #[tokio::test]
138    async fn test_load_missing_file() {
139        let source = ConfigSource::File {
140            path: PathBuf::from("/nonexistent/config.yaml"),
141        };
142        assert!(load_config(&source).await.is_err());
143    }
144
145    #[tokio::test]
146    async fn test_load_invalid_yaml() {
147        let source = ConfigSource::Inline {
148            content: "{{invalid yaml:".to_string(),
149        };
150        assert!(load_config(&source).await.is_err());
151    }
152
153    #[tokio::test]
154    async fn test_reload_config() {
155        let initial = crate::grpc::service::default_generator_config();
156        let config_lock = Arc::new(RwLock::new(initial));
157
158        reload_config(&ConfigSource::Default, &config_lock)
159            .await
160            .unwrap();
161
162        let config = config_lock.read().await;
163        assert!(!config.companies.is_empty());
164    }
165}