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 from a file with [`Config::load`][crate::config::Config::load],
9//! or build programmatically via
10//! [`Config::default`][crate::config::Config::default] and
11//! [`Config::set`][crate::config::Config::set]. Serialize back to TOML
12//! with [`Config::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;
22#[cfg(feature = "tokio")]
23use notify::{Event, EventKind, RecursiveMode, Watcher};
24use parking_lot::RwLock;
25use serde::{Deserialize, Serialize};
26use std::{
27    collections::HashMap,
28    env, fmt,
29    fs::{self, OpenOptions},
30    num::NonZeroU64,
31    path::{Path, PathBuf},
32    str::FromStr,
33    sync::Arc,
34};
35use thiserror::Error;
36
37#[cfg(feature = "tokio")]
38use tokio::fs::File;
39#[cfg(feature = "tokio")]
40use tokio::io::AsyncReadExt;
41#[cfg(feature = "tokio")]
42use tokio::sync::mpsc;
43
44const CURRENT_CONFIG_VERSION: &str = "1.0";
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    /// File watcher setup failed (requires `tokio` feature).
82    #[cfg(feature = "tokio")]
83    #[error("Watcher error: {0}")]
84    WatcherError(#[from] notify::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/// Log rotation policy variants.
94#[derive(
95    Clone,
96    Copy,
97    Debug,
98    Deserialize,
99    Serialize,
100    Eq,
101    PartialEq,
102    Ord,
103    PartialOrd,
104    Hash,
105)]
106pub enum LogRotation {
107    /// Size-based log rotation.
108    Size(NonZeroU64),
109    /// Time-based log rotation.
110    Time(NonZeroU64),
111    /// Date-based log rotation.
112    Date,
113    /// Count-based log rotation.
114    Count(u32),
115}
116
117impl FromStr for LogRotation {
118    type Err = ConfigError;
119
120    fn from_str(s: &str) -> Result<Self, Self::Err> {
121        let parts: Vec<&str> = s.trim().splitn(2, ':').collect();
122        match parts[0].to_lowercase().as_str() {
123            "size" => {
124                let size_str = parts.get(1).ok_or_else(|| {
125                    ConfigError::ValidationError(
126                        "Missing size value for log rotation"
127                            .to_string(),
128                    )
129                })?;
130                let size = size_str.parse::<u64>().map_err(|_| ConfigError::ValidationError(format!("Invalid size value for log rotation: '{size_str}'")))?;
131                Ok(Self::Size(NonZeroU64::new(size).ok_or_else(
132                    || {
133                        ConfigError::ValidationError(
134                            "Log rotation size must be greater than 0"
135                                .to_string(),
136                        )
137                    },
138                )?))
139            }
140            "time" => {
141                let time_str = parts.get(1).ok_or_else(|| {
142                    ConfigError::ValidationError(
143                        "Missing time value for log rotation"
144                            .to_string(),
145                    )
146                })?;
147                let time = time_str.parse::<u64>().map_err(|_| ConfigError::ValidationError(format!("Invalid time value for log rotation: '{time_str}'")))?;
148                Ok(Self::Time(NonZeroU64::new(time).ok_or_else(
149                    || {
150                        ConfigError::ValidationError(
151                            "Log rotation time must be greater than 0"
152                                .to_string(),
153                        )
154                    },
155                )?))
156            }
157            "date" => Ok(Self::Date),
158            "count" => {
159                let count = parts
160                    .get(1)
161                    .ok_or_else(|| ConfigError::ValidationError("Missing count value for log rotation".to_string()))?
162                    .parse::<usize>()
163                    .map_err(|_| ConfigError::ValidationError(format!("Invalid count value for log rotation: '{0}'", parts[1])))?;
164                if count == 0 {
165                    Err(ConfigError::ValidationError(
166                        "Log rotation count must be greater than 0"
167                            .to_string(),
168                    ))
169                } else {
170                    Ok(Self::Count(
171                        count.try_into().unwrap_or(u32::MAX),
172                    ))
173                }
174            }
175            _ => Err(ConfigError::ValidationError(format!(
176                "Invalid log rotation option: '{s}'"
177            ))),
178        }
179    }
180}
181
182/// Enum representing different logging destinations.
183#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
184#[serde(tag = "type", content = "value")]
185pub enum LoggingDestination {
186    /// Log to a file.
187    File(PathBuf),
188    /// Log to standard output.
189    Stdout,
190    /// Log to a network destination.
191    Network(String),
192}
193
194/// Configuration structure for the logging system.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196// Allowed because Config contains no unsafe invariants that Deserialize could violate.
197#[allow(clippy::unsafe_derive_deserialize)]
198pub struct Config {
199    /// Version of the configuration.
200    #[serde(default = "default_version")]
201    pub version: String,
202    /// Profile name for the configuration.
203    #[serde(default = "default_profile")]
204    pub profile: String,
205    /// Path to the log file.
206    #[serde(default = "default_log_file_path")]
207    pub log_file_path: PathBuf,
208    /// Log level for the system.
209    #[serde(default)]
210    pub log_level: LogLevel,
211    /// Log rotation settings.
212    pub log_rotation: Option<LogRotation>,
213    /// Log format string.
214    #[serde(default = "default_log_format")]
215    pub log_format: String,
216    /// Logging destinations for the system.
217    #[serde(default = "default_logging_destinations")]
218    pub logging_destinations: Vec<LoggingDestination>,
219    /// Environment variables for the system.
220    #[serde(default)]
221    pub env_vars: HashMap<String, String>,
222}
223
224fn default_version() -> String {
225    CURRENT_CONFIG_VERSION.to_string()
226}
227fn default_profile() -> String {
228    "default".to_string()
229}
230fn default_log_file_path() -> PathBuf {
231    PathBuf::from("RLG.log")
232}
233fn default_log_format() -> String {
234    "%level - %message".to_string()
235}
236fn default_logging_destinations() -> Vec<LoggingDestination> {
237    vec![LoggingDestination::File(PathBuf::from("RLG.log"))]
238}
239
240impl Default for Config {
241    fn default() -> Self {
242        Self {
243            version: default_version(),
244            profile: default_profile(),
245            log_file_path: default_log_file_path(),
246            log_level: LogLevel::INFO,
247            log_rotation: NonZeroU64::new(10 * 1024 * 1024)
248                .map(LogRotation::Size),
249            log_format: default_log_format(),
250            logging_destinations: default_logging_destinations(),
251            env_vars: HashMap::new(),
252        }
253    }
254}
255
256impl Config {
257    /// Loads configuration from a file or falls back to defaults.
258    ///
259    /// This is the synchronous variant. See [`Config::load_async`] for the
260    /// async equivalent (requires the `tokio` feature).
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the configuration file cannot be read,
265    /// parsed, or if the version is unsupported.
266    pub fn load<P: AsRef<Path>>(
267        config_path: Option<P>,
268    ) -> Result<Arc<RwLock<Self>>, ConfigError> {
269        let config = if let Some(path) = config_path {
270            let contents =
271                fs::read_to_string(path.as_ref()).map_err(|e| {
272                    ConfigError::FileReadError(e.to_string())
273                })?;
274            let config_source = ConfigSource::builder()
275                .add_source(ConfigFile::from_str(
276                    &contents,
277                    config::FileFormat::Toml,
278                ))
279                .build()?;
280            let version: String = config_source.get("version")?;
281            if version != CURRENT_CONFIG_VERSION {
282                return Err(ConfigError::VersionError(format!(
283                    "Unsupported configuration version: {version}"
284                )));
285            }
286            config_source.try_deserialize()?
287        } else {
288            Self::default()
289        };
290        config.validate()?;
291        config.ensure_paths()?;
292        Ok(Arc::new(RwLock::new(config)))
293    }
294
295    /// Loads configuration from a file or environment variables (async).
296    ///
297    /// Requires the `tokio` feature.
298    ///
299    /// # Errors
300    ///
301    /// This function returns an error if the configuration file cannot be read,
302    /// parsed, or if the version is unsupported.
303    #[cfg(feature = "tokio")]
304    pub async fn load_async<P: AsRef<Path>>(
305        config_path: Option<P>,
306    ) -> Result<Arc<RwLock<Self>>, ConfigError> {
307        let path_buf = config_path.map(|p| p.as_ref().to_path_buf());
308        let config = if let Some(path) = path_buf {
309            let mut file = File::open(&path).await.map_err(|e| {
310                ConfigError::FileReadError(e.to_string())
311            })?;
312            let mut contents = String::new();
313            file.read_to_string(&mut contents).await.map_err(|e| {
314                ConfigError::FileReadError(e.to_string())
315            })?;
316            let config_source = ConfigSource::builder()
317                .add_source(ConfigFile::from_str(
318                    &contents,
319                    config::FileFormat::Toml,
320                ))
321                .build()?;
322            let version: String = config_source.get("version")?;
323            if version != CURRENT_CONFIG_VERSION {
324                return Err(ConfigError::VersionError(format!(
325                    "Unsupported configuration version: {version}"
326                )));
327            }
328            config_source.try_deserialize()?
329        } else {
330            Self::default()
331        };
332        config.validate()?;
333        config.ensure_paths()?;
334        Ok(Arc::new(RwLock::new(config)))
335    }
336
337    /// Saves the current configuration to a file in TOML format.
338    ///
339    /// This matches the TOML format expected by [`Config::load`].
340    ///
341    /// # Errors
342    ///
343    /// This function returns an error if the file cannot be written or
344    /// if serialization fails.
345    pub fn save_to_file<P: AsRef<Path>>(
346        &self,
347        path: P,
348    ) -> Result<(), ConfigError> {
349        let config_string =
350            toml::to_string_pretty(self).map_err(|e| {
351                ConfigError::FileWriteError(format!(
352                    "Failed to serialize config to TOML: {e}"
353                ))
354            })?;
355        fs::write(path, config_string).map_err(|e| {
356            ConfigError::FileWriteError(format!(
357                "Failed to write config file: {e}"
358            ))
359        })?;
360        Ok(())
361    }
362
363    /// Sets a value in the configuration based on the specified key.
364    ///
365    /// # Errors
366    ///
367    /// This function returns an error if the value cannot be serialized or if the key is unknown.
368    pub fn set<T: Serialize>(
369        &mut self,
370        key: &str,
371        value: T,
372    ) -> Result<(), ConfigError> {
373        let val = serde_json::to_value(value)
374            .map_err(|e| ConfigError::ValidationError(e.to_string()))?;
375
376        match key {
377            "version" => {
378                if let Some(s) = val.as_str() {
379                    self.version = s.to_string();
380                } else {
381                    return Err(ConfigError::ValidationError(
382                        "Invalid version format".to_string(),
383                    ));
384                }
385            }
386            "profile" => {
387                if let Some(s) = val.as_str() {
388                    self.profile = s.to_string();
389                } else {
390                    return Err(ConfigError::ValidationError(
391                        "Invalid profile format".to_string(),
392                    ));
393                }
394            }
395            "log_file_path" => {
396                self.log_file_path = serde_json::from_value(val)
397                    .map_err(|e| {
398                        ConfigError::ConfigParseError(
399                            SourceConfigError::Message(e.to_string()),
400                        )
401                    })?;
402            }
403            "log_level" => {
404                self.log_level =
405                    serde_json::from_value(val).map_err(|e| {
406                        ConfigError::ConfigParseError(
407                            SourceConfigError::Message(e.to_string()),
408                        )
409                    })?;
410            }
411            "log_rotation" => {
412                self.log_rotation = serde_json::from_value(val)
413                    .map_err(|e| {
414                        ConfigError::ConfigParseError(
415                            SourceConfigError::Message(e.to_string()),
416                        )
417                    })?;
418            }
419            "log_format" => {
420                if let Some(s) = val.as_str() {
421                    self.log_format = s.to_string();
422                } else {
423                    return Err(ConfigError::ValidationError(
424                        "Invalid log format".to_string(),
425                    ));
426                }
427            }
428            "logging_destinations" => {
429                self.logging_destinations = serde_json::from_value(val)
430                    .map_err(|e| {
431                        ConfigError::ConfigParseError(
432                            SourceConfigError::Message(e.to_string()),
433                        )
434                    })?;
435            }
436            "env_vars" => {
437                self.env_vars =
438                    serde_json::from_value(val).map_err(|e| {
439                        ConfigError::ConfigParseError(
440                            SourceConfigError::Message(e.to_string()),
441                        )
442                    })?;
443            }
444            _ => {
445                return Err(ConfigError::ValidationError(format!(
446                    "Unknown configuration key: {key}"
447                )));
448            }
449        }
450        Ok(())
451    }
452
453    /// Validates the configuration settings.
454    ///
455    /// # Errors
456    ///
457    /// This function returns an error if any configuration setting is invalid.
458    pub fn validate(&self) -> Result<(), ConfigError> {
459        use crate::commons::validation::{
460            Validator, validate_not_empty,
461        };
462
463        let mut v = Validator::new();
464        v.check("version", || {
465            validate_not_empty(self.version.trim()).map(|_| ())
466        })
467        .check("profile", || {
468            validate_not_empty(self.profile.trim()).map(|_| ())
469        })
470        .check("log_format", || {
471            validate_not_empty(self.log_format.trim()).map(|_| ())
472        });
473
474        // Path and destination checks remain manual (not string validations)
475        if self.log_file_path.as_os_str().is_empty() {
476            return Err(ConfigError::ValidationError(
477                "Log file path cannot be empty".into(),
478            ));
479        }
480        if self.logging_destinations.is_empty() {
481            return Err(ConfigError::ValidationError(
482                "At least one logging destination must be specified"
483                    .into(),
484            ));
485        }
486        for (key, value) in &self.env_vars {
487            v.check(&format!("env_var_key_{key}"), || {
488                validate_not_empty(key.trim()).map(|_| ())
489            });
490            v.check(&format!("env_var_val_{key}"), || {
491                validate_not_empty(value.trim()).map(|_| ())
492            });
493        }
494
495        v.finish().map_err(|errors| {
496            let msgs: Vec<String> = errors
497                .iter()
498                .map(|(f, e)| format!("{f}: {e}"))
499                .collect();
500            ConfigError::ValidationError(msgs.join("; "))
501        })
502    }
503
504    /// Creates directories and log files required by the configuration.
505    ///
506    /// # Errors
507    ///
508    /// This function returns an error if the directories or files cannot be created.
509    pub fn ensure_paths(&self) -> Result<(), ConfigError> {
510        if let Some(LoggingDestination::File(path)) =
511            self.logging_destinations.first()
512        {
513            if let Some(parent_dir) = path.parent() {
514                fs::create_dir_all(parent_dir).map_err(|e| {
515                    ConfigError::ValidationError(format!(
516                        "Failed to create directory for log file: {e}"
517                    ))
518                })?;
519            }
520            OpenOptions::new()
521                .create(true)
522                .append(true)
523                .open(path)
524                .map_err(|e| {
525                    ConfigError::ValidationError(format!(
526                        "Log file is not writable: {e}"
527                    ))
528                })?;
529        }
530        Ok(())
531    }
532
533    /// Expands environment variables in the configuration values.
534    #[must_use]
535    pub fn expand_env_vars(&self) -> Self {
536        let mut new_config = self.clone();
537        for (key, value) in &mut new_config.env_vars {
538            if let Ok(env_value) = env::var(key) {
539                *value = env_value;
540            }
541        }
542        new_config
543    }
544
545    /// Hot-reloads configuration on file change.
546    ///
547    /// Requires the `tokio` feature.
548    ///
549    /// # Errors
550    ///
551    /// This function returns an error if the watcher cannot be initialized.
552    #[cfg(feature = "tokio")]
553    #[allow(clippy::incompatible_msrv)]
554    pub fn hot_reload_async(
555        config_path: &str,
556        config: &Arc<RwLock<Self>>,
557    ) -> Result<mpsc::Sender<()>, ConfigError> {
558        let (stop_tx, mut stop_rx) = mpsc::channel::<()>(1);
559        let (tx, mut rx) = mpsc::channel::<notify::Result<Event>>(100);
560
561        let mut watcher = notify::recommended_watcher(move |res| {
562            let _ = tx.blocking_send(res);
563        })?;
564        watcher.watch(
565            Path::new(config_path),
566            RecursiveMode::NonRecursive,
567        )?;
568
569        let config_clone = config.clone();
570        let path_owned = config_path.to_string();
571        tokio::spawn(async move {
572            let _watcher = watcher; // Keep watcher alive for the lifetime of the task
573            loop {
574                tokio::select! {
575                    Some(res) = rx.recv() => {
576                        if let Ok(Event { kind: EventKind::Modify(_), .. }) = res
577                            && let Ok(new_config) = Self::load_async(Some(&path_owned)).await {
578                                let mut config_write = config_clone.write();
579                                *config_write = new_config.read().clone();
580                        }
581                    }
582                    _ = stop_rx.recv() => break,
583                }
584            }
585        });
586        Ok(stop_tx)
587    }
588
589    /// Compares two configurations and returns the differences.
590    #[must_use]
591    pub fn diff(
592        config1: &Self,
593        config2: &Self,
594    ) -> HashMap<String, String> {
595        let mut diffs = HashMap::new();
596        macro_rules! config_diff_fields {
597            ($c1:expr, $c2:expr, $diffs:expr;
598             $( display $field:ident; )*
599             $( debug $dfield:ident; )*
600             $( path $pfield:ident; )*
601            ) => {
602                $(
603                    if $c1.$field != $c2.$field {
604                        $diffs.insert(
605                            stringify!($field).to_string(),
606                            format!("{} -> {}", $c1.$field, $c2.$field),
607                        );
608                    }
609                )*
610                $(
611                    if $c1.$dfield != $c2.$dfield {
612                        $diffs.insert(
613                            stringify!($dfield).to_string(),
614                            format!("{:?} -> {:?}", $c1.$dfield, $c2.$dfield),
615                        );
616                    }
617                )*
618                $(
619                    if $c1.$pfield != $c2.$pfield {
620                        $diffs.insert(
621                            stringify!($pfield).to_string(),
622                            format!("{} -> {}", $c1.$pfield.display(), $c2.$pfield.display()),
623                        );
624                    }
625                )*
626            };
627        }
628        config_diff_fields!(config1, config2, diffs;
629            display version;
630            display profile;
631            display log_format;
632            debug log_level;
633            debug log_rotation;
634            debug logging_destinations;
635            debug env_vars;
636            path log_file_path;
637        );
638        diffs
639    }
640
641    /// Overrides the current configuration with values from another configuration.
642    #[must_use]
643    pub fn override_with(&self, other: &Self) -> Self {
644        let mut env_vars = self.env_vars.clone();
645        env_vars.extend(other.env_vars.clone());
646        Self {
647            version: other.version.clone(),
648            profile: other.profile.clone(),
649            log_file_path: other.log_file_path.clone(),
650            log_level: other.log_level,
651            log_rotation: other.log_rotation,
652            log_format: other.log_format.clone(),
653            logging_destinations: other.logging_destinations.clone(),
654            env_vars,
655        }
656    }
657}
658
659impl TryFrom<env::Vars> for Config {
660    type Error = ConfigError;
661    fn try_from(vars: env::Vars) -> Result<Self, Self::Error> {
662        envy::from_iter(vars).map_err(ConfigError::EnvVarParseError)
663    }
664}
665
666impl fmt::Display for LogRotation {
667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668        match self {
669            Self::Size(size) => write!(f, "Size: {size} bytes"),
670            Self::Time(seconds) => write!(f, "Time: {seconds} seconds"),
671            Self::Date => write!(f, "Date-based rotation"),
672            Self::Count(count) => write!(f, "Count: {count} logs"),
673        }
674    }
675}
676
677#[cfg(all(test, not(miri)))]
678mod tests {
679    use super::*;
680
681    #[cfg(feature = "tokio")]
682    #[tokio::test]
683    #[cfg_attr(miri, ignore)]
684    async fn test_config_hot_reload_async_full() {
685        use parking_lot::RwLock;
686        use std::sync::Arc;
687        use tokio::time::{Duration, sleep};
688
689        let temp_dir = tempfile::tempdir().unwrap();
690        let config_path = temp_dir.path().join("config.toml");
691        let config = Config::default();
692        config.save_to_file(&config_path).unwrap();
693
694        let shared_config = Arc::new(RwLock::new(Config::default()));
695        let stop_tx = Config::hot_reload_async(
696            config_path.to_str().unwrap(),
697            &shared_config,
698        )
699        .unwrap();
700
701        // Trigger Modify
702        let new_config = Config {
703            profile: "modified".to_string(),
704            ..Config::default()
705        };
706        new_config.save_to_file(&config_path).unwrap();
707
708        sleep(Duration::from_millis(200)).await;
709
710        let _ = stop_tx.send(()).await;
711    }
712
713    #[test]
714    fn test_config_set_exhaustive() {
715        let mut config = Config::default();
716        assert!(config.set("version", 123).is_err());
717        assert!(config.set("profile", 123).is_err());
718        assert!(config.set("log_file_path", 123).is_err());
719        assert!(config.set("log_level", 123).is_err());
720        assert!(config.set("log_rotation", 123).is_err());
721        assert!(config.set("log_format", 123).is_err());
722        assert!(config.set("logging_destinations", 123).is_err());
723        assert!(config.set("env_vars", 123).is_err());
724        assert!(config.set("unknown_key", "value").is_err());
725    }
726
727    #[test]
728    fn test_config_set_unknown_key() {
729        let mut config = Config::default();
730        let res = config.set("absolutely_unknown_key_123", "value");
731        assert!(res.is_err());
732    }
733
734    #[test]
735    #[cfg_attr(miri, ignore)]
736    fn test_config_save_to_file_fail_unit() {
737        let config = Config::default();
738        let dir_path = env::temp_dir();
739        let res = config.save_to_file(&dir_path);
740        assert!(res.is_err());
741    }
742
743    #[test]
744    fn test_commons_config_error_conversion() {
745        let commons_err =
746            crate::commons::config::ConfigError::MissingKey(
747                "test_key".to_string(),
748            );
749        let config_err: ConfigError = commons_err.into();
750        assert!(matches!(config_err, ConfigError::ValidationError(_)));
751        assert!(config_err.to_string().contains("test_key"));
752    }
753
754    #[test]
755    fn test_log_rotation_exhaustive() {
756        assert!(LogRotation::from_str("count:0").is_err());
757        assert!(LogRotation::from_str("size:0").is_err());
758        assert!(LogRotation::from_str("time:0").is_err());
759        assert!(LogRotation::from_str("invalid:xxx").is_err());
760    }
761
762    #[test]
763    fn test_log_rotation_valid() {
764        let size = LogRotation::from_str("size:1024").unwrap();
765        assert!(matches!(size, LogRotation::Size(_)));
766
767        let time = LogRotation::from_str("time:3600").unwrap();
768        assert!(matches!(time, LogRotation::Time(_)));
769
770        let date = LogRotation::from_str("date").unwrap();
771        assert!(matches!(date, LogRotation::Date));
772
773        let count = LogRotation::from_str("count:10").unwrap();
774        assert!(matches!(count, LogRotation::Count(10)));
775    }
776
777    #[test]
778    fn test_log_rotation_missing_values() {
779        assert!(LogRotation::from_str("size").is_err());
780        assert!(LogRotation::from_str("time").is_err());
781        assert!(LogRotation::from_str("count").is_err());
782    }
783
784    #[test]
785    fn test_log_rotation_invalid_numbers() {
786        assert!(LogRotation::from_str("size:abc").is_err());
787        assert!(LogRotation::from_str("time:xyz").is_err());
788        assert!(LogRotation::from_str("count:abc").is_err());
789    }
790
791    #[test]
792    fn test_log_rotation_display() {
793        let size = LogRotation::Size(NonZeroU64::new(1024).unwrap());
794        assert_eq!(size.to_string(), "Size: 1024 bytes");
795
796        let time = LogRotation::Time(NonZeroU64::new(3600).unwrap());
797        assert_eq!(time.to_string(), "Time: 3600 seconds");
798
799        assert_eq!(
800            LogRotation::Date.to_string(),
801            "Date-based rotation"
802        );
803
804        assert_eq!(LogRotation::Count(5).to_string(), "Count: 5 logs");
805    }
806
807    #[test]
808    fn test_config_default_values() {
809        let config = Config::default();
810        assert_eq!(config.version, "1.0");
811        assert_eq!(config.profile, "default");
812        assert_eq!(config.log_file_path, PathBuf::from("RLG.log"));
813        assert_eq!(config.log_level, LogLevel::INFO);
814        assert!(config.log_rotation.is_some());
815        assert_eq!(config.log_format, "%level - %message");
816        assert!(!config.logging_destinations.is_empty());
817        assert!(config.env_vars.is_empty());
818    }
819
820    #[test]
821    fn test_config_set_valid_values() {
822        let mut config = Config::default();
823        assert!(config.set("version", "2.0").is_ok());
824        assert_eq!(config.version, "2.0");
825
826        assert!(config.set("profile", "production").is_ok());
827        assert_eq!(config.profile, "production");
828
829        assert!(config.set("log_format", "%time %level %msg").is_ok());
830        assert_eq!(config.log_format, "%time %level %msg");
831
832        assert!(config.set("log_file_path", "/tmp/test.log").is_ok());
833        assert_eq!(
834            config.log_file_path,
835            PathBuf::from("/tmp/test.log")
836        );
837    }
838
839    #[test]
840    fn test_config_set_log_level() {
841        let mut config = Config::default();
842        assert!(config.set("log_level", "DEBUG").is_ok());
843        assert_eq!(config.log_level, LogLevel::DEBUG);
844    }
845
846    #[test]
847    fn test_config_set_log_rotation() {
848        let mut config = Config::default();
849        assert!(config.set("log_rotation", Option::<()>::None).is_ok());
850        assert!(config.log_rotation.is_none());
851    }
852
853    #[test]
854    fn test_config_set_logging_destinations() {
855        let mut config = Config::default();
856        let dests = vec![LoggingDestination::Stdout];
857        assert!(config.set("logging_destinations", &dests).is_ok());
858        assert_eq!(config.logging_destinations.len(), 1);
859    }
860
861    #[test]
862    fn test_config_set_env_vars() {
863        let mut config = Config::default();
864        let mut vars = HashMap::new();
865        vars.insert("KEY".to_string(), "VALUE".to_string());
866        assert!(config.set("env_vars", &vars).is_ok());
867        assert_eq!(config.env_vars.get("KEY").unwrap(), "VALUE");
868    }
869
870    #[test]
871    fn test_config_validate_empty_path() {
872        let config = Config {
873            log_file_path: PathBuf::from(""),
874            ..Config::default()
875        };
876        assert!(config.validate().is_err());
877    }
878
879    #[test]
880    fn test_config_validate_empty_destinations() {
881        let mut config = Config::default();
882        config.logging_destinations.clear();
883        assert!(config.validate().is_err());
884    }
885
886    #[test]
887    fn test_config_validate_empty_version() {
888        let config = Config {
889            version: "  ".to_string(),
890            ..Config::default()
891        };
892        assert!(config.validate().is_err());
893    }
894
895    #[test]
896    fn test_config_validate_empty_profile() {
897        let config = Config {
898            profile: "  ".to_string(),
899            ..Config::default()
900        };
901        assert!(config.validate().is_err());
902    }
903
904    #[test]
905    fn test_config_validate_empty_log_format() {
906        let config = Config {
907            log_format: "  ".to_string(),
908            ..Config::default()
909        };
910        assert!(config.validate().is_err());
911    }
912
913    #[test]
914    fn test_config_validate_empty_env_var() {
915        let mut config = Config::default();
916        config.env_vars.insert(String::new(), "val".to_string());
917        assert!(config.validate().is_err());
918    }
919
920    #[test]
921    #[allow(unsafe_code)]
922    #[cfg_attr(miri, ignore)]
923    fn test_config_expand_env_vars() {
924        // SAFETY: this test owns the value for the duration of the
925        // expand_env_vars call; no other thread reads it.
926        unsafe { env::set_var("RLG_TEST_EXPAND_KEY", "expected") };
927        let mut config = Config::default();
928        config.env_vars.insert(
929            "RLG_TEST_EXPAND_KEY".to_string(),
930            "placeholder".to_string(),
931        );
932        let expanded = config.expand_env_vars();
933        assert_eq!(
934            expanded.env_vars["RLG_TEST_EXPAND_KEY"],
935            "expected"
936        );
937        // SAFETY: cleanup.
938        unsafe { env::remove_var("RLG_TEST_EXPAND_KEY") };
939    }
940
941    #[test]
942    fn test_config_expand_env_vars_missing() {
943        let mut config = Config::default();
944        config.env_vars.insert(
945            "DEFINITELY_NOT_SET_VAR_XYZ_123".to_string(),
946            "original".to_string(),
947        );
948        let expanded = config.expand_env_vars();
949        assert_eq!(
950            expanded.env_vars["DEFINITELY_NOT_SET_VAR_XYZ_123"],
951            "original"
952        );
953    }
954
955    #[test]
956    fn test_config_diff_no_changes() {
957        let c1 = Config::default();
958        let c2 = Config::default();
959        let diffs = Config::diff(&c1, &c2);
960        assert!(diffs.is_empty());
961    }
962
963    #[test]
964    fn test_config_diff_with_changes() {
965        let c1 = Config::default();
966        let c2 = Config {
967            version: "2.0".to_string(),
968            profile: "prod".to_string(),
969            log_format: "%msg".to_string(),
970            log_level: LogLevel::DEBUG,
971            log_file_path: PathBuf::from("/var/log/app.log"),
972            ..Config::default()
973        };
974        let diffs = Config::diff(&c1, &c2);
975        assert!(diffs.contains_key("version"));
976        assert!(diffs.contains_key("profile"));
977        assert!(diffs.contains_key("log_format"));
978        assert!(diffs.contains_key("log_level"));
979        assert!(diffs.contains_key("log_file_path"));
980    }
981
982    #[test]
983    fn test_config_override_with() {
984        let c1 = Config::default();
985        let mut c2 = Config {
986            version: "2.0".to_string(),
987            profile: "prod".to_string(),
988            ..Config::default()
989        };
990        c2.env_vars
991            .insert("NEW_KEY".to_string(), "new_val".to_string());
992        let merged = c1.override_with(&c2);
993        assert_eq!(merged.version, "2.0");
994        assert_eq!(merged.profile, "prod");
995        assert!(merged.env_vars.contains_key("NEW_KEY"));
996    }
997
998    #[test]
999    #[cfg_attr(miri, ignore)]
1000    fn test_config_ensure_paths() {
1001        let config = Config::default();
1002        // Default config points to RLG.log in current dir — should succeed
1003        assert!(config.ensure_paths().is_ok());
1004    }
1005
1006    #[test]
1007    fn test_config_ensure_paths_stdout_dest() {
1008        let config = Config {
1009            logging_destinations: vec![LoggingDestination::Stdout],
1010            ..Config::default()
1011        };
1012        // Stdout destination doesn't match File pattern — should succeed
1013        assert!(config.ensure_paths().is_ok());
1014    }
1015
1016    #[test]
1017    fn test_config_envy_from_iter_succeeds_with_defaults() {
1018        // All `Config` fields have serde defaults; `envy::from_iter`
1019        // should succeed even with an empty iterator.
1020        let empty: Vec<(String, String)> = Vec::new();
1021        let cfg: Config = envy::from_iter(empty).unwrap();
1022        assert!(!cfg.version.is_empty());
1023    }
1024
1025    #[test]
1026    fn test_config_envy_from_iter_rejects_bad_type() {
1027        // Force an `envy::Error` by supplying a typed field with
1028        // unparseable contents. `log_level` deserializes from a string,
1029        // and `LogLevel::from_str` rejects unknown labels.
1030        let bad = vec![(
1031            "log_level".to_string(),
1032            "NOT_A_REAL_LEVEL".to_string(),
1033        )];
1034        let result: Result<Config, _> = envy::from_iter(bad);
1035        let err = result.unwrap_err();
1036        // Whatever the wording, the key error must surface somehow.
1037        let msg = err.to_string().to_lowercase();
1038        assert!(!msg.is_empty(), "got: {err}");
1039    }
1040
1041    #[test]
1042    fn test_config_try_from_real_env_vars() {
1043        // Exercises the `TryFrom<env::Vars>` impl with the process's
1044        // actual env (which always has serde defaults available).
1045        let result = Config::try_from(env::vars());
1046        assert!(result.is_ok() || result.is_err());
1047    }
1048
1049    #[test]
1050    fn test_logging_destination_debug() {
1051        let file_dest =
1052            LoggingDestination::File(PathBuf::from("/tmp/test.log"));
1053        let stdout_dest = LoggingDestination::Stdout;
1054        let network_dest =
1055            LoggingDestination::Network("localhost:9200".into());
1056        assert!(format!("{file_dest:?}").contains("File"));
1057        assert!(format!("{stdout_dest:?}").contains("Stdout"));
1058        assert!(format!("{network_dest:?}").contains("Network"));
1059    }
1060
1061    #[test]
1062    fn test_config_error_display_all_variants() {
1063        let err = ConfigError::InvalidFilePath("bad".into());
1064        assert!(err.to_string().contains("Invalid file path"));
1065
1066        let err = ConfigError::FileReadError("read fail".into());
1067        assert!(err.to_string().contains("File read error"));
1068
1069        let err = ConfigError::FileWriteError("write fail".into());
1070        assert!(err.to_string().contains("File write error"));
1071
1072        let err = ConfigError::ValidationError("invalid".into());
1073        assert!(err.to_string().contains("validation error"));
1074
1075        let err = ConfigError::VersionError("bad version".into());
1076        assert!(err.to_string().contains("version error"));
1077
1078        let err = ConfigError::MissingFieldError("field_x".into());
1079        assert!(err.to_string().contains("Missing required field"));
1080    }
1081
1082    #[test]
1083    #[cfg_attr(miri, ignore)]
1084    fn test_config_save_and_load() {
1085        let temp_dir = tempfile::tempdir().unwrap();
1086        let path = temp_dir.path().join("test_config.toml");
1087        let config = Config::default();
1088        config.save_to_file(&path).unwrap();
1089        assert!(path.exists());
1090    }
1091
1092    #[cfg(feature = "tokio")]
1093    #[tokio::test]
1094    #[cfg_attr(miri, ignore)]
1095    async fn test_load_async_with_valid_toml() {
1096        let temp_dir = tempfile::tempdir().unwrap();
1097        let config_path = temp_dir.path().join("config.toml");
1098        let toml_content = r#"
1099version = "1.0"
1100profile = "test"
1101log_file_path = "test.log"
1102log_format = "%level - %message"
1103
1104[[logging_destinations]]
1105type = "File"
1106value = "test.log"
1107"#;
1108        fs::write(&config_path, toml_content).unwrap();
1109        let result = Config::load_async(Some(&config_path)).await;
1110        assert!(result.is_ok());
1111        let config = result.unwrap();
1112        let c = config.read();
1113        assert_eq!(c.version, "1.0");
1114        assert_eq!(c.profile, "test");
1115        drop(c);
1116    }
1117
1118    #[cfg(feature = "tokio")]
1119    #[tokio::test]
1120    #[cfg_attr(miri, ignore)]
1121    async fn test_load_async_with_bad_version() {
1122        let temp_dir = tempfile::tempdir().unwrap();
1123        let config_path = temp_dir.path().join("bad_version.toml");
1124        let toml_content = r#"
1125version = "99.0"
1126profile = "test"
1127log_file_path = "test.log"
1128log_format = "%level - %message"
1129
1130[[logging_destinations]]
1131type = "File"
1132value = "test.log"
1133"#;
1134        fs::write(&config_path, toml_content).unwrap();
1135        let result = Config::load_async(Some(&config_path)).await;
1136        assert!(result.is_err());
1137    }
1138
1139    #[cfg(feature = "tokio")]
1140    #[tokio::test]
1141    #[cfg_attr(miri, ignore)]
1142    async fn test_load_async_no_path() {
1143        let result = Config::load_async(None::<&str>).await;
1144        assert!(result.is_ok());
1145    }
1146
1147    #[cfg(feature = "tokio")]
1148    #[tokio::test]
1149    #[cfg_attr(miri, ignore)]
1150    async fn test_load_async_nonexistent_file() {
1151        let result = Config::load_async(Some(
1152            "/tmp/definitely_not_exists_rlg_test.toml",
1153        ))
1154        .await;
1155        assert!(result.is_err());
1156    }
1157
1158    #[test]
1159    #[cfg_attr(miri, ignore)]
1160    fn test_load_sync_with_valid_toml() {
1161        let temp_dir = tempfile::tempdir().unwrap();
1162        let config_path = temp_dir.path().join("config.toml");
1163        let toml_content = r#"
1164version = "1.0"
1165profile = "test"
1166log_file_path = "test.log"
1167log_format = "%level - %message"
1168
1169[[logging_destinations]]
1170type = "File"
1171value = "test.log"
1172"#;
1173        fs::write(&config_path, toml_content).unwrap();
1174        let result = Config::load(Some(&config_path));
1175        assert!(result.is_ok());
1176        let config = result.unwrap();
1177        let c = config.read();
1178        assert_eq!(c.version, "1.0");
1179        assert_eq!(c.profile, "test");
1180        drop(c);
1181    }
1182
1183    #[test]
1184    #[cfg_attr(miri, ignore)]
1185    fn test_load_sync_no_path() {
1186        let result = Config::load(None::<&str>);
1187        assert!(result.is_ok());
1188    }
1189
1190    #[test]
1191    #[cfg_attr(miri, ignore)]
1192    fn test_load_sync_nonexistent_file() {
1193        let result = Config::load(Some(
1194            "/tmp/definitely_not_exists_rlg_test.toml",
1195        ));
1196        assert!(result.is_err());
1197    }
1198
1199    #[test]
1200    #[cfg_attr(miri, ignore)]
1201    fn test_load_sync_bad_version() {
1202        let temp_dir = tempfile::tempdir().unwrap();
1203        let config_path = temp_dir.path().join("bad_version.toml");
1204        let toml_content = r#"
1205version = "99.0"
1206profile = "test"
1207log_file_path = "test.log"
1208log_format = "%level - %message"
1209
1210[[logging_destinations]]
1211type = "File"
1212value = "test.log"
1213"#;
1214        fs::write(&config_path, toml_content).unwrap();
1215        let result = Config::load(Some(&config_path));
1216        assert!(result.is_err());
1217    }
1218
1219    #[test]
1220    fn test_config_save_to_file_success() {
1221        let temp_dir = tempfile::tempdir().unwrap();
1222        let path = temp_dir.path().join("save_test_config.toml");
1223        let config = Config::default();
1224        assert!(config.save_to_file(&path).is_ok());
1225        let contents = fs::read_to_string(&path).unwrap();
1226        assert!(contents.contains("version"));
1227    }
1228
1229    #[test]
1230    #[cfg_attr(miri, ignore)]
1231    fn test_config_save_and_load_roundtrip() {
1232        let temp_dir = tempfile::tempdir().unwrap();
1233        let path = temp_dir.path().join("roundtrip.toml");
1234        let config = Config::default();
1235        config.save_to_file(&path).unwrap();
1236        let loaded = Config::load(Some(&path)).unwrap();
1237        let guard = loaded.read();
1238        let version = guard.version.clone();
1239        let profile = guard.profile.clone();
1240        let log_level = guard.log_level;
1241        drop(guard);
1242        assert_eq!(version, config.version);
1243        assert_eq!(profile, config.profile);
1244        assert_eq!(log_level, config.log_level);
1245    }
1246}