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 policy:
29/// defaults → TOML → profile dotenv → dotenv → adapter sources → env → overrides.
30/// `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 empty string is passed,
135    /// the profile name is read from the `ENVIRONMENT` environment variable during loading.
136    #[must_use]
137    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
138        let profile = profile.into();
139        self.profile = Some(if profile.is_empty() {
140            Profile::FromEnvironment
141        } else {
142            Profile::Name(profile)
143        });
144        self
145    }
146
147    /// Add an adapter source.
148    ///
149    /// In app loading, adapter sources are evaluated after files/dotenv
150    /// and before process environment variables. In custom/TOML loading,
151    /// adapter sources are evaluated after the primary file and before overrides.
152    #[must_use]
153    pub fn with_source(mut self, source: impl ConfigSource) -> Self {
154        self.sources.push(Box::new(source));
155        self
156    }
157
158    /// Set a programmatic override value.
159    ///
160    /// Overrides are loaded after all sources and have the highest precedence.
161    #[must_use]
162    pub fn with_override(
163        mut self,
164        key: impl Into<String>,
165        value: impl Into<config::Value>,
166    ) -> Self {
167        self.overrides.push((key.into(), value.into()));
168        self
169    }
170
171    /// Load any typed config from this loader's source policy.
172    ///
173    /// This path does not run validation; the type only needs to be `Deserialize`.
174    /// Use [`ConfigLoader::load_validated`] to additionally run [`rskit_validation::Validate`],
175    /// or [`ConfigLoader::load_app`] for the service-application convenience.
176    pub fn load<T>(&self) -> AppResult<T>
177    where
178        T: DeserializeOwned,
179    {
180        self.load_with(|_| {})
181    }
182
183    /// Load any typed config and apply programmatic defaults (no validation).
184    pub fn load_with<T>(&self, apply_defaults: impl FnOnce(&mut T)) -> AppResult<T>
185    where
186        T: DeserializeOwned,
187    {
188        decode::decode_typed(self.collect()?, apply_defaults)
189    }
190
191    /// Load a typed config and run [`rskit_validation::Validate`] after decoding.
192    #[cfg(feature = "validate")]
193    pub fn load_validated<T>(&self) -> AppResult<T>
194    where
195        T: DeserializeOwned + Validate,
196    {
197        self.load_validated_with(|_| {})
198    }
199
200    /// Load a typed config, apply programmatic defaults, then run validation.
201    #[cfg(feature = "validate")]
202    pub fn load_validated_with<T>(&self, apply_defaults: impl FnOnce(&mut T)) -> AppResult<T>
203    where
204        T: DeserializeOwned + Validate,
205    {
206        decode::decode_validated(self.collect()?, apply_defaults)
207    }
208
209    /// Load an application/service config and call [`AppConfig::apply_defaults`].
210    ///
211    /// Applies defaults via [`AppConfig::apply_defaults`], then runs validation.
212    #[cfg(feature = "validate")]
213    pub fn load_app<T>(&self) -> AppResult<T>
214    where
215        T: AppConfig,
216    {
217        self.load_validated_with(T::apply_defaults)
218    }
219
220    fn collect(&self) -> AppResult<config::Config> {
221        let mut builder = config::Config::builder();
222
223        for (key, value) in &self.defaults {
224            builder = builder
225                .set_default(key, value.clone())
226                .map_err(|e| AppError::invalid_input("config", e.to_string()))?;
227        }
228
229        for source in self.policy_sources()? {
230            builder = builder.add_source(source.collect()?);
231        }
232
233        for source in &self.sources {
234            builder = builder.add_source(source.collect()?);
235        }
236
237        if self.policy == LoaderPolicy::App {
238            builder =
239                builder.add_source(EnvironmentSource::with_prefix(&self.env_prefix).collect()?);
240        }
241
242        for (key, value) in &self.overrides {
243            builder = builder
244                .set_override(key, value.clone())
245                .map_err(|e| AppError::invalid_input("config", e.to_string()))?;
246        }
247
248        builder
249            .build()
250            .map_err(|e| AppError::invalid_input("config", e.to_string()))
251    }
252
253    fn policy_sources(&self) -> AppResult<Vec<Box<dyn ConfigSource>>> {
254        match self.policy {
255            LoaderPolicy::App => self.app_policy_sources(),
256            LoaderPolicy::Toml => {
257                let path = self.config_file.as_ref().ok_or_else(|| {
258                    AppError::invalid_input("config", "TOML loader requires a config file")
259                })?;
260                Ok(vec![Box::new(TomlFileSource::required(path.clone()))])
261            }
262            LoaderPolicy::Custom => Ok(Vec::new()),
263        }
264    }
265
266    fn app_policy_sources(&self) -> AppResult<Vec<Box<dyn ConfigSource>>> {
267        let mut sources: Vec<Box<dyn ConfigSource>> = Vec::new();
268
269        if let Some(path) = &self.config_file {
270            sources.push(Box::new(TomlFileSource::optional(path.clone())));
271        } else {
272            sources.push(Box::new(TomlFileSource::optional("config.toml")));
273            sources.push(Box::new(TomlFileSource::optional("config/config.toml")));
274        }
275
276        if let Some(profile) = &self.profile {
277            let profile_name = profile.resolve()?;
278            let path = dotenv::find_profile_env_file(profile_name.as_ref()).ok_or_else(|| {
279                AppError::invalid_input(
280                    "config",
281                    format!("profile env file not found for profile '{profile_name}'"),
282                )
283            })?;
284            sources.push(Box::new(DotenvFileSource::profile(
285                path,
286                self.env_prefix.clone(),
287            )));
288        }
289
290        if let Some(path) = &self.env_file {
291            sources.push(Box::new(DotenvFileSource::required(
292                path.clone(),
293                self.env_prefix.clone(),
294            )));
295        } else if let Some(path) = dotenv::find_default_env_file() {
296            sources.push(Box::new(DotenvFileSource::auto_discovered(
297                path,
298                self.env_prefix.clone(),
299            )));
300        }
301
302        Ok(sources)
303    }
304}
305
306/// Convenience function: create a default app loader and call [`ConfigLoader::load_app`].
307#[cfg(feature = "validate")]
308pub fn load_config<T: AppConfig>() -> AppResult<T> {
309    ConfigLoader::app().load_app()
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn policy_sources_cover_toml_errors_and_app_env_paths() {
318        let missing_toml = ConfigLoader {
319            policy: LoaderPolicy::Toml,
320            defaults: Vec::new(),
321            config_file: None,
322            env_file: None,
323            env_prefix: String::new(),
324            profile: None,
325            sources: Vec::new(),
326            overrides: Vec::new(),
327        };
328        assert!(missing_toml.policy_sources().is_err());
329
330        let dir = tempfile::tempdir().unwrap();
331        let config_file = dir.path().join("config.toml");
332        let env_file = dir.path().join(".env");
333        std::fs::write(&config_file, "service.name = 'unit'").unwrap();
334        std::fs::write(&env_file, "SERVICE__PORT=8080").unwrap();
335        let sources = ConfigLoader::app()
336            .with_config_file(&config_file)
337            .with_env_file(&env_file)
338            .app_policy_sources()
339            .unwrap();
340        assert_eq!(sources.len(), 2);
341
342        let err = ConfigLoader::app()
343            .with_profile("missing-profile")
344            .app_policy_sources()
345            .unwrap_err();
346        assert!(err.to_string().contains("profile env file not found"));
347    }
348}