use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::constants::{
DEFAULT_CONFIG_BASE_NAME, DEFAULT_ENV_PREFIX, DEFAULT_EXTENSIONS, MAX_SEARCH_DEPTH,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MergeStrategy {
Replace,
#[default]
Deep,
Shallow,
Strict,
}
impl MergeStrategy {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Replace => "replace",
Self::Deep => "deep",
Self::Shallow => "shallow",
Self::Strict => "strict",
}
}
}
impl std::fmt::Display for MergeStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceConfig {
pub name: String,
pub optional: bool,
pub priority: i32,
pub cache: bool,
pub format: Option<String>,
pub path: Option<PathBuf>,
pub url: Option<String>,
pub env_prefix: Option<String>,
pub extra: std::collections::BTreeMap<String, String>,
}
impl SourceConfig {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
optional: false,
priority: 0,
cache: true,
format: None,
path: None,
url: None,
env_prefix: None,
extra: std::collections::BTreeMap::new(),
}
}
#[must_use]
pub fn builder() -> SourceConfigBuilder {
SourceConfigBuilder::new()
}
#[must_use]
pub fn file(path: impl Into<PathBuf>) -> Self {
Self::builder().name("file").path(path).build()
}
#[must_use]
pub fn env(prefix: impl Into<String>) -> Self {
Self::builder().name("env").env_prefix(prefix).build()
}
#[must_use]
pub fn remote(url: impl Into<String>) -> Self {
Self::builder().name("remote").url(url).build()
}
#[must_use]
pub const fn is_optional(&self) -> bool {
self.optional
}
#[must_use]
pub fn display_id(&self) -> String {
if let Some(ref path) = self.path {
format!("{}:{}", self.name, path.display())
} else if let Some(ref url) = self.url {
format!("{}:{}", self.name, url)
} else if let Some(ref prefix) = self.env_prefix {
format!("{}:{}", self.name, prefix)
} else {
self.name.clone()
}
}
}
impl Default for SourceConfig {
fn default() -> Self {
Self::new(DEFAULT_CONFIG_BASE_NAME)
}
}
#[derive(Debug, Clone, Default)]
pub struct SourceConfigBuilder {
name: Option<String>,
optional: bool,
priority: i32,
cache: bool,
format: Option<String>,
path: Option<PathBuf>,
url: Option<String>,
env_prefix: Option<String>,
extra: std::collections::BTreeMap<String, String>,
}
impl SourceConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
cache: true,
..Self::default()
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn optional(mut self, optional: bool) -> Self {
self.optional = optional;
self
}
#[must_use]
pub fn priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
#[must_use]
pub fn cache(mut self, cache: bool) -> Self {
self.cache = cache;
self
}
#[must_use]
pub fn format(mut self, format: impl Into<String>) -> Self {
self.format = Some(format.into());
self
}
#[must_use]
pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
#[must_use]
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
#[must_use]
pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = Some(prefix.into());
self
}
#[must_use]
pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra.insert(key.into(), value.into());
self
}
#[must_use]
pub fn build(self) -> SourceConfig {
SourceConfig {
name: self
.name
.unwrap_or_else(|| DEFAULT_CONFIG_BASE_NAME.to_string()),
optional: self.optional,
priority: self.priority,
cache: self.cache,
format: self.format,
path: self.path,
url: self.url,
env_prefix: self.env_prefix,
extra: self.extra,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoadOptions {
pub merge_strategy: MergeStrategy,
pub fail_fast: bool,
pub ignore_optional_missing: bool,
pub validate: bool,
pub max_depth: usize,
pub extensions: Vec<String>,
pub env_prefix: String,
pub base_name: String,
pub cache_enabled: bool,
pub search_paths: Vec<PathBuf>,
pub remote_timeout_secs: u64,
}
impl LoadOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn builder() -> LoadOptionsBuilder {
LoadOptionsBuilder::new()
}
#[must_use]
pub const fn is_cache_enabled(&self) -> bool {
self.cache_enabled
}
#[must_use]
pub const fn is_fail_fast(&self) -> bool {
self.fail_fast
}
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
merge_strategy: MergeStrategy::default(),
fail_fast: true,
ignore_optional_missing: true,
validate: true,
max_depth: MAX_SEARCH_DEPTH,
extensions: DEFAULT_EXTENSIONS.iter().map(|s| s.to_string()).collect(),
env_prefix: DEFAULT_ENV_PREFIX.to_string(),
base_name: DEFAULT_CONFIG_BASE_NAME.to_string(),
cache_enabled: true,
search_paths: Vec::new(),
remote_timeout_secs: crate::constants::DEFAULT_REMOTE_TIMEOUT_SECS,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LoadOptionsBuilder {
merge_strategy: Option<MergeStrategy>,
fail_fast: Option<bool>,
ignore_optional_missing: Option<bool>,
validate: Option<bool>,
max_depth: Option<usize>,
extensions: Option<Vec<String>>,
env_prefix: Option<String>,
base_name: Option<String>,
cache_enabled: Option<bool>,
search_paths: Option<Vec<PathBuf>>,
remote_timeout_secs: Option<u64>,
}
impl LoadOptionsBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn merge_strategy(mut self, strategy: MergeStrategy) -> Self {
self.merge_strategy = Some(strategy);
self
}
#[must_use]
pub fn fail_fast(mut self, fail_fast: bool) -> Self {
self.fail_fast = Some(fail_fast);
self
}
#[must_use]
pub fn ignore_optional_missing(mut self, ignore: bool) -> Self {
self.ignore_optional_missing = Some(ignore);
self
}
#[must_use]
pub fn validate(mut self, validate: bool) -> Self {
self.validate = Some(validate);
self
}
#[must_use]
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = Some(depth);
self
}
#[must_use]
pub fn extensions(mut self, extensions: Vec<String>) -> Self {
self.extensions = Some(extensions);
self
}
#[must_use]
pub fn extension(mut self, ext: impl Into<String>) -> Self {
self.extensions
.get_or_insert_with(Vec::new)
.push(ext.into());
self
}
#[must_use]
pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = Some(prefix.into());
self
}
#[must_use]
pub fn base_name(mut self, name: impl Into<String>) -> Self {
self.base_name = Some(name.into());
self
}
#[must_use]
pub fn cache_enabled(mut self, enabled: bool) -> Self {
self.cache_enabled = Some(enabled);
self
}
#[must_use]
pub fn search_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.search_paths = Some(paths);
self
}
#[must_use]
pub fn search_path(mut self, path: impl Into<PathBuf>) -> Self {
self.search_paths
.get_or_insert_with(Vec::new)
.push(path.into());
self
}
#[must_use]
pub fn remote_timeout_secs(mut self, secs: u64) -> Self {
self.remote_timeout_secs = Some(secs);
self
}
#[must_use]
pub fn build(self) -> LoadOptions {
let defaults = LoadOptions::default();
LoadOptions {
merge_strategy: self.merge_strategy.unwrap_or(defaults.merge_strategy),
fail_fast: self.fail_fast.unwrap_or(defaults.fail_fast),
ignore_optional_missing: self
.ignore_optional_missing
.unwrap_or(defaults.ignore_optional_missing),
validate: self.validate.unwrap_or(defaults.validate),
max_depth: self.max_depth.unwrap_or(defaults.max_depth),
extensions: self.extensions.unwrap_or(defaults.extensions),
env_prefix: self.env_prefix.unwrap_or(defaults.env_prefix),
base_name: self.base_name.unwrap_or(defaults.base_name),
cache_enabled: self.cache_enabled.unwrap_or(defaults.cache_enabled),
search_paths: self.search_paths.unwrap_or(defaults.search_paths),
remote_timeout_secs: self
.remote_timeout_secs
.unwrap_or(defaults.remote_timeout_secs),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_strategy_default() {
let strategy = MergeStrategy::default();
assert_eq!(strategy, MergeStrategy::Deep);
}
#[test]
fn test_merge_strategy_as_str() {
assert_eq!(MergeStrategy::Replace.as_str(), "replace");
assert_eq!(MergeStrategy::Deep.as_str(), "deep");
assert_eq!(MergeStrategy::Shallow.as_str(), "shallow");
assert_eq!(MergeStrategy::Strict.as_str(), "strict");
}
#[test]
fn test_merge_strategy_display() {
assert_eq!(format!("{}", MergeStrategy::Replace), "replace");
}
#[test]
fn test_source_config_new() {
let config = SourceConfig::new("test");
assert_eq!(config.name, "test");
assert!(!config.optional);
assert_eq!(config.priority, 0);
}
#[test]
fn test_source_config_file() {
let config = SourceConfig::file("/etc/config.toml");
assert_eq!(config.name, "file");
assert_eq!(config.path.unwrap().to_str(), Some("/etc/config.toml"));
}
#[test]
fn test_source_config_env() {
let config = SourceConfig::env("MYAPP");
assert_eq!(config.name, "env");
assert_eq!(config.env_prefix.unwrap(), "MYAPP");
}
#[test]
fn test_source_config_remote() {
let config = SourceConfig::remote("https://example.com/config.json");
assert_eq!(config.name, "remote");
assert_eq!(config.url.unwrap(), "https://example.com/config.json");
}
#[test]
fn test_source_config_builder() {
let config = SourceConfig::builder()
.name("custom")
.path("/path/to/config.toml")
.optional(true)
.priority(10)
.format("toml")
.build();
assert_eq!(config.name, "custom");
assert!(config.optional);
assert_eq!(config.priority, 10);
assert_eq!(config.format.unwrap(), "toml");
}
#[test]
fn test_source_config_display_id() {
let config = SourceConfig::file("/etc/config.toml");
assert_eq!(config.display_id(), "file:/etc/config.toml");
let config = SourceConfig::env("APP");
assert_eq!(config.display_id(), "env:APP");
}
#[test]
fn test_source_config_serialization() {
let config = SourceConfig::builder().name("test").optional(true).build();
let json = serde_json::to_string(&config).unwrap();
let decoded: SourceConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, decoded);
}
#[test]
fn test_load_options_default() {
let options = LoadOptions::default();
assert_eq!(options.merge_strategy, MergeStrategy::Deep);
assert!(options.fail_fast);
assert!(options.validate);
assert!(options.cache_enabled);
}
#[test]
fn test_load_options_builder() {
let options = LoadOptions::builder()
.merge_strategy(MergeStrategy::Replace)
.fail_fast(false)
.validate(false)
.max_depth(5)
.env_prefix("MYAPP")
.base_name("settings")
.cache_enabled(false)
.extension("yaml")
.search_path("/etc/myapp")
.remote_timeout_secs(60)
.build();
assert_eq!(options.merge_strategy, MergeStrategy::Replace);
assert!(!options.fail_fast);
assert!(!options.validate);
assert_eq!(options.max_depth, 5);
assert_eq!(options.env_prefix, "MYAPP");
assert_eq!(options.base_name, "settings");
assert!(!options.cache_enabled);
assert!(options.extensions.contains(&"yaml".to_string()));
assert!(options.search_paths.contains(&PathBuf::from("/etc/myapp")));
assert_eq!(options.remote_timeout_secs, 60);
}
#[test]
fn test_load_options_is_cache_enabled() {
let options = LoadOptions::builder().cache_enabled(true).build();
assert!(options.is_cache_enabled());
let options = LoadOptions::builder().cache_enabled(false).build();
assert!(!options.is_cache_enabled());
}
#[test]
fn test_load_options_is_fail_fast() {
let options = LoadOptions::builder().fail_fast(true).build();
assert!(options.is_fail_fast());
let options = LoadOptions::builder().fail_fast(false).build();
assert!(!options.is_fail_fast());
}
#[test]
fn test_load_options_serialization() {
let options = LoadOptions::builder()
.merge_strategy(MergeStrategy::Shallow)
.fail_fast(false)
.build();
let json = serde_json::to_string(&options).unwrap();
let decoded: LoadOptions = serde_json::from_str(&json).unwrap();
assert_eq!(options, decoded);
}
}