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, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ErrorMode {
#[default]
FailFast,
CollectAll,
}
impl ErrorMode {
#[must_use]
pub const fn from_fail_fast(fail_fast: bool) -> Self {
if fail_fast {
Self::FailFast
} else {
Self::CollectAll
}
}
#[must_use]
pub const fn is_fail_fast(self) -> bool {
matches!(self, Self::FailFast)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum OptionalSourceMode {
#[default]
IgnoreMissing,
RequirePresent,
}
impl OptionalSourceMode {
#[must_use]
pub const fn from_ignore_missing(ignore_missing: bool) -> Self {
if ignore_missing {
Self::IgnoreMissing
} else {
Self::RequirePresent
}
}
#[must_use]
pub const fn ignores_missing(self) -> bool {
matches!(self, Self::IgnoreMissing)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ValidationMode {
#[default]
Enabled,
Disabled,
}
impl ValidationMode {
#[must_use]
pub const fn from_enabled(enabled: bool) -> Self {
if enabled {
Self::Enabled
} else {
Self::Disabled
}
}
#[must_use]
pub const fn is_enabled(self) -> bool {
matches!(self, Self::Enabled)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CacheMode {
#[default]
Enabled,
Disabled,
}
impl CacheMode {
#[must_use]
pub const fn from_enabled(enabled: bool) -> Self {
if enabled {
Self::Enabled
} else {
Self::Disabled
}
}
#[must_use]
pub const fn is_enabled(self) -> bool {
matches!(self, Self::Enabled)
}
}
#[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 const fn is_optional(&self) -> bool {
self.optional
}
#[must_use]
pub fn display_id(&self) -> String {
self.path
.as_ref()
.map(|path| path.display().to_string())
.or_else(|| self.url.clone())
.or_else(|| self.env_prefix.clone())
.map_or_else(
|| self.name.clone(),
|target| format!("{}:{target}", self.name),
)
}
}
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 const fn optional(mut self, optional: bool) -> Self {
self.optional = optional;
self
}
#[must_use]
pub const fn priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
#[must_use]
pub const 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 error_mode: ErrorMode,
pub optional_source_mode: OptionalSourceMode,
pub validation_mode: ValidationMode,
pub max_depth: usize,
pub extensions: Vec<String>,
pub env_prefix: String,
pub base_name: String,
pub cache_mode: CacheMode,
pub search_paths: Vec<PathBuf>,
}
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_mode.is_enabled()
}
#[must_use]
pub const fn is_fail_fast(&self) -> bool {
self.error_mode.is_fail_fast()
}
#[must_use]
pub const fn ignores_missing_optional(&self) -> bool {
self.optional_source_mode.ignores_missing()
}
#[must_use]
pub const fn validates(&self) -> bool {
self.validation_mode.is_enabled()
}
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
merge_strategy: MergeStrategy::default(),
error_mode: ErrorMode::default(),
optional_source_mode: OptionalSourceMode::default(),
validation_mode: ValidationMode::default(),
max_depth: MAX_SEARCH_DEPTH,
extensions: DEFAULT_EXTENSIONS.iter().map(ToString::to_string).collect(),
env_prefix: DEFAULT_ENV_PREFIX.to_string(),
base_name: DEFAULT_CONFIG_BASE_NAME.to_string(),
cache_mode: CacheMode::default(),
search_paths: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LoadOptionsBuilder {
merge_strategy: Option<MergeStrategy>,
error_mode: Option<ErrorMode>,
optional_source_mode: Option<OptionalSourceMode>,
validation_mode: Option<ValidationMode>,
max_depth: Option<usize>,
extensions: Option<Vec<String>>,
env_prefix: Option<String>,
base_name: Option<String>,
cache_mode: Option<CacheMode>,
search_paths: Option<Vec<PathBuf>>,
}
impl LoadOptionsBuilder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn merge_strategy(mut self, strategy: MergeStrategy) -> Self {
self.merge_strategy = Some(strategy);
self
}
#[must_use]
pub const fn fail_fast(mut self, fail_fast: bool) -> Self {
self.error_mode = Some(ErrorMode::from_fail_fast(fail_fast));
self
}
#[must_use]
pub const fn ignore_optional_missing(mut self, ignore: bool) -> Self {
self.optional_source_mode = Some(OptionalSourceMode::from_ignore_missing(ignore));
self
}
#[must_use]
pub const fn validate(mut self, validate: bool) -> Self {
self.validation_mode = Some(ValidationMode::from_enabled(validate));
self
}
#[must_use]
pub const 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 const fn cache_enabled(mut self, enabled: bool) -> Self {
self.cache_mode = Some(CacheMode::from_enabled(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 build(self) -> LoadOptions {
let defaults = LoadOptions::default();
LoadOptions {
merge_strategy: self.merge_strategy.unwrap_or(defaults.merge_strategy),
error_mode: self.error_mode.unwrap_or(defaults.error_mode),
optional_source_mode: self
.optional_source_mode
.unwrap_or(defaults.optional_source_mode),
validation_mode: self.validation_mode.unwrap_or(defaults.validation_mode),
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_mode: self.cache_mode.unwrap_or(defaults.cache_mode),
search_paths: self.search_paths.unwrap_or(defaults.search_paths),
}
}
}
#[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_url_builder() {
let config = SourceConfig::builder()
.name("custom-url")
.url("https://example.com/config.json")
.build();
assert_eq!(config.name, "custom-url");
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.is_fail_fast());
assert!(options.validates());
assert!(options.is_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")
.build();
assert_eq!(options.merge_strategy, MergeStrategy::Replace);
assert!(!options.is_fail_fast());
assert!(!options.validates());
assert_eq!(options.max_depth, 5);
assert_eq!(options.env_prefix, "MYAPP");
assert_eq!(options.base_name, "settings");
assert!(!options.is_cache_enabled());
assert!(options.extensions.contains(&"yaml".to_string()));
assert!(options.search_paths.contains(&PathBuf::from("/etc/myapp")));
}
#[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);
}
}