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