Skip to main content

cfgmatic_source/config/
options.rs

1//! Options and configuration types for source loading.
2//!
3//! This module provides builder-based configuration types:
4//!
5//! - [`LoadOptions`] - Options controlling how configuration is loaded
6//! - [`SourceConfig`] - Configuration for a single source
7//! - [`MergeStrategy`] - Strategy for merging multiple sources
8//!
9//! # Example
10//!
11//! ```rust
12//! use cfgmatic_source::config::{LoadOptions, SourceConfig, MergeStrategy};
13//!
14//! let options = LoadOptions::builder()
15//!     .merge_strategy(MergeStrategy::Deep)
16//!     .fail_fast(false)
17//!     .build();
18//!
19//! assert_eq!(options.merge_strategy, MergeStrategy::Deep);
20//! ```
21
22use std::path::PathBuf;
23
24use serde::{Deserialize, Serialize};
25
26use crate::constants::{
27    DEFAULT_CONFIG_BASE_NAME, DEFAULT_ENV_PREFIX, DEFAULT_EXTENSIONS, MAX_SEARCH_DEPTH,
28};
29
30/// Strategy for merging configuration from multiple sources.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum MergeStrategy {
34    /// Replace previous values completely.
35    Replace,
36
37    /// Deep merge objects, replace arrays and scalars.
38    #[default]
39    Deep,
40
41    /// Shallow merge - only top-level keys.
42    Shallow,
43
44    /// Merge with type preservation.
45    Strict,
46}
47
48impl MergeStrategy {
49    /// Get the display name for this strategy.
50    #[must_use]
51    pub const fn as_str(&self) -> &'static str {
52        match self {
53            Self::Replace => "replace",
54            Self::Deep => "deep",
55            Self::Shallow => "shallow",
56            Self::Strict => "strict",
57        }
58    }
59}
60
61impl std::fmt::Display for MergeStrategy {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(f, "{}", self.as_str())
64    }
65}
66
67/// Error handling mode for source loading.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
69#[serde(rename_all = "snake_case")]
70pub enum ErrorMode {
71    /// Stop on the first source error.
72    #[default]
73    FailFast,
74    /// Continue loading and collect non-fatal errors.
75    CollectAll,
76}
77
78impl ErrorMode {
79    /// Create a mode from the legacy boolean flag.
80    #[must_use]
81    pub const fn from_fail_fast(fail_fast: bool) -> Self {
82        if fail_fast {
83            Self::FailFast
84        } else {
85            Self::CollectAll
86        }
87    }
88
89    /// Check whether loading should fail fast.
90    #[must_use]
91    pub const fn is_fail_fast(self) -> bool {
92        matches!(self, Self::FailFast)
93    }
94}
95
96/// Optional-source handling mode.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
98#[serde(rename_all = "snake_case")]
99pub enum OptionalSourceMode {
100    /// Skip missing optional sources.
101    #[default]
102    IgnoreMissing,
103    /// Treat missing optional sources as errors.
104    RequirePresent,
105}
106
107impl OptionalSourceMode {
108    /// Create a mode from the legacy boolean flag.
109    #[must_use]
110    pub const fn from_ignore_missing(ignore_missing: bool) -> Self {
111        if ignore_missing {
112            Self::IgnoreMissing
113        } else {
114            Self::RequirePresent
115        }
116    }
117
118    /// Check whether missing optional sources should be ignored.
119    #[must_use]
120    pub const fn ignores_missing(self) -> bool {
121        matches!(self, Self::IgnoreMissing)
122    }
123}
124
125/// Validation mode for loaded configuration.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum ValidationMode {
129    /// Validate configuration after loading.
130    #[default]
131    Enabled,
132    /// Skip post-load validation.
133    Disabled,
134}
135
136impl ValidationMode {
137    /// Create a mode from the legacy boolean flag.
138    #[must_use]
139    pub const fn from_enabled(enabled: bool) -> Self {
140        if enabled {
141            Self::Enabled
142        } else {
143            Self::Disabled
144        }
145    }
146
147    /// Check whether validation is enabled.
148    #[must_use]
149    pub const fn is_enabled(self) -> bool {
150        matches!(self, Self::Enabled)
151    }
152}
153
154/// Cache mode for loaded configuration content.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
156#[serde(rename_all = "snake_case")]
157pub enum CacheMode {
158    /// Use in-memory caching when available.
159    #[default]
160    Enabled,
161    /// Disable in-memory caching.
162    Disabled,
163}
164
165impl CacheMode {
166    /// Create a mode from the legacy boolean flag.
167    #[must_use]
168    pub const fn from_enabled(enabled: bool) -> Self {
169        if enabled {
170            Self::Enabled
171        } else {
172            Self::Disabled
173        }
174    }
175
176    /// Check whether caching is enabled.
177    #[must_use]
178    pub const fn is_enabled(self) -> bool {
179        matches!(self, Self::Enabled)
180    }
181}
182
183/// Configuration for a single source.
184///
185/// Describes how to load configuration from a specific source.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SourceConfig {
188    /// Name/identifier for this source.
189    pub name: String,
190
191    /// Whether this source is optional.
192    pub optional: bool,
193
194    /// Priority for this source (higher = more important).
195    pub priority: i32,
196
197    /// Whether to cache the loaded content.
198    pub cache: bool,
199
200    /// Format override (auto-detected if None).
201    pub format: Option<String>,
202
203    /// Source-specific path (for file sources).
204    pub path: Option<PathBuf>,
205
206    /// Source-specific URL for custom URL-backed sources.
207    pub url: Option<String>,
208
209    /// Source-specific environment variable prefix.
210    pub env_prefix: Option<String>,
211
212    /// Additional source-specific options.
213    pub extra: std::collections::BTreeMap<String, String>,
214}
215
216impl SourceConfig {
217    /// Create a new source config with the given name.
218    #[must_use]
219    pub fn new(name: impl Into<String>) -> Self {
220        Self {
221            name: name.into(),
222            optional: false,
223            priority: 0,
224            cache: true,
225            format: None,
226            path: None,
227            url: None,
228            env_prefix: None,
229            extra: std::collections::BTreeMap::new(),
230        }
231    }
232
233    /// Create a builder for constructing a `SourceConfig`.
234    #[must_use]
235    pub fn builder() -> SourceConfigBuilder {
236        SourceConfigBuilder::new()
237    }
238
239    /// Create a file source config.
240    #[must_use]
241    pub fn file(path: impl Into<PathBuf>) -> Self {
242        Self::builder().name("file").path(path).build()
243    }
244
245    /// Create an environment source config.
246    #[must_use]
247    pub fn env(prefix: impl Into<String>) -> Self {
248        Self::builder().name("env").env_prefix(prefix).build()
249    }
250
251    /// Check if this is an optional source.
252    #[must_use]
253    pub const fn is_optional(&self) -> bool {
254        self.optional
255    }
256
257    /// Get the display identifier for this source.
258    #[must_use]
259    pub fn display_id(&self) -> String {
260        self.path
261            .as_ref()
262            .map(|path| path.display().to_string())
263            .or_else(|| self.url.clone())
264            .or_else(|| self.env_prefix.clone())
265            .map_or_else(
266                || self.name.clone(),
267                |target| format!("{}:{target}", self.name),
268            )
269    }
270}
271
272impl Default for SourceConfig {
273    fn default() -> Self {
274        Self::new(DEFAULT_CONFIG_BASE_NAME)
275    }
276}
277
278/// Builder for [`SourceConfig`].
279#[derive(Debug, Clone, Default)]
280pub struct SourceConfigBuilder {
281    name: Option<String>,
282    optional: bool,
283    priority: i32,
284    cache: bool,
285    format: Option<String>,
286    path: Option<PathBuf>,
287    url: Option<String>,
288    env_prefix: Option<String>,
289    extra: std::collections::BTreeMap<String, String>,
290}
291
292impl SourceConfigBuilder {
293    /// Create a new builder.
294    #[must_use]
295    pub fn new() -> Self {
296        Self {
297            cache: true,
298            ..Self::default()
299        }
300    }
301
302    /// Set the source name.
303    #[must_use]
304    pub fn name(mut self, name: impl Into<String>) -> Self {
305        self.name = Some(name.into());
306        self
307    }
308
309    /// Set whether this source is optional.
310    #[must_use]
311    pub const fn optional(mut self, optional: bool) -> Self {
312        self.optional = optional;
313        self
314    }
315
316    /// Set the priority.
317    #[must_use]
318    pub const fn priority(mut self, priority: i32) -> Self {
319        self.priority = priority;
320        self
321    }
322
323    /// Set whether to cache loaded content.
324    #[must_use]
325    pub const fn cache(mut self, cache: bool) -> Self {
326        self.cache = cache;
327        self
328    }
329
330    /// Set the format override.
331    #[must_use]
332    pub fn format(mut self, format: impl Into<String>) -> Self {
333        self.format = Some(format.into());
334        self
335    }
336
337    /// Set the file path.
338    #[must_use]
339    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
340        self.path = Some(path.into());
341        self
342    }
343
344    /// Set the URL.
345    #[must_use]
346    pub fn url(mut self, url: impl Into<String>) -> Self {
347        self.url = Some(url.into());
348        self
349    }
350
351    /// Set the environment variable prefix.
352    #[must_use]
353    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
354        self.env_prefix = Some(prefix.into());
355        self
356    }
357
358    /// Add an extra option.
359    #[must_use]
360    pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
361        self.extra.insert(key.into(), value.into());
362        self
363    }
364
365    /// Build the `SourceConfig`.
366    #[must_use]
367    pub fn build(self) -> SourceConfig {
368        SourceConfig {
369            name: self
370                .name
371                .unwrap_or_else(|| DEFAULT_CONFIG_BASE_NAME.to_string()),
372            optional: self.optional,
373            priority: self.priority,
374            cache: self.cache,
375            format: self.format,
376            path: self.path,
377            url: self.url,
378            env_prefix: self.env_prefix,
379            extra: self.extra,
380        }
381    }
382}
383
384/// Options for loading configuration.
385///
386/// Controls how configuration sources are loaded, merged, and processed.
387#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
388pub struct LoadOptions {
389    /// Strategy for merging multiple sources.
390    pub merge_strategy: MergeStrategy,
391
392    /// Error handling mode.
393    pub error_mode: ErrorMode,
394
395    /// Optional-source handling mode.
396    pub optional_source_mode: OptionalSourceMode,
397
398    /// Validation mode for loaded configuration.
399    pub validation_mode: ValidationMode,
400
401    /// Maximum depth for directory traversal.
402    pub max_depth: usize,
403
404    /// File extensions to search.
405    pub extensions: Vec<String>,
406
407    /// Environment variable prefix.
408    pub env_prefix: String,
409
410    /// Base name for configuration files.
411    pub base_name: String,
412
413    /// Cache mode for loaded configuration.
414    pub cache_mode: CacheMode,
415
416    /// Custom search paths.
417    pub search_paths: Vec<PathBuf>,
418}
419
420impl LoadOptions {
421    /// Create a new `LoadOptions` with defaults.
422    #[must_use]
423    pub fn new() -> Self {
424        Self::default()
425    }
426
427    /// Create a builder for constructing `LoadOptions`.
428    #[must_use]
429    pub fn builder() -> LoadOptionsBuilder {
430        LoadOptionsBuilder::new()
431    }
432
433    /// Check if caching is enabled.
434    #[must_use]
435    pub const fn is_cache_enabled(&self) -> bool {
436        self.cache_mode.is_enabled()
437    }
438
439    /// Check if fail-fast mode is enabled.
440    #[must_use]
441    pub const fn is_fail_fast(&self) -> bool {
442        self.error_mode.is_fail_fast()
443    }
444
445    /// Check whether missing optional sources are ignored.
446    #[must_use]
447    pub const fn ignores_missing_optional(&self) -> bool {
448        self.optional_source_mode.ignores_missing()
449    }
450
451    /// Check whether validation is enabled.
452    #[must_use]
453    pub const fn validates(&self) -> bool {
454        self.validation_mode.is_enabled()
455    }
456}
457
458impl Default for LoadOptions {
459    fn default() -> Self {
460        Self {
461            merge_strategy: MergeStrategy::default(),
462            error_mode: ErrorMode::default(),
463            optional_source_mode: OptionalSourceMode::default(),
464            validation_mode: ValidationMode::default(),
465            max_depth: MAX_SEARCH_DEPTH,
466            extensions: DEFAULT_EXTENSIONS.iter().map(ToString::to_string).collect(),
467            env_prefix: DEFAULT_ENV_PREFIX.to_string(),
468            base_name: DEFAULT_CONFIG_BASE_NAME.to_string(),
469            cache_mode: CacheMode::default(),
470            search_paths: Vec::new(),
471        }
472    }
473}
474
475/// Builder for [`LoadOptions`].
476#[derive(Debug, Clone, Default)]
477pub struct LoadOptionsBuilder {
478    merge_strategy: Option<MergeStrategy>,
479    error_mode: Option<ErrorMode>,
480    optional_source_mode: Option<OptionalSourceMode>,
481    validation_mode: Option<ValidationMode>,
482    max_depth: Option<usize>,
483    extensions: Option<Vec<String>>,
484    env_prefix: Option<String>,
485    base_name: Option<String>,
486    cache_mode: Option<CacheMode>,
487    search_paths: Option<Vec<PathBuf>>,
488}
489
490impl LoadOptionsBuilder {
491    /// Create a new builder.
492    #[must_use]
493    pub fn new() -> Self {
494        Self::default()
495    }
496
497    /// Set the merge strategy.
498    #[must_use]
499    pub const fn merge_strategy(mut self, strategy: MergeStrategy) -> Self {
500        self.merge_strategy = Some(strategy);
501        self
502    }
503
504    /// Set whether to stop on first error.
505    #[must_use]
506    pub const fn fail_fast(mut self, fail_fast: bool) -> Self {
507        self.error_mode = Some(ErrorMode::from_fail_fast(fail_fast));
508        self
509    }
510
511    /// Set whether to ignore missing optional sources.
512    #[must_use]
513    pub const fn ignore_optional_missing(mut self, ignore: bool) -> Self {
514        self.optional_source_mode = Some(OptionalSourceMode::from_ignore_missing(ignore));
515        self
516    }
517
518    /// Set whether to validate configuration.
519    #[must_use]
520    pub const fn validate(mut self, validate: bool) -> Self {
521        self.validation_mode = Some(ValidationMode::from_enabled(validate));
522        self
523    }
524
525    /// Set the maximum search depth.
526    #[must_use]
527    pub const fn max_depth(mut self, depth: usize) -> Self {
528        self.max_depth = Some(depth);
529        self
530    }
531
532    /// Set the file extensions to search.
533    #[must_use]
534    pub fn extensions(mut self, extensions: Vec<String>) -> Self {
535        self.extensions = Some(extensions);
536        self
537    }
538
539    /// Add a file extension.
540    #[must_use]
541    pub fn extension(mut self, ext: impl Into<String>) -> Self {
542        self.extensions
543            .get_or_insert_with(Vec::new)
544            .push(ext.into());
545        self
546    }
547
548    /// Set the environment variable prefix.
549    #[must_use]
550    pub fn env_prefix(mut self, prefix: impl Into<String>) -> Self {
551        self.env_prefix = Some(prefix.into());
552        self
553    }
554
555    /// Set the base name for configuration files.
556    #[must_use]
557    pub fn base_name(mut self, name: impl Into<String>) -> Self {
558        self.base_name = Some(name.into());
559        self
560    }
561
562    /// Set whether to enable caching.
563    #[must_use]
564    pub const fn cache_enabled(mut self, enabled: bool) -> Self {
565        self.cache_mode = Some(CacheMode::from_enabled(enabled));
566        self
567    }
568
569    /// Set the search paths.
570    #[must_use]
571    pub fn search_paths(mut self, paths: Vec<PathBuf>) -> Self {
572        self.search_paths = Some(paths);
573        self
574    }
575
576    /// Add a search path.
577    #[must_use]
578    pub fn search_path(mut self, path: impl Into<PathBuf>) -> Self {
579        self.search_paths
580            .get_or_insert_with(Vec::new)
581            .push(path.into());
582        self
583    }
584
585    /// Build the `LoadOptions`.
586    #[must_use]
587    pub fn build(self) -> LoadOptions {
588        let defaults = LoadOptions::default();
589
590        LoadOptions {
591            merge_strategy: self.merge_strategy.unwrap_or(defaults.merge_strategy),
592            error_mode: self.error_mode.unwrap_or(defaults.error_mode),
593            optional_source_mode: self
594                .optional_source_mode
595                .unwrap_or(defaults.optional_source_mode),
596            validation_mode: self.validation_mode.unwrap_or(defaults.validation_mode),
597            max_depth: self.max_depth.unwrap_or(defaults.max_depth),
598            extensions: self.extensions.unwrap_or(defaults.extensions),
599            env_prefix: self.env_prefix.unwrap_or(defaults.env_prefix),
600            base_name: self.base_name.unwrap_or(defaults.base_name),
601            cache_mode: self.cache_mode.unwrap_or(defaults.cache_mode),
602            search_paths: self.search_paths.unwrap_or(defaults.search_paths),
603        }
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_merge_strategy_default() {
613        let strategy = MergeStrategy::default();
614        assert_eq!(strategy, MergeStrategy::Deep);
615    }
616
617    #[test]
618    fn test_merge_strategy_as_str() {
619        assert_eq!(MergeStrategy::Replace.as_str(), "replace");
620        assert_eq!(MergeStrategy::Deep.as_str(), "deep");
621        assert_eq!(MergeStrategy::Shallow.as_str(), "shallow");
622        assert_eq!(MergeStrategy::Strict.as_str(), "strict");
623    }
624
625    #[test]
626    fn test_merge_strategy_display() {
627        assert_eq!(format!("{}", MergeStrategy::Replace), "replace");
628    }
629
630    #[test]
631    fn test_source_config_new() {
632        let config = SourceConfig::new("test");
633        assert_eq!(config.name, "test");
634        assert!(!config.optional);
635        assert_eq!(config.priority, 0);
636    }
637
638    #[test]
639    fn test_source_config_file() {
640        let config = SourceConfig::file("/etc/config.toml");
641        assert_eq!(config.name, "file");
642        assert_eq!(config.path.unwrap().to_str(), Some("/etc/config.toml"));
643    }
644
645    #[test]
646    fn test_source_config_env() {
647        let config = SourceConfig::env("MYAPP");
648        assert_eq!(config.name, "env");
649        assert_eq!(config.env_prefix.unwrap(), "MYAPP");
650    }
651
652    #[test]
653    fn test_source_config_url_builder() {
654        let config = SourceConfig::builder()
655            .name("custom-url")
656            .url("https://example.com/config.json")
657            .build();
658        assert_eq!(config.name, "custom-url");
659        assert_eq!(config.url.unwrap(), "https://example.com/config.json");
660    }
661
662    #[test]
663    fn test_source_config_builder() {
664        let config = SourceConfig::builder()
665            .name("custom")
666            .path("/path/to/config.toml")
667            .optional(true)
668            .priority(10)
669            .format("toml")
670            .build();
671
672        assert_eq!(config.name, "custom");
673        assert!(config.optional);
674        assert_eq!(config.priority, 10);
675        assert_eq!(config.format.unwrap(), "toml");
676    }
677
678    #[test]
679    fn test_source_config_display_id() {
680        let config = SourceConfig::file("/etc/config.toml");
681        assert_eq!(config.display_id(), "file:/etc/config.toml");
682
683        let config = SourceConfig::env("APP");
684        assert_eq!(config.display_id(), "env:APP");
685    }
686
687    #[test]
688    fn test_source_config_serialization() {
689        let config = SourceConfig::builder().name("test").optional(true).build();
690
691        let json = serde_json::to_string(&config).unwrap();
692        let decoded: SourceConfig = serde_json::from_str(&json).unwrap();
693        assert_eq!(config, decoded);
694    }
695
696    #[test]
697    fn test_load_options_default() {
698        let options = LoadOptions::default();
699        assert_eq!(options.merge_strategy, MergeStrategy::Deep);
700        assert!(options.is_fail_fast());
701        assert!(options.validates());
702        assert!(options.is_cache_enabled());
703    }
704
705    #[test]
706    fn test_load_options_builder() {
707        let options = LoadOptions::builder()
708            .merge_strategy(MergeStrategy::Replace)
709            .fail_fast(false)
710            .validate(false)
711            .max_depth(5)
712            .env_prefix("MYAPP")
713            .base_name("settings")
714            .cache_enabled(false)
715            .extension("yaml")
716            .search_path("/etc/myapp")
717            .build();
718
719        assert_eq!(options.merge_strategy, MergeStrategy::Replace);
720        assert!(!options.is_fail_fast());
721        assert!(!options.validates());
722        assert_eq!(options.max_depth, 5);
723        assert_eq!(options.env_prefix, "MYAPP");
724        assert_eq!(options.base_name, "settings");
725        assert!(!options.is_cache_enabled());
726        assert!(options.extensions.contains(&"yaml".to_string()));
727        assert!(options.search_paths.contains(&PathBuf::from("/etc/myapp")));
728    }
729
730    #[test]
731    fn test_load_options_is_cache_enabled() {
732        let options = LoadOptions::builder().cache_enabled(true).build();
733        assert!(options.is_cache_enabled());
734
735        let options = LoadOptions::builder().cache_enabled(false).build();
736        assert!(!options.is_cache_enabled());
737    }
738
739    #[test]
740    fn test_load_options_is_fail_fast() {
741        let options = LoadOptions::builder().fail_fast(true).build();
742        assert!(options.is_fail_fast());
743
744        let options = LoadOptions::builder().fail_fast(false).build();
745        assert!(!options.is_fail_fast());
746    }
747
748    #[test]
749    fn test_load_options_serialization() {
750        let options = LoadOptions::builder()
751            .merge_strategy(MergeStrategy::Shallow)
752            .fail_fast(false)
753            .build();
754
755        let json = serde_json::to_string(&options).unwrap();
756        let decoded: LoadOptions = serde_json::from_str(&json).unwrap();
757        assert_eq!(options, decoded);
758    }
759}