Skip to main content

osp_cli/config/
loader.rs

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 tests;