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 {
9 fn load(&self) -> Result<ConfigLayer, ConfigError>;
10}
11
12fn collect_string_pairs<I, K, V>(vars: I) -> Vec<(String, String)>
13where
14 I: IntoIterator<Item = (K, V)>,
15 K: AsRef<str>,
16 V: AsRef<str>,
17{
18 vars.into_iter()
19 .map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
20 .collect()
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct StaticLayerLoader {
25 layer: ConfigLayer,
26}
27
28impl StaticLayerLoader {
29 pub fn new(layer: ConfigLayer) -> Self {
30 Self { layer }
31 }
32}
33
34impl ConfigLoader for StaticLayerLoader {
35 fn load(&self) -> Result<ConfigLayer, ConfigError> {
36 tracing::trace!(
37 entries = self.layer.entries().len(),
38 "loaded static config layer"
39 );
40 Ok(self.layer.clone())
41 }
42}
43
44#[derive(Debug, Clone)]
45pub struct TomlFileLoader {
46 path: PathBuf,
47 missing_ok: bool,
48}
49
50impl TomlFileLoader {
51 pub fn new(path: PathBuf) -> Self {
52 Self {
53 path,
54 missing_ok: true,
55 }
56 }
57
58 pub fn required(mut self) -> Self {
59 self.missing_ok = false;
60 self
61 }
62
63 pub fn optional(mut self) -> Self {
64 self.missing_ok = true;
65 self
66 }
67}
68
69impl ConfigLoader for TomlFileLoader {
70 fn load(&self) -> Result<ConfigLayer, ConfigError> {
71 tracing::debug!(
72 path = %self.path.display(),
73 missing_ok = self.missing_ok,
74 "loading TOML config layer"
75 );
76 if !self.path.exists() {
77 if self.missing_ok {
78 tracing::debug!(path = %self.path.display(), "optional TOML config file missing");
79 return Ok(ConfigLayer::default());
80 }
81 return Err(ConfigError::FileRead {
82 path: self.path.display().to_string(),
83 reason: "file not found".to_string(),
84 });
85 }
86
87 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
88 path: self.path.display().to_string(),
89 reason: err.to_string(),
90 })?;
91
92 let mut layer = ConfigLayer::from_toml_str(&raw)
93 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
94 let origin = self.path.display().to_string();
95 for entry in &mut layer.entries {
96 entry.origin = Some(origin.clone());
97 }
98 tracing::debug!(
99 path = %self.path.display(),
100 entries = layer.entries().len(),
101 "loaded TOML config layer"
102 );
103 Ok(layer)
104 }
105}
106
107#[derive(Debug, Clone, Default)]
108pub struct EnvVarLoader {
109 vars: Vec<(String, String)>,
110}
111
112impl EnvVarLoader {
113 pub fn from_process_env() -> Self {
114 Self {
115 vars: std::env::vars().collect(),
116 }
117 }
118
119 pub fn from_pairs<I, K, V>(vars: I) -> Self
120 where
121 I: IntoIterator<Item = (K, V)>,
122 K: AsRef<str>,
123 V: AsRef<str>,
124 {
125 Self {
126 vars: collect_string_pairs(vars),
127 }
128 }
129}
130
131impl<K, V> std::iter::FromIterator<(K, V)> for EnvVarLoader
132where
133 K: AsRef<str>,
134 V: AsRef<str>,
135{
136 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
137 Self {
138 vars: collect_string_pairs(iter),
139 }
140 }
141}
142
143impl ConfigLoader for EnvVarLoader {
144 fn load(&self) -> Result<ConfigLayer, ConfigError> {
145 let layer =
146 ConfigLayer::from_env_iter(self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
147 tracing::debug!(
148 input_vars = self.vars.len(),
149 entries = layer.entries().len(),
150 "loaded environment config layer"
151 );
152 Ok(layer)
153 }
154}
155
156#[derive(Debug, Clone)]
157pub struct SecretsTomlLoader {
158 path: PathBuf,
159 missing_ok: bool,
160 strict_permissions: bool,
161}
162
163impl SecretsTomlLoader {
164 pub fn new(path: PathBuf) -> Self {
165 Self {
166 path,
167 missing_ok: true,
168 strict_permissions: true,
169 }
170 }
171
172 pub fn required(mut self) -> Self {
173 self.missing_ok = false;
174 self
175 }
176
177 pub fn optional(mut self) -> Self {
178 self.missing_ok = true;
179 self
180 }
181
182 pub fn with_strict_permissions(mut self, strict: bool) -> Self {
183 self.strict_permissions = strict;
184 self
185 }
186}
187
188impl ConfigLoader for SecretsTomlLoader {
189 fn load(&self) -> Result<ConfigLayer, ConfigError> {
190 tracing::debug!(
191 path = %self.path.display(),
192 missing_ok = self.missing_ok,
193 strict_permissions = self.strict_permissions,
194 "loading TOML secrets layer"
195 );
196 if !self.path.exists() {
197 if self.missing_ok {
198 tracing::debug!(path = %self.path.display(), "optional TOML secrets file missing");
199 return Ok(ConfigLayer::default());
200 }
201 return Err(ConfigError::FileRead {
202 path: self.path.display().to_string(),
203 reason: "file not found".to_string(),
204 });
205 }
206
207 validate_secrets_permissions(&self.path, self.strict_permissions)?;
208
209 let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
210 path: self.path.display().to_string(),
211 reason: err.to_string(),
212 })?;
213
214 let mut layer = ConfigLayer::from_toml_str(&raw)
215 .map_err(|err| with_path_context(self.path.display().to_string(), err))?;
216 let origin = self.path.display().to_string();
217 for entry in &mut layer.entries {
218 entry.origin = Some(origin.clone());
219 }
220 layer.mark_all_secret();
221 tracing::debug!(
222 path = %self.path.display(),
223 entries = layer.entries().len(),
224 "loaded TOML secrets layer"
225 );
226 Ok(layer)
227 }
228}
229
230#[derive(Debug, Clone, Default)]
231pub struct EnvSecretsLoader {
232 vars: Vec<(String, String)>,
233}
234
235impl EnvSecretsLoader {
236 pub fn from_process_env() -> Self {
237 Self {
238 vars: std::env::vars().collect(),
239 }
240 }
241
242 pub fn from_pairs<I, K, V>(vars: I) -> Self
243 where
244 I: IntoIterator<Item = (K, V)>,
245 K: AsRef<str>,
246 V: AsRef<str>,
247 {
248 Self {
249 vars: collect_string_pairs(vars),
250 }
251 }
252}
253
254impl<K, V> std::iter::FromIterator<(K, V)> for EnvSecretsLoader
255where
256 K: AsRef<str>,
257 V: AsRef<str>,
258{
259 fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
260 Self {
261 vars: collect_string_pairs(iter),
262 }
263 }
264}
265
266impl ConfigLoader for EnvSecretsLoader {
267 fn load(&self) -> Result<ConfigLayer, ConfigError> {
268 let mut layer = ConfigLayer::default();
269
270 for (name, value) in &self.vars {
271 let Some(rest) = name.strip_prefix("OSP_SECRET__") else {
272 continue;
273 };
274
275 let synthetic = format!("OSP__{rest}");
276 let spec = parse_env_key(&synthetic)?;
277 layer.insert_with_origin(
278 spec.key,
279 ConfigValue::String(value.clone()).into_secret(),
280 spec.scope,
281 Some(name.clone()),
282 );
283 }
284
285 tracing::debug!(
286 input_vars = self.vars.len(),
287 entries = layer.entries().len(),
288 "loaded environment secrets layer"
289 );
290 Ok(layer)
291 }
292}
293
294#[derive(Default)]
295pub struct ChainedLoader {
296 loaders: Vec<Box<dyn ConfigLoader>>,
297}
298
299impl ChainedLoader {
300 pub fn new<L>(loader: L) -> Self
301 where
302 L: ConfigLoader + 'static,
303 {
304 Self {
305 loaders: vec![Box::new(loader)],
306 }
307 }
308
309 pub fn with<L>(mut self, loader: L) -> Self
310 where
311 L: ConfigLoader + 'static,
312 {
313 self.loaders.push(Box::new(loader));
314 self
315 }
316}
317
318impl ConfigLoader for ChainedLoader {
319 fn load(&self) -> Result<ConfigLayer, ConfigError> {
320 let mut merged = ConfigLayer::default();
321 tracing::debug!(
322 loader_count = self.loaders.len(),
323 "loading chained config layer"
324 );
325 for loader in &self.loaders {
326 let layer = loader.load()?;
327 merged.entries.extend(layer.entries);
328 }
329 tracing::debug!(
330 entries = merged.entries().len(),
331 "loaded chained config layer"
332 );
333 Ok(merged)
334 }
335}
336
337#[derive(Debug, Clone, Default)]
338pub struct LoadedLayers {
339 pub defaults: ConfigLayer,
340 pub presentation: ConfigLayer,
341 pub file: ConfigLayer,
342 pub secrets: ConfigLayer,
343 pub env: ConfigLayer,
344 pub cli: ConfigLayer,
345 pub session: ConfigLayer,
346}
347
348pub struct LoaderPipeline {
349 defaults: Box<dyn ConfigLoader>,
350 presentation: Option<Box<dyn ConfigLoader>>,
351 file: Option<Box<dyn ConfigLoader>>,
352 secrets: Option<Box<dyn ConfigLoader>>,
353 env: Option<Box<dyn ConfigLoader>>,
354 cli: Option<Box<dyn ConfigLoader>>,
355 session: Option<Box<dyn ConfigLoader>>,
356 schema: ConfigSchema,
357}
358
359impl LoaderPipeline {
360 pub fn new<L>(defaults: L) -> Self
361 where
362 L: ConfigLoader + 'static,
363 {
364 Self {
365 defaults: Box::new(defaults),
366 presentation: None,
367 file: None,
368 secrets: None,
369 env: None,
370 cli: None,
371 session: None,
372 schema: ConfigSchema::default(),
373 }
374 }
375
376 pub fn with_file<L>(mut self, loader: L) -> Self
377 where
378 L: ConfigLoader + 'static,
379 {
380 self.file = Some(Box::new(loader));
381 self
382 }
383
384 pub fn with_presentation<L>(mut self, loader: L) -> Self
385 where
386 L: ConfigLoader + 'static,
387 {
388 self.presentation = Some(Box::new(loader));
389 self
390 }
391
392 pub fn with_secrets<L>(mut self, loader: L) -> Self
393 where
394 L: ConfigLoader + 'static,
395 {
396 self.secrets = Some(Box::new(loader));
397 self
398 }
399
400 pub fn with_env<L>(mut self, loader: L) -> Self
401 where
402 L: ConfigLoader + 'static,
403 {
404 self.env = Some(Box::new(loader));
405 self
406 }
407
408 pub fn with_cli<L>(mut self, loader: L) -> Self
409 where
410 L: ConfigLoader + 'static,
411 {
412 self.cli = Some(Box::new(loader));
413 self
414 }
415
416 pub fn with_session<L>(mut self, loader: L) -> Self
417 where
418 L: ConfigLoader + 'static,
419 {
420 self.session = Some(Box::new(loader));
421 self
422 }
423
424 pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
425 self.schema = schema;
426 self
427 }
428
429 pub fn load_layers(&self) -> Result<LoadedLayers, ConfigError> {
430 tracing::debug!("loading config layers");
431 let layers = LoadedLayers {
432 defaults: self.defaults.load()?,
433 presentation: load_optional_loader(self.presentation.as_deref())?,
434 file: load_optional_loader(self.file.as_deref())?,
435 secrets: load_optional_loader(self.secrets.as_deref())?,
436 env: load_optional_loader(self.env.as_deref())?,
437 cli: load_optional_loader(self.cli.as_deref())?,
438 session: load_optional_loader(self.session.as_deref())?,
439 };
440 tracing::debug!(
441 defaults = layers.defaults.entries().len(),
442 presentation = layers.presentation.entries().len(),
443 file = layers.file.entries().len(),
444 secrets = layers.secrets.entries().len(),
445 env = layers.env.entries().len(),
446 cli = layers.cli.entries().len(),
447 session = layers.session.entries().len(),
448 "loaded config layers"
449 );
450 Ok(layers)
451 }
452
453 pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
454 let layers = self.load_layers()?;
455 let mut resolver = ConfigResolver::from_loaded_layers(layers);
456 resolver.set_schema(self.schema.clone());
457 resolver.resolve(options)
458 }
459}
460
461fn load_optional_loader(loader: Option<&dyn ConfigLoader>) -> Result<ConfigLayer, ConfigError> {
462 match loader {
463 Some(loader) => loader.load(),
464 None => Ok(ConfigLayer::default()),
465 }
466}
467
468#[cfg(test)]
469mod loader_path_tests {
470 use super::{
471 ChainedLoader, ConfigLoader, EnvSecretsLoader, EnvVarLoader, LoaderPipeline,
472 SecretsTomlLoader, StaticLayerLoader, TomlFileLoader,
473 };
474 use crate::config::{ConfigError, ConfigLayer, ConfigSchema, ResolveOptions, Scope};
475 use std::path::PathBuf;
476
477 fn make_temp_dir(prefix: &str) -> PathBuf {
478 let mut dir = std::env::temp_dir();
479 let nonce = std::time::SystemTime::now()
480 .duration_since(std::time::UNIX_EPOCH)
481 .expect("time should be valid")
482 .as_nanos();
483 dir.push(format!("{prefix}-{nonce}"));
484 std::fs::create_dir_all(&dir).expect("temp dir should be created");
485 dir
486 }
487
488 #[test]
489 fn toml_file_loader_covers_existing_optional_and_missing_required_paths() {
490 let root = make_temp_dir("osp-config-loader");
491 let config_path = root.join("config.toml");
492 std::fs::write(&config_path, "[default.ui]\ntheme = \"plain\"\n")
493 .expect("config should be written");
494
495 let layer = TomlFileLoader::new(config_path.clone())
496 .optional()
497 .load()
498 .expect("optional existing config should load");
499 let config_origin = config_path.to_string_lossy().to_string();
500 assert_eq!(layer.entries().len(), 1);
501 assert_eq!(layer.entries()[0].key, "ui.theme");
502 assert_eq!(
503 layer.entries()[0].origin.as_deref(),
504 Some(config_origin.as_str())
505 );
506
507 let missing_path = root.join("missing.toml");
508 let missing = TomlFileLoader::new(missing_path.clone())
509 .required()
510 .load()
511 .expect_err("required missing config should fail");
512 let missing_display = missing_path.to_string_lossy().to_string();
513 assert!(matches!(
514 missing,
515 ConfigError::FileRead { path, reason }
516 if path == missing_display && reason == "file not found"
517 ));
518
519 let optional_missing = TomlFileLoader::new(root.join("optional.toml"))
520 .optional()
521 .load()
522 .expect("optional missing config should be empty");
523 assert!(optional_missing.entries().is_empty());
524 }
525
526 #[test]
527 fn secrets_and_env_loaders_mark_secret_entries_and_origins() {
528 let root = make_temp_dir("osp-config-secrets");
529 let secrets_path = root.join("secrets.toml");
530 std::fs::write(&secrets_path, "[default.auth]\ntoken = \"shh\"\n")
531 .expect("secrets file should be written");
532
533 let secrets = SecretsTomlLoader::new(secrets_path.clone())
534 .with_strict_permissions(false)
535 .required()
536 .load()
537 .expect("secrets file should load");
538 let secrets_origin = secrets_path.to_string_lossy().to_string();
539 assert_eq!(secrets.entries().len(), 1);
540 assert!(secrets.entries()[0].value.is_secret());
541 assert_eq!(
542 secrets.entries()[0].origin.as_deref(),
543 Some(secrets_origin.as_str())
544 );
545
546 let env =
547 EnvSecretsLoader::from_iter([("IGNORED", "x"), ("OSP_SECRET__AUTH__TOKEN", "env-shh")])
548 .load()
549 .expect("env secrets should load");
550 assert_eq!(env.entries().len(), 1);
551 assert_eq!(env.entries()[0].key, "auth.token");
552 assert!(env.entries()[0].value.is_secret());
553 assert_eq!(
554 env.entries()[0].origin.as_deref(),
555 Some("OSP_SECRET__AUTH__TOKEN")
556 );
557 }
558
559 #[test]
560 fn chained_loader_and_pipeline_merge_and_resolve_layers() {
561 let chained = ChainedLoader::new(StaticLayerLoader::new({
562 let mut layer = ConfigLayer::default();
563 layer.insert("theme.name", "plain", Scope::global());
564 layer
565 }))
566 .with(EnvVarLoader::from_pairs([("OSP__THEME__NAME", "dracula")]));
567 let merged = chained.load().expect("chained loader should merge");
568 assert_eq!(merged.entries().len(), 2);
569
570 let resolved = LoaderPipeline::new(StaticLayerLoader::new({
571 let mut layer = ConfigLayer::default();
572 layer.insert("theme.name", "plain", Scope::global());
573 layer
574 }))
575 .with_env(EnvVarLoader::from_pairs([("OSP__THEME__NAME", "dracula")]))
576 .resolve(ResolveOptions::default())
577 .expect("pipeline should resolve");
578 assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
579
580 let layers = LoaderPipeline::new(StaticLayerLoader::new(ConfigLayer::default()))
581 .load_layers()
582 .expect("optional loaders should default to empty");
583 assert!(layers.file.entries().is_empty());
584 assert!(layers.secrets.entries().is_empty());
585 assert!(layers.env.entries().is_empty());
586 assert!(layers.cli.entries().is_empty());
587 assert!(layers.session.entries().is_empty());
588 }
589
590 #[test]
591 fn pipeline_builder_covers_schema_and_collected_env_loaders() {
592 let env: EnvVarLoader = [("OSP__THEME__NAME", "nord")].into_iter().collect();
593 let secrets: EnvSecretsLoader = [("OSP_SECRET__AUTH__TOKEN", "tok")].into_iter().collect();
594
595 let layers = LoaderPipeline::new(StaticLayerLoader::new(ConfigLayer::default()))
596 .with_env(env)
597 .with_secrets(secrets)
598 .with_schema(ConfigSchema::default())
599 .load_layers()
600 .expect("pipeline should load collected loaders");
601
602 assert_eq!(layers.env.entries().len(), 1);
603 assert_eq!(layers.secrets.entries().len(), 1);
604 }
605}