1use 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#[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 pub fn new() -> Self {
53 Self::app()
54 }
55
56 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 pub fn load<T>(&self) -> AppResult<T>
177 where
178 T: DeserializeOwned,
179 {
180 self.load_with(|_| {})
181 }
182
183 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 #[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 #[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 #[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#[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}