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