1use 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#[derive(Debug, Error)]
46pub enum ConfigError {
47 #[error("Environment variable parse error: {0}")]
49 EnvVarParseError(#[from] envy::Error),
50
51 #[error("Configuration parsing error: {0}")]
53 ConfigParseError(#[from] SourceConfigError),
54
55 #[error("Invalid file path: {0}")]
57 InvalidFilePath(String),
58
59 #[error("File read error: {0}")]
61 FileReadError(String),
62
63 #[error("File write error: {0}")]
65 FileWriteError(String),
66
67 #[error("Configuration validation error: {0}")]
69 ValidationError(String),
70
71 #[error("Configuration version error: {0}")]
73 VersionError(String),
74
75 #[error("Missing required field: {0}")]
77 MissingFieldError(String),
78
79 #[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#[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(NonZeroU64),
107 Time(NonZeroU64),
109 Date,
111 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#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
182#[serde(tag = "type", content = "value")]
183pub enum LoggingDestination {
184 File(PathBuf),
186 Stdout,
188 Network(String),
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
194#[allow(clippy::unsafe_derive_deserialize)]
196pub struct Config {
197 #[serde(default = "default_version")]
199 pub version: String,
200 #[serde(default = "default_profile")]
202 pub profile: String,
203 #[serde(default = "default_log_file_path")]
205 pub log_file_path: PathBuf,
206 #[serde(default)]
208 pub log_level: LogLevel,
209 pub log_rotation: Option<LogRotation>,
211 #[serde(default = "default_log_format")]
213 pub log_format: String,
214 #[serde(default = "default_logging_destinations")]
216 pub logging_destinations: Vec<LoggingDestination>,
217 #[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 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 #[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 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 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 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 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 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 #[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 #[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; 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 #[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 #[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 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 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 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 assert!(config.ensure_paths().is_ok());
1004 }
1005
1006 #[test]
1007 fn test_config_try_from_env_vars() {
1008 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}