Skip to main content

rskit_config/loader/
mod.rs

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