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)]
60#[must_use]
61pub struct TomlFileLoader {
62 path: PathBuf,
63 missing_ok: bool,
64}
65
66impl TomlFileLoader {
67 pub fn new(path: PathBuf) -> Self {
69 Self {
70 path,
71 missing_ok: true,
72 }
73 }
74
75 pub fn required(mut self) -> Self {
77 self.missing_ok = false;
78 self
79 }
80
81 pub fn optional(mut self) -> Self {
83 self.missing_ok = true;
84 self
85 }
86}
87
88impl ConfigLoader for TomlFileLoader {
89 fn load(&self) -> Result<ConfigLayer, ConfigError> {
90 tracing::debug!(
91 path = %self.path.display(),
92 missing_ok = self.missing_ok,
93 "loading TOML config layer"
94 );
95 if !self.path.exists() {
96 if self.missing_ok {
97 tracing::debug!(path = %self.path.display(), "optional TOML config file missing");
98 return Ok(ConfigLayer::default());
99 }
100 return Err(ConfigError::FileRead {
101 path: self.path.display().to_string(),
102 reason: "file not found".to_string(),
103 });
104 }
105
106 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
107 path: self.path.display().to_string(),
108 reason: err.to_string(),
109 })?;
110
111 let mut layer = ConfigLayer::from_toml_str(&raw)
112 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
113 let origin = self.path.display().to_string();
114 for entry in &mut layer.entries {
115 entry.origin = Some(origin.clone());
116 }
117 tracing::debug!(
118 path = %self.path.display(),
119 entries = layer.entries().len(),
120 "loaded TOML config layer"
121 );
122 Ok(layer)
123 }
124}
125
126#[derive(Debug, Clone, Default)]
128pub struct EnvVarLoader {
129 vars: Vec<(String, String)>,
130}
131
132impl EnvVarLoader {
133 pub fn from_process_env() -> Self {
135 Self {
136 vars: std::env::vars().collect(),
137 }
138 }
139
140 pub fn from_pairs<I, K, V>(vars: I) -> Self
153 where
154 I: IntoIterator<Item = (K, V)>,
155 K: AsRef<str>,
156 V: AsRef<str>,
157 {
158 Self {
159 vars: collect_string_pairs(vars),
160 }
161 }
162}
163
164impl<K, V> std::iter::FromIterator<(K, V)> for EnvVarLoader
165where
166 K: AsRef<str>,
167 V: AsRef<str>,
168{
169 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
170 Self {
171 vars: collect_string_pairs(iter),
172 }
173 }
174}
175
176impl ConfigLoader for EnvVarLoader {
177 fn load(&self) -> Result<ConfigLayer, ConfigError> {
178 let layer =
179 ConfigLayer::from_env_iter(self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
180 tracing::debug!(
181 input_vars = self.vars.len(),
182 entries = layer.entries().len(),
183 "loaded environment config layer"
184 );
185 Ok(layer)
186 }
187}
188
189#[derive(Debug, Clone)]
191#[must_use]
192pub struct SecretsTomlLoader {
193 path: PathBuf,
194 missing_ok: bool,
195 strict_permissions: bool,
196}
197
198impl SecretsTomlLoader {
199 pub fn new(path: PathBuf) -> Self {
201 Self {
202 path,
203 missing_ok: true,
204 strict_permissions: true,
205 }
206 }
207
208 pub fn required(mut self) -> Self {
210 self.missing_ok = false;
211 self
212 }
213
214 pub fn optional(mut self) -> Self {
216 self.missing_ok = true;
217 self
218 }
219
220 pub fn with_strict_permissions(mut self, strict: bool) -> Self {
222 self.strict_permissions = strict;
223 self
224 }
225}
226
227impl ConfigLoader for SecretsTomlLoader {
228 fn load(&self) -> Result<ConfigLayer, ConfigError> {
229 tracing::debug!(
230 path = %self.path.display(),
231 missing_ok = self.missing_ok,
232 strict_permissions = self.strict_permissions,
233 "loading TOML secrets layer"
234 );
235 if !self.path.exists() {
236 if self.missing_ok {
237 tracing::debug!(path = %self.path.display(), "optional TOML secrets file missing");
238 return Ok(ConfigLayer::default());
239 }
240 return Err(ConfigError::FileRead {
241 path: self.path.display().to_string(),
242 reason: "file not found".to_string(),
243 });
244 }
245
246 validate_secrets_permissions(&self.path, self.strict_permissions)?;
247
248 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
249 path: self.path.display().to_string(),
250 reason: err.to_string(),
251 })?;
252
253 let mut layer = ConfigLayer::from_toml_str(&raw)
254 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
255 let origin = self.path.display().to_string();
256 for entry in &mut layer.entries {
257 entry.origin = Some(origin.clone());
258 }
259 layer.mark_all_secret();
260 tracing::debug!(
261 path = %self.path.display(),
262 entries = layer.entries().len(),
263 "loaded TOML secrets layer"
264 );
265 Ok(layer)
266 }
267}
268
269#[derive(Debug, Clone, Default)]
271pub struct EnvSecretsLoader {
272 vars: Vec<(String, String)>,
273}
274
275impl EnvSecretsLoader {
276 pub fn from_process_env() -> Self {
278 Self {
279 vars: std::env::vars().collect(),
280 }
281 }
282
283 pub fn from_pairs<I, K, V>(vars: I) -> Self
285 where
286 I: IntoIterator<Item = (K, V)>,
287 K: AsRef<str>,
288 V: AsRef<str>,
289 {
290 Self {
291 vars: collect_string_pairs(vars),
292 }
293 }
294}
295
296impl<K, V> std::iter::FromIterator<(K, V)> for EnvSecretsLoader
297where
298 K: AsRef<str>,
299 V: AsRef<str>,
300{
301 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
302 Self {
303 vars: collect_string_pairs(iter),
304 }
305 }
306}
307
308impl ConfigLoader for EnvSecretsLoader {
309 fn load(&self) -> Result<ConfigLayer, ConfigError> {
310 let mut layer = ConfigLayer::default();
311
312 for (name, value) in &self.vars {
313 let Some(rest) = name.strip_prefix("OSP_SECRET__") else {
314 continue;
315 };
316
317 let synthetic = format!("OSP__{rest}");
318 let spec = parse_env_key(&synthetic)?;
319 ConfigSchema::default().validate_writable_key(&spec.key)?;
320 layer.insert_with_origin(
321 spec.key,
322 ConfigValue::String(value.clone()).into_secret(),
323 spec.scope,
324 Some(name.clone()),
325 );
326 }
327
328 tracing::debug!(
329 input_vars = self.vars.len(),
330 entries = layer.entries().len(),
331 "loaded environment secrets layer"
332 );
333 Ok(layer)
334 }
335}
336
337#[derive(Default)]
339#[must_use]
340pub struct ChainedLoader {
341 loaders: Vec<Box<dyn ConfigLoader>>,
342}
343
344impl ChainedLoader {
345 pub fn new<L>(loader: L) -> Self
347 where
348 L: ConfigLoader + 'static,
349 {
350 Self {
351 loaders: vec![Box::new(loader)],
352 }
353 }
354
355 pub fn with<L>(mut self, loader: L) -> Self
357 where
358 L: ConfigLoader + 'static,
359 {
360 self.loaders.push(Box::new(loader));
361 self
362 }
363}
364
365impl ConfigLoader for ChainedLoader {
366 fn load(&self) -> Result<ConfigLayer, ConfigError> {
367 let mut merged = ConfigLayer::default();
368 tracing::debug!(
369 loader_count = self.loaders.len(),
370 "loading chained config layer"
371 );
372 for loader in &self.loaders {
373 let layer = loader.load()?;
374 merged.entries.extend(layer.entries);
375 }
376 tracing::debug!(
377 entries = merged.entries().len(),
378 "loaded chained config layer"
379 );
380 Ok(merged)
381 }
382}
383
384#[derive(Debug, Clone, Default)]
386pub struct LoadedLayers {
387 pub defaults: ConfigLayer,
389 pub presentation: ConfigLayer,
391 pub file: ConfigLayer,
393 pub secrets: ConfigLayer,
395 pub env: ConfigLayer,
397 pub cli: ConfigLayer,
399 pub session: ConfigLayer,
401}
402
403#[must_use]
405pub struct LoaderPipeline {
406 defaults: Box<dyn ConfigLoader>,
407 presentation: Option<Box<dyn ConfigLoader>>,
408 file: Option<Box<dyn ConfigLoader>>,
409 secrets: Option<Box<dyn ConfigLoader>>,
410 env: Option<Box<dyn ConfigLoader>>,
411 cli: Option<Box<dyn ConfigLoader>>,
412 session: Option<Box<dyn ConfigLoader>>,
413 schema: ConfigSchema,
414}
415
416impl LoaderPipeline {
417 pub fn new<L>(defaults: L) -> Self
419 where
420 L: ConfigLoader + 'static,
421 {
422 Self {
423 defaults: Box::new(defaults),
424 presentation: None,
425 file: None,
426 secrets: None,
427 env: None,
428 cli: None,
429 session: None,
430 schema: ConfigSchema::default(),
431 }
432 }
433
434 pub fn with_file<L>(mut self, loader: L) -> Self
436 where
437 L: ConfigLoader + 'static,
438 {
439 self.file = Some(Box::new(loader));
440 self
441 }
442
443 pub fn with_presentation<L>(mut self, loader: L) -> Self
445 where
446 L: ConfigLoader + 'static,
447 {
448 self.presentation = Some(Box::new(loader));
449 self
450 }
451
452 pub fn with_secrets<L>(mut self, loader: L) -> Self
454 where
455 L: ConfigLoader + 'static,
456 {
457 self.secrets = Some(Box::new(loader));
458 self
459 }
460
461 pub fn with_env<L>(mut self, loader: L) -> Self
463 where
464 L: ConfigLoader + 'static,
465 {
466 self.env = Some(Box::new(loader));
467 self
468 }
469
470 pub fn with_cli<L>(mut self, loader: L) -> Self
472 where
473 L: ConfigLoader + 'static,
474 {
475 self.cli = Some(Box::new(loader));
476 self
477 }
478
479 pub fn with_session<L>(mut self, loader: L) -> Self
481 where
482 L: ConfigLoader + 'static,
483 {
484 self.session = Some(Box::new(loader));
485 self
486 }
487
488 pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
490 self.schema = schema;
491 self
492 }
493
494 pub fn load_layers(&self) -> Result<LoadedLayers, ConfigError> {
496 tracing::debug!("loading config layers");
497 let layers = LoadedLayers {
498 defaults: self.defaults.load()?,
499 presentation: load_optional_loader(self.presentation.as_deref())?,
500 file: load_optional_loader(self.file.as_deref())?,
501 secrets: load_optional_loader(self.secrets.as_deref())?,
502 env: load_optional_loader(self.env.as_deref())?,
503 cli: load_optional_loader(self.cli.as_deref())?,
504 session: load_optional_loader(self.session.as_deref())?,
505 };
506 tracing::debug!(
507 defaults = layers.defaults.entries().len(),
508 presentation = layers.presentation.entries().len(),
509 file = layers.file.entries().len(),
510 secrets = layers.secrets.entries().len(),
511 env = layers.env.entries().len(),
512 cli = layers.cli.entries().len(),
513 session = layers.session.entries().len(),
514 "loaded config layers"
515 );
516 Ok(layers)
517 }
518
519 pub fn resolver(&self) -> Result<ConfigResolver, ConfigError> {
522 let layers = self.load_layers()?;
523 Ok(ConfigResolver::from_loaded_layers_with_schema(
524 layers,
525 self.schema.clone(),
526 ))
527 }
528
529 pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
547 self.resolver()?.resolve(options)
548 }
549}
550
551fn load_optional_loader(loader: Option<&dyn ConfigLoader>) -> Result<ConfigLayer, ConfigError> {
552 match loader {
553 Some(loader) => loader.load(),
554 None => Ok(ConfigLayer::default()),
555 }
556}
557
558#[cfg(test)]
559mod tests;