Skip to main content

rskit_config/source/
pipeline.rs

1//! Ordered-source merge engine: defaults → files → env → overrides.
2
3use std::path::PathBuf;
4
5use rskit_errors::{AppError, AppResult};
6use serde::de::DeserializeOwned;
7
8use super::contract::ConfigSource;
9use super::dotenv::{self, DotenvFileSource, Profile};
10use super::env::EnvironmentSource;
11use super::toml::TomlFileSource;
12use crate::typed::decode;
13
14#[cfg(feature = "validate")]
15use crate::AppConfig;
16#[cfg(feature = "validate")]
17use rskit_validation::Validate;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum LoaderPolicy {
21    App,
22    Toml,
23    Custom,
24}
25
26/// Loads typed configuration from ordered source adapters.
27///
28/// `ConfigLoader::app()` / `ConfigLoader::new()` keeps the service-oriented
29/// policy: defaults → TOML → profile dotenv → dotenv → adapter sources → env →
30/// overrides. `ConfigLoader::toml(path)` is deterministic file-only loading.
31/// `ConfigLoader::custom()` only loads explicitly provided sources.
32#[derive(Debug)]
33pub struct ConfigLoader {
34    policy: LoaderPolicy,
35    defaults: Vec<(String, config::Value)>,
36    config_file: Option<PathBuf>,
37    env_file: Option<PathBuf>,
38    env_prefix: String,
39    profile: Option<Profile>,
40    sources: Vec<Box<dyn ConfigSource>>,
41    overrides: Vec<(String, config::Value)>,
42}
43
44impl Default for ConfigLoader {
45    fn default() -> Self {
46        Self::app()
47    }
48}
49
50impl ConfigLoader {
51    /// Create an application/service loader.
52    pub fn new() -> Self {
53        Self::app()
54    }
55
56    /// Create an application/service loader.
57    pub fn app() -> Self {
58        Self {
59            policy: LoaderPolicy::App,
60            defaults: Vec::new(),
61            config_file: None,
62            env_file: None,
63            env_prefix: String::new(),
64            profile: None,
65            sources: Vec::new(),
66            overrides: Vec::new(),
67        }
68    }
69
70    /// Create a deterministic single-TOML loader.
71    ///
72    /// This policy does not read dotenv files or process environment variables.
73    pub fn toml(path: impl Into<PathBuf>) -> Self {
74        Self {
75            policy: LoaderPolicy::Toml,
76            defaults: Vec::new(),
77            config_file: Some(path.into()),
78            env_file: None,
79            env_prefix: String::new(),
80            profile: None,
81            sources: Vec::new(),
82            overrides: Vec::new(),
83        }
84    }
85
86    /// Create a loader with no implicit sources.
87    pub fn custom() -> Self {
88        Self {
89            policy: LoaderPolicy::Custom,
90            defaults: Vec::new(),
91            config_file: None,
92            env_file: None,
93            env_prefix: String::new(),
94            profile: None,
95            sources: Vec::new(),
96            overrides: Vec::new(),
97        }
98    }
99
100    /// Set a programmatic default value.
101    ///
102    /// Defaults are loaded before all configured sources.
103    #[must_use]
104    pub fn with_default(mut self, key: impl Into<String>, value: impl Into<config::Value>) -> Self {
105        self.defaults.push((key.into(), value.into()));
106        self
107    }
108
109    /// Explicitly set the TOML config file path for app loading.
110    #[must_use]
111    pub fn with_config_file(mut self, path: impl Into<PathBuf>) -> Self {
112        self.config_file = Some(path.into());
113        self
114    }
115
116    /// Explicitly set the `.env` file path for app loading.
117    #[must_use]
118    pub fn with_env_file(mut self, path: impl Into<PathBuf>) -> Self {
119        self.env_file = Some(path.into());
120        self
121    }
122
123    /// Override the env-var prefix for app loading.
124    ///
125    /// Separator is always `"__"`.
126    #[must_use]
127    pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
128        self.env_prefix = prefix.into();
129        self
130    }
131
132    /// Set the configuration profile for app loading.
133    ///
134    /// Loads `config/profiles/{profile}.env` before the main `.env` file. If an
135    /// empty string is passed, the profile name is read from the `ENVIRONMENT`
136    /// environment variable during loading.
137    #[must_use]
138    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
139        let profile = profile.into();
140        self.profile = Some(if profile.is_empty() {
141            Profile::FromEnvironment
142        } else {
143            Profile::Name(profile)
144        });
145        self
146    }
147
148    /// Add an adapter source.
149    ///
150    /// In app loading, adapter sources are evaluated after files/dotenv and
151    /// before process environment variables. In custom/TOML loading, adapter
152    /// sources are evaluated after the primary file and before overrides.
153    #[must_use]
154    pub fn with_source(mut self, source: impl ConfigSource) -> Self {
155        self.sources.push(Box::new(source));
156        self
157    }
158
159    /// Set a programmatic override value.
160    ///
161    /// Overrides are loaded after all sources and have the highest precedence.
162    #[must_use]
163    pub fn with_override(
164        mut self,
165        key: impl Into<String>,
166        value: impl Into<config::Value>,
167    ) -> Self {
168        self.overrides.push((key.into(), value.into()));
169        self
170    }
171
172    /// Load any typed config from this loader's source policy.
173    ///
174    /// This path does not run validation; the type only needs to be
175    /// `Deserialize`. Use [`ConfigLoader::load_validated`] to additionally run
176    /// [`rskit_validation::Validate`], or [`ConfigLoader::load_app`] for the
177    /// service-application convenience.
178    pub fn load<T>(&self) -> AppResult<T>
179    where
180        T: DeserializeOwned,
181    {
182        self.load_with(|_| {})
183    }
184
185    /// Load any typed config and apply programmatic defaults (no validation).
186    pub fn load_with<T>(&self, apply_defaults: impl FnOnce(&mut T)) -> AppResult<T>
187    where
188        T: DeserializeOwned,
189    {
190        decode::decode_typed(self.collect()?, apply_defaults)
191    }
192
193    /// Load a typed config and run [`rskit_validation::Validate`] after decoding.
194    #[cfg(feature = "validate")]
195    pub fn load_validated<T>(&self) -> AppResult<T>
196    where
197        T: DeserializeOwned + Validate,
198    {
199        self.load_validated_with(|_| {})
200    }
201
202    /// Load a typed config, apply programmatic defaults, then run validation.
203    #[cfg(feature = "validate")]
204    pub fn load_validated_with<T>(&self, apply_defaults: impl FnOnce(&mut T)) -> AppResult<T>
205    where
206        T: DeserializeOwned + Validate,
207    {
208        decode::decode_validated(self.collect()?, apply_defaults)
209    }
210
211    /// Load an application/service config and call [`AppConfig::apply_defaults`].
212    ///
213    /// Applies defaults via [`AppConfig::apply_defaults`], then runs validation.
214    #[cfg(feature = "validate")]
215    pub fn load_app<T>(&self) -> AppResult<T>
216    where
217        T: AppConfig,
218    {
219        self.load_validated_with(T::apply_defaults)
220    }
221
222    fn collect(&self) -> AppResult<config::Config> {
223        let mut builder = config::Config::builder();
224
225        for (key, value) in &self.defaults {
226            builder = builder
227                .set_default(key, value.clone())
228                .map_err(|e| AppError::invalid_input("config", e.to_string()))?;
229        }
230
231        for source in self.policy_sources()? {
232            builder = builder.add_source(source.collect()?);
233        }
234
235        for source in &self.sources {
236            builder = builder.add_source(source.collect()?);
237        }
238
239        if self.policy == LoaderPolicy::App {
240            builder =
241                builder.add_source(EnvironmentSource::with_prefix(&self.env_prefix).collect()?);
242        }
243
244        for (key, value) in &self.overrides {
245            builder = builder
246                .set_override(key, value.clone())
247                .map_err(|e| AppError::invalid_input("config", e.to_string()))?;
248        }
249
250        builder
251            .build()
252            .map_err(|e| AppError::invalid_input("config", e.to_string()))
253    }
254
255    fn policy_sources(&self) -> AppResult<Vec<Box<dyn ConfigSource>>> {
256        match self.policy {
257            LoaderPolicy::App => self.app_policy_sources(),
258            LoaderPolicy::Toml => {
259                let path = self.config_file.as_ref().ok_or_else(|| {
260                    AppError::invalid_input("config", "TOML loader requires a config file")
261                })?;
262                Ok(vec![Box::new(TomlFileSource::required(path.clone()))])
263            }
264            LoaderPolicy::Custom => Ok(Vec::new()),
265        }
266    }
267
268    fn app_policy_sources(&self) -> AppResult<Vec<Box<dyn ConfigSource>>> {
269        let mut sources: Vec<Box<dyn ConfigSource>> = Vec::new();
270
271        if let Some(path) = &self.config_file {
272            sources.push(Box::new(TomlFileSource::optional(path.clone())));
273        } else {
274            sources.push(Box::new(TomlFileSource::optional("config.toml")));
275            sources.push(Box::new(TomlFileSource::optional("config/config.toml")));
276        }
277
278        if let Some(profile) = &self.profile {
279            let profile_name = profile.resolve()?;
280            let path = dotenv::find_profile_env_file(profile_name.as_ref()).ok_or_else(|| {
281                AppError::invalid_input(
282                    "config",
283                    format!("profile env file not found for profile '{profile_name}'"),
284                )
285            })?;
286            sources.push(Box::new(DotenvFileSource::profile(
287                path,
288                self.env_prefix.clone(),
289            )));
290        }
291
292        if let Some(path) = &self.env_file {
293            sources.push(Box::new(DotenvFileSource::required(
294                path.clone(),
295                self.env_prefix.clone(),
296            )));
297        } else if let Some(path) = dotenv::find_default_env_file() {
298            sources.push(Box::new(DotenvFileSource::auto_discovered(
299                path,
300                self.env_prefix.clone(),
301            )));
302        }
303
304        Ok(sources)
305    }
306}
307
308/// Convenience function: create a default app loader and call [`ConfigLoader::load_app`].
309#[cfg(feature = "validate")]
310pub fn load_config<T: AppConfig>() -> AppResult<T> {
311    ConfigLoader::app().load_app()
312}