Skip to main content

gooty_proxy/config/
loader.rs

1//! # Configuration Loader
2//!
3//! This module provides functionality for loading, saving, and managing application configuration files.
4//! It ensures that configuration files are validated, defaults are created when missing, and changes are persisted.
5//!
6//! ## Overview
7//!
8//! The configuration loader is responsible for:
9//! - Loading configuration from TOML files
10//! - Validating configuration values
11//! - Saving configuration back to disk
12//! - Managing configuration snapshots for backup and restore
13//!
14//! ## Examples
15//!
16//! ```
17//! use gooty_proxy::config::loader::ConfigLoader;
18//! use std::path::Path;
19//!
20//! let mut loader = ConfigLoader::new(Path::new("./config")).unwrap();
21//! let config = loader.get_config();
22//! println!("Log level: {}", config.application.log_level);
23//! ```
24
25use std::fs;
26use std::path::{Path, PathBuf};
27
28use chrono::{DateTime, Utc};
29use log::{debug, info, warn};
30
31use crate::config::schema::AppConfig;
32use crate::definitions::errors::{ConfigError, ConfigResult};
33
34/// Configuration loader that handles loading and saving configuration files
35pub struct ConfigLoader {
36    /// Directory containing configuration files
37    config_dir: PathBuf,
38
39    /// Base configuration file name
40    config_filename: String,
41
42    /// Current configuration
43    config: AppConfig,
44}
45
46impl ConfigLoader {
47    /// Create a new `ConfigLoader` with the specified directory and default filename
48    ///
49    /// # Errors
50    ///
51    /// This function will return an error if:
52    /// * The configuration directory cannot be created
53    /// * The configuration file cannot be read or created
54    /// * The configuration file contains invalid TOML
55    pub fn new<P: AsRef<Path>>(config_dir: P) -> ConfigResult<Self> {
56        Self::with_filename(config_dir, "config.toml")
57    }
58
59    /// Create a new `ConfigLoader` with a specified directory and filename
60    ///
61    /// # Errors
62    ///
63    /// This function will return an error if:
64    /// * The configuration directory cannot be created
65    /// * The configuration file cannot be read or created
66    /// * The configuration file contains invalid TOML
67    pub fn with_filename<P: AsRef<Path>>(config_dir: P, filename: &str) -> ConfigResult<Self> {
68        let config_dir = config_dir.as_ref().to_path_buf();
69
70        // Create the directory if it doesn't exist
71        if !config_dir.exists() {
72            info!("Creating configuration directory: {}", config_dir.display());
73            fs::create_dir_all(&config_dir).map_err(ConfigError::IoError)?;
74        }
75
76        // Build the config path
77        let config_path = config_dir.join(filename);
78
79        // Load or create default configuration
80        let config = if config_path.exists() {
81            Self::load_from_file(&config_path)?
82        } else {
83            info!("Configuration file not found, creating default");
84            let default_config = AppConfig::default();
85            Self::save_to_file(&default_config, &config_path)?;
86            default_config
87        };
88
89        Ok(ConfigLoader {
90            config_dir,
91            config_filename: filename.to_string(),
92            config,
93        })
94    }
95
96    /// Get the current configuration
97    #[must_use]
98    pub fn get_config(&self) -> &AppConfig {
99        &self.config
100    }
101
102    /// Get a mutable reference to the current configuration
103    pub fn get_config_mut(&mut self) -> &mut AppConfig {
104        &mut self.config
105    }
106
107    /// Update the configuration and save changes to disk
108    ///
109    /// # Errors
110    ///
111    /// This function will return an error if:
112    /// * The configuration cannot be serialized to TOML
113    /// * The configuration file cannot be written to disk
114    pub fn update_config(&mut self, config: AppConfig) -> ConfigResult<()> {
115        let config_path = self.config_dir.join(&self.config_filename);
116        Self::save_to_file(&config, &config_path)?;
117        self.config = config;
118        debug!(
119            "Configuration updated and saved to {}",
120            config_path.display()
121        );
122        Ok(())
123    }
124
125    /// Reload configuration from disk
126    ///
127    /// # Errors
128    ///
129    /// This function will return an error if:
130    /// * The configuration file doesn't exist
131    /// * The configuration file cannot be read
132    /// * The configuration file contains invalid TOML
133    pub fn reload(&mut self) -> ConfigResult<()> {
134        let config_path = self.config_dir.join(&self.config_filename);
135        if config_path.exists() {
136            self.config = Self::load_from_file(&config_path)?;
137            debug!("Configuration reloaded from {}", config_path.display());
138            Ok(())
139        } else {
140            warn!("Configuration file not found at {}", config_path.display());
141            Err(ConfigError::MissingConfig(config_path))
142        }
143    }
144
145    /// Save the current configuration to disk
146    ///
147    /// # Errors
148    ///
149    /// This function will return an error if:
150    /// * The configuration cannot be serialized to TOML
151    /// * The configuration file cannot be written to disk
152    pub fn save(&self) -> ConfigResult<()> {
153        let config_path = self.config_dir.join(&self.config_filename);
154        Self::save_to_file(&self.config, &config_path)?;
155        debug!("Configuration saved to {}", config_path.display());
156        Ok(())
157    }
158
159    /// Reset the configuration to default values and save to disk
160    ///
161    /// # Errors
162    ///
163    /// This function will return an error if:
164    /// * The default configuration cannot be serialized to TOML
165    /// * The configuration file cannot be written to disk
166    pub fn reset_to_defaults(&mut self) -> ConfigResult<()> {
167        self.config = AppConfig::default();
168        self.save()?;
169        info!("Configuration reset to defaults");
170        Ok(())
171    }
172
173    /// Get the path to the configuration file
174    #[must_use]
175    pub fn get_config_path(&self) -> PathBuf {
176        self.config_dir.join(&self.config_filename)
177    }
178
179    /// Check if the configuration file exists
180    #[must_use]
181    pub fn config_exists(&self) -> bool {
182        self.get_config_path().exists()
183    }
184
185    /// Load configuration from a file
186    ///
187    /// # Errors
188    ///
189    /// This function will return an error if:
190    /// * The file cannot be read
191    /// * The file contains invalid TOML
192    fn load_from_file(path: &Path) -> ConfigResult<AppConfig> {
193        debug!("Loading configuration from {}", path.display());
194        let content = fs::read_to_string(path).map_err(ConfigError::IoError)?;
195
196        let config: AppConfig = toml::from_str(&content).map_err(ConfigError::TomlDeError)?;
197
198        Ok(config)
199    }
200
201    /// Save configuration to a file
202    ///
203    /// # Errors
204    ///
205    /// This function will return an error if:
206    /// * The parent directory cannot be created
207    /// * The configuration cannot be serialized to TOML
208    /// * The file cannot be written
209    fn save_to_file(config: &AppConfig, path: &Path) -> ConfigResult<()> {
210        debug!("Saving configuration to {}", path.display());
211        // Ensure parent directory exists
212        if let Some(parent) = path.parent() {
213            if !parent.exists() {
214                fs::create_dir_all(parent).map_err(ConfigError::IoError)?;
215            }
216        }
217
218        // Convert to TOML with pretty formatting
219        let toml_string = if config.storage.pretty_print {
220            toml::to_string_pretty(config).map_err(ConfigError::TomlSerError)?
221        } else {
222            toml::to_string(config).map_err(ConfigError::TomlSerError)?
223        };
224
225        fs::write(path, toml_string).map_err(ConfigError::IoError)?;
226
227        Ok(())
228    }
229
230    /// Validate the current configuration
231    ///
232    /// # Errors
233    ///
234    /// This function will return an error if:
235    /// * The log level is invalid
236    /// * The request timeout is zero
237    /// * The parallel validations count is zero
238    /// * The minimum success rate is outside the range 0.0 to 1.0
239    /// * The auto-save interval is zero
240    pub fn validate(&self) -> ConfigResult<()> {
241        // Validate log level
242        let valid_log_levels = ["error", "warn", "info", "debug", "trace"];
243        let log_level = self.config.application.log_level.to_lowercase();
244
245        if !valid_log_levels.contains(&log_level.as_str()) {
246            return Err(ConfigError::InvalidValue(format!(
247                "Invalid log_level: {log_level}. Must be one of: error, warn, info, debug, trace"
248            )));
249        }
250
251        // Validate HTTP settings
252        if self.config.http.request_timeout_secs == 0 {
253            return Err(ConfigError::InvalidValue(
254                "request_timeout_secs must be greater than 0".to_string(),
255            ));
256        }
257
258        // Validate judge settings
259        if self.config.judge.parallel_validations == 0 {
260            return Err(ConfigError::InvalidValue(
261                "parallel_validations must be greater than 0".to_string(),
262            ));
263        }
264
265        // Validate proxies settings
266        if self.config.proxies.min_success_rate < 0.0 || self.config.proxies.min_success_rate > 1.0
267        {
268            return Err(ConfigError::InvalidValue(
269                "min_success_rate must be between 0.0 and 1.0".to_string(),
270            ));
271        }
272
273        // Validate storage settings
274        if self.config.storage.auto_save_interval_secs == 0 {
275            return Err(ConfigError::InvalidValue(
276                "auto_save_interval_secs must be greater than 0".to_string(),
277            ));
278        }
279
280        Ok(())
281    }
282
283    /// Create a snapshot of the configuration with the current timestamp
284    ///
285    /// # Errors
286    ///
287    /// This function will return an error if:
288    /// * The backups directory cannot be created
289    /// * The configuration cannot be serialized to TOML
290    /// * The snapshot file cannot be written
291    pub fn create_snapshot(&self) -> ConfigResult<PathBuf> {
292        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
293        let snapshot_filename = format!("config_backup_{timestamp}.toml");
294        let snapshot_path = self.config_dir.join("backups").join(&snapshot_filename);
295
296        // Ensure the backups directory exists
297        if let Some(parent) = snapshot_path.parent() {
298            if !parent.exists() {
299                fs::create_dir_all(parent).map_err(ConfigError::IoError)?;
300            }
301        }
302
303        Self::save_to_file(&self.config, &snapshot_path)?;
304        info!(
305            "Configuration snapshot created at {}",
306            snapshot_path.display()
307        );
308
309        Ok(snapshot_path)
310    }
311
312    /// List all configuration snapshots
313    ///
314    /// # Errors
315    ///
316    /// This function will return an error if:
317    /// * The backups directory cannot be read
318    /// * There's an error reading directory entries
319    /// * There's an error getting file metadata
320    pub fn list_snapshots(&self) -> ConfigResult<Vec<PathBuf>> {
321        let backups_dir = self.config_dir.join("backups");
322
323        if !backups_dir.exists() {
324            return Ok(Vec::new());
325        }
326
327        let mut snapshots = Vec::new();
328        for entry in fs::read_dir(backups_dir).map_err(ConfigError::IoError)? {
329            let entry = entry.map_err(ConfigError::IoError)?;
330            let path = entry.path();
331
332            if path.is_file() && path.extension().is_some_and(|ext| ext == "toml") {
333                snapshots.push(path);
334            }
335        }
336
337        // Sort by modification time, newest first
338        snapshots.sort_by(|a, b| {
339            let a_meta = fs::metadata(a).ok();
340            let b_meta = fs::metadata(b).ok();
341
342            match (a_meta, b_meta) {
343                (Some(a_meta), Some(b_meta)) => {
344                    let a_modified = a_meta.modified().ok().map(DateTime::<Utc>::from);
345                    let b_modified = b_meta.modified().ok().map(DateTime::<Utc>::from);
346
347                    match (b_modified, a_modified) {
348                        (Some(b_time), Some(a_time)) => b_time.cmp(&a_time),
349                        _ => std::cmp::Ordering::Equal,
350                    }
351                }
352                _ => std::cmp::Ordering::Equal,
353            }
354        });
355
356        Ok(snapshots)
357    }
358
359    /// Restore configuration from a snapshot
360    ///
361    /// # Errors
362    ///
363    /// This function will return an error if:
364    /// * The snapshot file doesn't exist
365    /// * The snapshot file cannot be read
366    /// * The snapshot file contains invalid TOML
367    /// * The restored configuration cannot be saved
368    pub fn restore_from_snapshot(&mut self, snapshot_path: &Path) -> ConfigResult<()> {
369        if !snapshot_path.exists() {
370            return Err(ConfigError::MissingConfig(snapshot_path.to_path_buf()));
371        }
372
373        let snapshot_config = Self::load_from_file(snapshot_path)?;
374        self.update_config(snapshot_config)?;
375
376        info!(
377            "Configuration restored from snapshot {}",
378            snapshot_path.display()
379        );
380        Ok(())
381    }
382}