Skip to main content

rlg/
config.rs

1// config.rs
2// Copyright © 2024-2026 RustLogs (RLG). All rights reserved.
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6//! TOML-based configuration: loading, validation, diffing, and hot-reload.
7//!
8//! Load a `Config` from a file with its
9//! [`load`](crate::config::Config::load) function, or build one with
10//! [`default`](crate::config::Config::default) and
11//! [`set`](crate::config::Config::set). Serialize back to TOML with
12//! [`save_to_file`](crate::config::Config::save_to_file).
13//!
14//! Enable the `tokio` feature for async loading and file-watcher hot-reload.
15
16use crate::LogLevel;
17use config::{
18    Config as ConfigSource, ConfigError as SourceConfigError,
19    File as ConfigFile,
20};
21use envy;
22use parking_lot::RwLock;
23use serde::{Deserialize, Serialize};
24use std::{
25    collections::HashMap,
26    env,
27    fs::{self, OpenOptions},
28    num::NonZeroU64,
29    path::{Path, PathBuf},
30    sync::Arc,
31};
32use thiserror::Error;
33
34const CURRENT_CONFIG_VERSION: &str = "1.0";
35
36#[cfg(feature = "tokio")]
37mod hot_reload;
38mod log_rotation;
39mod set;
40
41#[cfg(feature = "tokio")]
42pub use hot_reload::HOT_RELOAD_POLL_INTERVAL;
43pub use log_rotation::LogRotation;
44use set::SETTERS;
45
46/// Configuration error variants.
47#[derive(Debug, Error)]
48pub enum ConfigError {
49    /// Failed to parse an environment variable.
50    #[error("Environment variable parse error: {0}")]
51    EnvVarParseError(#[from] envy::Error),
52
53    /// Failed to parse the configuration file.
54    #[error("Configuration parsing error: {0}")]
55    ConfigParseError(#[from] SourceConfigError),
56
57    /// The provided config file path is invalid or inaccessible.
58    #[error("Invalid file path: {0}")]
59    InvalidFilePath(String),
60
61    /// File read failed.
62    #[error("File read error: {0}")]
63    FileReadError(String),
64
65    /// File write failed.
66    #[error("File write error: {0}")]
67    FileWriteError(String),
68
69    /// Validation failed for a configuration field.
70    #[error("Configuration validation error: {0}")]
71    ValidationError(String),
72
73    /// Config file version does not match the expected version.
74    #[error("Configuration version error: {0}")]
75    VersionError(String),
76
77    /// A required field is missing from the configuration.
78    #[error("Missing required field: {0}")]
79    MissingFieldError(String),
80
81    /// The watched file could not be read (requires `tokio`).
82    #[cfg(feature = "tokio")]
83    #[error("Watcher error: {0}")]
84    WatcherError(std::io::Error),
85}
86
87impl From<crate::commons::config::ConfigError> for ConfigError {
88    fn from(err: crate::commons::config::ConfigError) -> Self {
89        Self::ValidationError(err.to_string())
90    }
91}
92
93/// Enum representing different logging destinations.
94#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
95#[serde(tag = "type", content = "value")]
96pub enum LoggingDestination {
97    /// Log to a file.
98    File(PathBuf),
99    /// Log to standard output.
100    Stdout,
101    /// Log to a network destination.
102    Network(String),
103}
104
105/// Configuration structure for the logging system.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107// Allowed because Config contains no unsafe invariants that Deserialize could violate.
108#[allow(clippy::unsafe_derive_deserialize)]
109pub struct Config {
110    /// Version of the configuration.
111    #[serde(default = "default_version")]
112    pub version: String,
113    /// Profile name for the configuration.
114    #[serde(default = "default_profile")]
115    pub profile: String,
116    /// Path to the log file.
117    #[serde(default = "default_log_file_path")]
118    pub log_file_path: PathBuf,
119    /// Log level for the system.
120    #[serde(default)]
121    pub log_level: LogLevel,
122    /// Log rotation settings.
123    pub log_rotation: Option<LogRotation>,
124    /// Log format string.
125    #[serde(default = "default_log_format")]
126    pub log_format: String,
127    /// Logging destinations for the system.
128    #[serde(default = "default_logging_destinations")]
129    pub logging_destinations: Vec<LoggingDestination>,
130    /// Environment variables for the system.
131    #[serde(default)]
132    pub env_vars: HashMap<String, String>,
133}
134
135fn default_version() -> String {
136    CURRENT_CONFIG_VERSION.to_string()
137}
138fn default_profile() -> String {
139    "default".to_string()
140}
141fn default_log_file_path() -> PathBuf {
142    PathBuf::from("RLG.log")
143}
144fn default_log_format() -> String {
145    "%level - %message".to_string()
146}
147fn default_logging_destinations() -> Vec<LoggingDestination> {
148    vec![LoggingDestination::File(PathBuf::from("RLG.log"))]
149}
150
151impl Default for Config {
152    fn default() -> Self {
153        Self {
154            version: default_version(),
155            profile: default_profile(),
156            log_file_path: default_log_file_path(),
157            log_level: LogLevel::INFO,
158            log_rotation: NonZeroU64::new(10 * 1024 * 1024)
159                .map(LogRotation::Size),
160            log_format: default_log_format(),
161            logging_destinations: default_logging_destinations(),
162            env_vars: HashMap::new(),
163        }
164    }
165}
166
167impl Config {
168    /// Loads configuration from a file or falls back to defaults.
169    ///
170    /// This is the synchronous variant. See [`Config::load_async`] for the
171    /// async equivalent (requires the `tokio` feature).
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the configuration file cannot be read,
176    /// parsed, or if the version is unsupported.
177    pub fn load<P: AsRef<Path>>(
178        config_path: Option<P>,
179    ) -> Result<Arc<RwLock<Self>>, ConfigError> {
180        let config = match config_path {
181            Some(path) => Self::from_toml(
182                &fs::read_to_string(path.as_ref())
183                    .map_err(|e| read_error(&e))?,
184            )?,
185            None => Self::default(),
186        };
187        config.activate()
188    }
189
190    /// Loads configuration from a file or environment variables (async).
191    ///
192    /// Requires the `tokio` feature.
193    ///
194    /// # Errors
195    ///
196    /// This function returns an error if the configuration file cannot be read,
197    /// parsed, or if the version is unsupported.
198    #[cfg(feature = "tokio")]
199    pub async fn load_async<P: AsRef<Path>>(
200        config_path: Option<P>,
201    ) -> Result<Arc<RwLock<Self>>, ConfigError> {
202        // Owned before the first await, so the future does not borrow
203        // the caller's path.
204        let path = config_path.map(|p| p.as_ref().to_path_buf());
205        let config = match path {
206            Some(path) => Self::from_toml(
207                &tokio::fs::read_to_string(&path)
208                    .await
209                    .map_err(|e| read_error(&e))?,
210            )?,
211            None => Self::default(),
212        };
213        config.activate()
214    }
215
216    /// Parse a TOML configuration and check its version.
217    fn from_toml(contents: &str) -> Result<Self, ConfigError> {
218        let source = ConfigSource::builder()
219            .add_source(ConfigFile::from_str(
220                contents,
221                config::FileFormat::Toml,
222            ))
223            .build()?;
224        let version: String = source.get("version")?;
225        if version != CURRENT_CONFIG_VERSION {
226            return Err(ConfigError::VersionError(format!(
227                "Unsupported configuration version: {version}"
228            )));
229        }
230        Ok(source.try_deserialize()?)
231    }
232
233    /// Validate, create the log paths, and share the result.
234    fn activate(self) -> Result<Arc<RwLock<Self>>, ConfigError> {
235        self.validate()?;
236        self.ensure_paths()?;
237        Ok(Arc::new(RwLock::new(self)))
238    }
239
240    /// Saves the current configuration to a file in TOML format.
241    ///
242    /// This matches the TOML format expected by [`Config::load`].
243    ///
244    /// # Errors
245    ///
246    /// This function returns an error if the file cannot be written or
247    /// if serialization fails.
248    pub fn save_to_file<P: AsRef<Path>>(
249        &self,
250        path: P,
251    ) -> Result<(), ConfigError> {
252        let config_string =
253            toml::to_string_pretty(self).map_err(|e| {
254                ConfigError::FileWriteError(format!(
255                    "Failed to serialize config to TOML: {e}"
256                ))
257            })?;
258        fs::write(path, config_string).map_err(|e| {
259            ConfigError::FileWriteError(format!(
260                "Failed to write config file: {e}"
261            ))
262        })?;
263        Ok(())
264    }
265
266    /// Sets a value in the configuration based on the specified key.
267    ///
268    /// # Errors
269    ///
270    /// This function returns an error if the value cannot be serialized or if the key is unknown.
271    pub fn set<T: Serialize>(
272        &mut self,
273        key: &str,
274        value: T,
275    ) -> Result<(), ConfigError> {
276        let val = serde_json::to_value(value)
277            .map_err(|e| ConfigError::ValidationError(e.to_string()))?;
278        let (_, setter) = SETTERS
279            .iter()
280            .find(|(name, _)| *name == key)
281            .ok_or_else(|| {
282                ConfigError::ValidationError(format!(
283                    "Unknown configuration key: {key}"
284                ))
285            })?;
286        setter(self, val)
287    }
288
289    /// Validates the configuration settings.
290    ///
291    /// # Errors
292    ///
293    /// This function returns an error if any configuration setting is invalid.
294    pub fn validate(&self) -> Result<(), ConfigError> {
295        let mut v = crate::commons::validation::Validator::new();
296        v.check("version", || not_empty(&self.version))
297            .check("profile", || not_empty(&self.profile))
298            .check("log_format", || not_empty(&self.log_format));
299        // Path and destination checks are not string validations, and
300        // fail on their own.
301        self.validate_destinations()?;
302        for (key, value) in &self.env_vars {
303            v.check(&format!("env_var_key_{key}"), || not_empty(key));
304            v.check(&format!("env_var_val_{key}"), || not_empty(value));
305        }
306        v.finish().map_err(|errors| {
307            let msgs: Vec<String> = errors
308                .iter()
309                .map(|(f, e)| format!("{f}: {e}"))
310                .collect();
311            ConfigError::ValidationError(msgs.join("; "))
312        })
313    }
314
315    fn validate_destinations(&self) -> Result<(), ConfigError> {
316        if self.log_file_path.as_os_str().is_empty() {
317            return Err(ConfigError::ValidationError(
318                "Log file path cannot be empty".into(),
319            ));
320        }
321        if self.logging_destinations.is_empty() {
322            return Err(ConfigError::ValidationError(
323                "At least one logging destination must be specified"
324                    .into(),
325            ));
326        }
327        Ok(())
328    }
329
330    /// Creates directories and log files required by the configuration.
331    ///
332    /// # Errors
333    ///
334    /// This function returns an error if the directories or files cannot be created.
335    pub fn ensure_paths(&self) -> Result<(), ConfigError> {
336        if let Some(LoggingDestination::File(path)) =
337            self.logging_destinations.first()
338        {
339            if let Some(parent_dir) = path.parent() {
340                fs::create_dir_all(parent_dir).map_err(|e| {
341                    ConfigError::ValidationError(format!(
342                        "Failed to create directory for log file: {e}"
343                    ))
344                })?;
345            }
346            OpenOptions::new()
347                .create(true)
348                .append(true)
349                .open(path)
350                .map_err(|e| {
351                    ConfigError::ValidationError(format!(
352                        "Log file is not writable: {e}"
353                    ))
354                })?;
355        }
356        Ok(())
357    }
358
359    /// Expands environment variables in the configuration values.
360    #[must_use]
361    pub fn expand_env_vars(&self) -> Self {
362        let mut new_config = self.clone();
363        for (key, value) in &mut new_config.env_vars {
364            if let Ok(env_value) = env::var(key) {
365                *value = env_value;
366            }
367        }
368        new_config
369    }
370
371    /// Compares two configurations and returns the differences.
372    #[must_use]
373    pub fn diff(
374        config1: &Self,
375        config2: &Self,
376    ) -> HashMap<String, String> {
377        let mut diffs = HashMap::new();
378        macro_rules! config_diff_fields {
379            ($c1:expr, $c2:expr, $diffs:expr;
380             $( display $field:ident; )*
381             $( debug $dfield:ident; )*
382             $( path $pfield:ident; )*
383            ) => {
384                $(
385                    if $c1.$field != $c2.$field {
386                        $diffs.insert(
387                            stringify!($field).to_string(),
388                            format!("{} -> {}", $c1.$field, $c2.$field),
389                        );
390                    }
391                )*
392                $(
393                    if $c1.$dfield != $c2.$dfield {
394                        $diffs.insert(
395                            stringify!($dfield).to_string(),
396                            format!("{:?} -> {:?}", $c1.$dfield, $c2.$dfield),
397                        );
398                    }
399                )*
400                $(
401                    if $c1.$pfield != $c2.$pfield {
402                        $diffs.insert(
403                            stringify!($pfield).to_string(),
404                            format!("{} -> {}", $c1.$pfield.display(), $c2.$pfield.display()),
405                        );
406                    }
407                )*
408            };
409        }
410        config_diff_fields!(config1, config2, diffs;
411            display version;
412            display profile;
413            display log_format;
414            debug log_level;
415            debug log_rotation;
416            debug logging_destinations;
417            debug env_vars;
418            path log_file_path;
419        );
420        diffs
421    }
422
423    /// Overrides the current configuration with values from another configuration.
424    #[must_use]
425    pub fn override_with(&self, other: &Self) -> Self {
426        let mut env_vars = self.env_vars.clone();
427        env_vars.extend(other.env_vars.clone());
428        Self {
429            version: other.version.clone(),
430            profile: other.profile.clone(),
431            log_file_path: other.log_file_path.clone(),
432            log_level: other.log_level,
433            log_rotation: other.log_rotation,
434            log_format: other.log_format.clone(),
435            logging_destinations: other.logging_destinations.clone(),
436            env_vars,
437        }
438    }
439}
440
441impl TryFrom<env::Vars> for Config {
442    type Error = ConfigError;
443    fn try_from(vars: env::Vars) -> Result<Self, Self::Error> {
444        envy::from_iter(vars).map_err(ConfigError::EnvVarParseError)
445    }
446}
447
448/// A trimmed value must not be empty, for [`Config::validate`].
449fn not_empty(
450    value: &str,
451) -> crate::commons::validation::ValidationResult<()> {
452    crate::commons::validation::validate_not_empty(value.trim())
453        .map(|_| ())
454}
455
456/// A config file that could not be read.
457fn read_error(e: &std::io::Error) -> ConfigError {
458    ConfigError::FileReadError(e.to_string())
459}
460
461#[cfg(all(test, not(miri)))]
462mod tests;