1use std::path::PathBuf;
2
3use crate::config::{
4 ConfigError, ConfigLayer, ConfigResolver, ConfigSchema, ConfigValue, ResolveOptions,
5 ResolvedConfig, core::parse_env_key, store::validate_secrets_permissions, with_path_context,
6};
7
8pub trait ConfigLoader: Send + Sync {
10 fn load(&self) -> Result<ConfigLayer, ConfigError>;
12}
13
14fn collect_string_pairs<I, K, V>(vars: I) -> Vec<(String, String)>
15where
16 I: IntoIterator<Item = (K, V)>,
17 K: AsRef<str>,
18 V: AsRef<str>,
19{
20 vars.into_iter()
21 .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
22 .collect()
23}
24
25#[derive(Debug, Clone, Default)]
27pub struct StaticLayerLoader {
28 layer: ConfigLayer,
29}
30
31impl StaticLayerLoader {
32 pub fn new(layer: ConfigLayer) -> Self {
44 Self { layer }
45 }
46}
47
48impl ConfigLoader for StaticLayerLoader {
49 fn load(&self) -> Result<ConfigLayer, ConfigError> {
50 tracing::trace!(
51 entries = self.layer.entries().len(),
52 "loaded static config layer"
53 );
54 Ok(self.layer.clone())
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct TomlFileLoader {
61 path: PathBuf,
62 missing_ok: bool,
63}
64
65impl TomlFileLoader {
66 pub fn new(path: PathBuf) -> Self {
68 Self {
69 path,
70 missing_ok: true,
71 }
72 }
73
74 pub fn required(mut self) -> Self {
76 self.missing_ok = false;
77 self
78 }
79
80 pub fn optional(mut self) -> Self {
82 self.missing_ok = true;
83 self
84 }
85}
86
87impl ConfigLoader for TomlFileLoader {
88 fn load(&self) -> Result<ConfigLayer, ConfigError> {
89 tracing::debug!(
90 path = %self.path.display(),
91 missing_ok = self.missing_ok,
92 "loading TOML config layer"
93 );
94 if !self.path.exists() {
95 if self.missing_ok {
96 tracing::debug!(path = %self.path.display(), "optional TOML config file missing");
97 return Ok(ConfigLayer::default());
98 }
99 return Err(ConfigError::FileRead {
100 path: self.path.display().to_string(),
101 reason: "file not found".to_string(),
102 });
103 }
104
105 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
106 path: self.path.display().to_string(),
107 reason: err.to_string(),
108 })?;
109
110 let mut layer = ConfigLayer::from_toml_str(&raw)
111 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
112 let origin = self.path.display().to_string();
113 for entry in &mut layer.entries {
114 entry.origin = Some(origin.clone());
115 }
116 tracing::debug!(
117 path = %self.path.display(),
118 entries = layer.entries().len(),
119 "loaded TOML config layer"
120 );
121 Ok(layer)
122 }
123}
124
125#[derive(Debug, Clone, Default)]
127pub struct EnvVarLoader {
128 vars: Vec<(String, String)>,
129}
130
131impl EnvVarLoader {
132 pub fn from_process_env() -> Self {
134 Self {
135 vars: std::env::vars().collect(),
136 }
137 }
138
139 pub fn from_pairs<I, K, V>(vars: I) -> Self
152 where
153 I: IntoIterator<Item = (K, V)>,
154 K: AsRef<str>,
155 V: AsRef<str>,
156 {
157 Self {
158 vars: collect_string_pairs(vars),
159 }
160 }
161}
162
163impl<K, V> std::iter::FromIterator<(K, V)> for EnvVarLoader
164where
165 K: AsRef<str>,
166 V: AsRef<str>,
167{
168 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
169 Self {
170 vars: collect_string_pairs(iter),
171 }
172 }
173}
174
175impl ConfigLoader for EnvVarLoader {
176 fn load(&self) -> Result<ConfigLayer, ConfigError> {
177 let layer =
178 ConfigLayer::from_env_iter(self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
179 tracing::debug!(
180 input_vars = self.vars.len(),
181 entries = layer.entries().len(),
182 "loaded environment config layer"
183 );
184 Ok(layer)
185 }
186}
187
188#[derive(Debug, Clone)]
190pub struct SecretsTomlLoader {
191 path: PathBuf,
192 missing_ok: bool,
193 strict_permissions: bool,
194}
195
196impl SecretsTomlLoader {
197 pub fn new(path: PathBuf) -> Self {
199 Self {
200 path,
201 missing_ok: true,
202 strict_permissions: true,
203 }
204 }
205
206 pub fn required(mut self) -> Self {
208 self.missing_ok = false;
209 self
210 }
211
212 pub fn optional(mut self) -> Self {
214 self.missing_ok = true;
215 self
216 }
217
218 pub fn with_strict_permissions(mut self, strict: bool) -> Self {
220 self.strict_permissions = strict;
221 self
222 }
223}
224
225impl ConfigLoader for SecretsTomlLoader {
226 fn load(&self) -> Result<ConfigLayer, ConfigError> {
227 tracing::debug!(
228 path = %self.path.display(),
229 missing_ok = self.missing_ok,
230 strict_permissions = self.strict_permissions,
231 "loading TOML secrets layer"
232 );
233 if !self.path.exists() {
234 if self.missing_ok {
235 tracing::debug!(path = %self.path.display(), "optional TOML secrets file missing");
236 return Ok(ConfigLayer::default());
237 }
238 return Err(ConfigError::FileRead {
239 path: self.path.display().to_string(),
240 reason: "file not found".to_string(),
241 });
242 }
243
244 validate_secrets_permissions(&self.path, self.strict_permissions)?;
245
246 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
247 path: self.path.display().to_string(),
248 reason: err.to_string(),
249 })?;
250
251 let mut layer = ConfigLayer::from_toml_str(&raw)
252 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
253 let origin = self.path.display().to_string();
254 for entry in &mut layer.entries {
255 entry.origin = Some(origin.clone());
256 }
257 layer.mark_all_secret();
258 tracing::debug!(
259 path = %self.path.display(),
260 entries = layer.entries().len(),
261 "loaded TOML secrets layer"
262 );
263 Ok(layer)
264 }
265}
266
267#[derive(Debug, Clone, Default)]
269pub struct EnvSecretsLoader {
270 vars: Vec<(String, String)>,
271}
272
273impl EnvSecretsLoader {
274 pub fn from_process_env() -> Self {
276 Self {
277 vars: std::env::vars().collect(),
278 }
279 }
280
281 pub fn from_pairs<I, K, V>(vars: I) -> Self
283 where
284 I: IntoIterator<Item = (K, V)>,
285 K: AsRef<str>,
286 V: AsRef<str>,
287 {
288 Self {
289 vars: collect_string_pairs(vars),
290 }
291 }
292}
293
294impl<K, V> std::iter::FromIterator<(K, V)> for EnvSecretsLoader
295where
296 K: AsRef<str>,
297 V: AsRef<str>,
298{
299 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
300 Self {
301 vars: collect_string_pairs(iter),
302 }
303 }
304}
305
306impl ConfigLoader for EnvSecretsLoader {
307 fn load(&self) -> Result<ConfigLayer, ConfigError> {
308 let mut layer = ConfigLayer::default();
309
310 for (name, value) in &self.vars {
311 let Some(rest) = name.strip_prefix("OSP_SECRET__") else {
312 continue;
313 };
314
315 let synthetic = format!("OSP__{rest}");
316 let spec = parse_env_key(&synthetic)?;
317 ConfigSchema::default().validate_writable_key(&spec.key)?;
318 layer.insert_with_origin(
319 spec.key,
320 ConfigValue::String(value.clone()).into_secret(),
321 spec.scope,
322 Some(name.clone()),
323 );
324 }
325
326 tracing::debug!(
327 input_vars = self.vars.len(),
328 entries = layer.entries().len(),
329 "loaded environment secrets layer"
330 );
331 Ok(layer)
332 }
333}
334
335#[derive(Default)]
337pub struct ChainedLoader {
338 loaders: Vec<Box<dyn ConfigLoader>>,
339}
340
341impl ChainedLoader {
342 pub fn new<L>(loader: L) -> Self
344 where
345 L: ConfigLoader + 'static,
346 {
347 Self {
348 loaders: vec![Box::new(loader)],
349 }
350 }
351
352 pub fn with<L>(mut self, loader: L) -> Self
354 where
355 L: ConfigLoader + 'static,
356 {
357 self.loaders.push(Box::new(loader));
358 self
359 }
360}
361
362impl ConfigLoader for ChainedLoader {
363 fn load(&self) -> Result<ConfigLayer, ConfigError> {
364 let mut merged = ConfigLayer::default();
365 tracing::debug!(
366 loader_count = self.loaders.len(),
367 "loading chained config layer"
368 );
369 for loader in &self.loaders {
370 let layer = loader.load()?;
371 merged.entries.extend(layer.entries);
372 }
373 tracing::debug!(
374 entries = merged.entries().len(),
375 "loaded chained config layer"
376 );
377 Ok(merged)
378 }
379}
380
381#[derive(Debug, Clone, Default)]
383pub struct LoadedLayers {
384 pub defaults: ConfigLayer,
386 pub presentation: ConfigLayer,
388 pub file: ConfigLayer,
390 pub secrets: ConfigLayer,
392 pub env: ConfigLayer,
394 pub cli: ConfigLayer,
396 pub session: ConfigLayer,
398}
399
400pub struct LoaderPipeline {
402 defaults: Box<dyn ConfigLoader>,
403 presentation: Option<Box<dyn ConfigLoader>>,
404 file: Option<Box<dyn ConfigLoader>>,
405 secrets: Option<Box<dyn ConfigLoader>>,
406 env: Option<Box<dyn ConfigLoader>>,
407 cli: Option<Box<dyn ConfigLoader>>,
408 session: Option<Box<dyn ConfigLoader>>,
409 schema: ConfigSchema,
410}
411
412impl LoaderPipeline {
413 pub fn new<L>(defaults: L) -> Self
415 where
416 L: ConfigLoader + 'static,
417 {
418 Self {
419 defaults: Box::new(defaults),
420 presentation: None,
421 file: None,
422 secrets: None,
423 env: None,
424 cli: None,
425 session: None,
426 schema: ConfigSchema::default(),
427 }
428 }
429
430 pub fn with_file<L>(mut self, loader: L) -> Self
432 where
433 L: ConfigLoader + 'static,
434 {
435 self.file = Some(Box::new(loader));
436 self
437 }
438
439 pub fn with_presentation<L>(mut self, loader: L) -> Self
441 where
442 L: ConfigLoader + 'static,
443 {
444 self.presentation = Some(Box::new(loader));
445 self
446 }
447
448 pub fn with_secrets<L>(mut self, loader: L) -> Self
450 where
451 L: ConfigLoader + 'static,
452 {
453 self.secrets = Some(Box::new(loader));
454 self
455 }
456
457 pub fn with_env<L>(mut self, loader: L) -> Self
459 where
460 L: ConfigLoader + 'static,
461 {
462 self.env = Some(Box::new(loader));
463 self
464 }
465
466 pub fn with_cli<L>(mut self, loader: L) -> Self
468 where
469 L: ConfigLoader + 'static,
470 {
471 self.cli = Some(Box::new(loader));
472 self
473 }
474
475 pub fn with_session<L>(mut self, loader: L) -> Self
477 where
478 L: ConfigLoader + 'static,
479 {
480 self.session = Some(Box::new(loader));
481 self
482 }
483
484 pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
486 self.schema = schema;
487 self
488 }
489
490 pub fn load_layers(&self) -> Result<LoadedLayers, ConfigError> {
492 tracing::debug!("loading config layers");
493 let layers = LoadedLayers {
494 defaults: self.defaults.load()?,
495 presentation: load_optional_loader(self.presentation.as_deref())?,
496 file: load_optional_loader(self.file.as_deref())?,
497 secrets: load_optional_loader(self.secrets.as_deref())?,
498 env: load_optional_loader(self.env.as_deref())?,
499 cli: load_optional_loader(self.cli.as_deref())?,
500 session: load_optional_loader(self.session.as_deref())?,
501 };
502 tracing::debug!(
503 defaults = layers.defaults.entries().len(),
504 presentation = layers.presentation.entries().len(),
505 file = layers.file.entries().len(),
506 secrets = layers.secrets.entries().len(),
507 env = layers.env.entries().len(),
508 cli = layers.cli.entries().len(),
509 session = layers.session.entries().len(),
510 "loaded config layers"
511 );
512 Ok(layers)
513 }
514
515 pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
533 let layers = self.load_layers()?;
534 let mut resolver = ConfigResolver::from_loaded_layers(layers);
535 resolver.set_schema(self.schema.clone());
536 resolver.resolve(options)
537 }
538}
539
540fn load_optional_loader(loader: Option<&dyn ConfigLoader>) -> Result<ConfigLayer, ConfigError> {
541 match loader {
542 Some(loader) => loader.load(),
543 None => Ok(ConfigLayer::default()),
544 }
545}
546
547#[cfg(test)]
548mod tests;