1use 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#[derive(Debug, Error)]
48pub enum ConfigError {
49 #[error("Environment variable parse error: {0}")]
51 EnvVarParseError(#[from] envy::Error),
52
53 #[error("Configuration parsing error: {0}")]
55 ConfigParseError(#[from] SourceConfigError),
56
57 #[error("Invalid file path: {0}")]
59 InvalidFilePath(String),
60
61 #[error("File read error: {0}")]
63 FileReadError(String),
64
65 #[error("File write error: {0}")]
67 FileWriteError(String),
68
69 #[error("Configuration validation error: {0}")]
71 ValidationError(String),
72
73 #[error("Configuration version error: {0}")]
75 VersionError(String),
76
77 #[error("Missing required field: {0}")]
79 MissingFieldError(String),
80
81 #[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#[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(NonZeroU64),
109 Time(NonZeroU64),
111 Date,
113 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#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
184#[serde(tag = "type", content = "value")]
185pub enum LoggingDestination {
186 File(PathBuf),
188 Stdout,
190 Network(String),
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196#[allow(clippy::unsafe_derive_deserialize)]
198pub struct Config {
199 #[serde(default = "default_version")]
201 pub version: String,
202 #[serde(default = "default_profile")]
204 pub profile: String,
205 #[serde(default = "default_log_file_path")]
207 pub log_file_path: PathBuf,
208 #[serde(default)]
210 pub log_level: LogLevel,
211 pub log_rotation: Option<LogRotation>,
213 #[serde(default = "default_log_format")]
215 pub log_format: String,
216 #[serde(default = "default_logging_destinations")]
218 pub logging_destinations: Vec<LoggingDestination>,
219 #[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 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 #[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 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 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 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 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 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 #[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 #[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; 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 #[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 #[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 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 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 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 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 assert!(config.ensure_paths().is_ok());
1014 }
1015
1016 #[test]
1017 fn test_config_envy_from_iter_succeeds_with_defaults() {
1018 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 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 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 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}