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
8/// Loads a single config layer from some backing source.
9pub trait ConfigLoader: Send + Sync {
10    /// Reads the source and returns it as a config layer.
11    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/// Loader that returns a prebuilt config layer.
26#[derive(Debug, Clone, Default)]
27pub struct StaticLayerLoader {
28    layer: ConfigLayer,
29}
30
31impl StaticLayerLoader {
32    /// Wraps an existing layer so it can participate in a loader pipeline.
33    ///
34    /// # Examples
35    ///
36    /// ```
37    /// use osp_cli::config::{ConfigLayer, ConfigLoader, StaticLayerLoader};
38    ///
39    /// let loader = StaticLayerLoader::new(ConfigLayer::default());
40    ///
41    /// assert!(loader.load().unwrap().entries().is_empty());
42    /// ```
43    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/// Loader for ordinary TOML config files.
59#[derive(Debug, Clone)]
60pub struct TomlFileLoader {
61    path: PathBuf,
62    missing_ok: bool,
63}
64
65impl TomlFileLoader {
66    /// Creates a loader for the given TOML file path.
67    pub fn new(path: PathBuf) -> Self {
68        Self {
69            path,
70            missing_ok: true,
71        }
72    }
73
74    /// Requires the file to exist.
75    pub fn required(mut self) -> Self {
76        self.missing_ok = false;
77        self
78    }
79
80    /// Allows the file to be absent.
81    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/// Loader for `OSP__...` environment variables.
126#[derive(Debug, Clone, Default)]
127pub struct EnvVarLoader {
128    vars: Vec<(String, String)>,
129}
130
131impl EnvVarLoader {
132    /// Captures the current process environment.
133    pub fn from_process_env() -> Self {
134        Self {
135            vars: std::env::vars().collect(),
136        }
137    }
138
139    /// Creates a loader from explicit key-value pairs.
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use osp_cli::config::{ConfigLoader, EnvVarLoader};
145    ///
146    /// let loader = EnvVarLoader::from_pairs([("OSP__output__format", "json")]);
147    /// let layer = loader.load().unwrap();
148    ///
149    /// assert_eq!(layer.entries()[0].key, "output.format");
150    /// ```
151    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/// Loader for TOML secrets files whose values are marked secret.
189#[derive(Debug, Clone)]
190pub struct SecretsTomlLoader {
191    path: PathBuf,
192    missing_ok: bool,
193    strict_permissions: bool,
194}
195
196impl SecretsTomlLoader {
197    /// Creates a loader for the given secrets file path.
198    pub fn new(path: PathBuf) -> Self {
199        Self {
200            path,
201            missing_ok: true,
202            strict_permissions: true,
203        }
204    }
205
206    /// Requires the file to exist.
207    pub fn required(mut self) -> Self {
208        self.missing_ok = false;
209        self
210    }
211
212    /// Allows the file to be absent.
213    pub fn optional(mut self) -> Self {
214        self.missing_ok = true;
215        self
216    }
217
218    /// Enables or disables permission checks before loading.
219    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/// Loader for `OSP_SECRET__...` environment variables.
268#[derive(Debug, Clone, Default)]
269pub struct EnvSecretsLoader {
270    vars: Vec<(String, String)>,
271}
272
273impl EnvSecretsLoader {
274    /// Captures secret variables from the current process environment.
275    pub fn from_process_env() -> Self {
276        Self {
277            vars: std::env::vars().collect(),
278        }
279    }
280
281    /// Creates a loader from explicit key-value pairs.
282    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/// Loader that merges multiple loaders in the order they are added.
336#[derive(Default)]
337pub struct ChainedLoader {
338    loaders: Vec<Box<dyn ConfigLoader>>,
339}
340
341impl ChainedLoader {
342    /// Starts a chain with one loader.
343    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    /// Appends another loader to the chain.
353    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/// Materialized config layers grouped by source priority.
382#[derive(Debug, Clone, Default)]
383pub struct LoadedLayers {
384    /// Built-in defaults loaded before any user input.
385    pub defaults: ConfigLayer,
386    /// Presentation-specific defaults layered above built-ins.
387    pub presentation: ConfigLayer,
388    /// Values loaded from the ordinary config file.
389    pub file: ConfigLayer,
390    /// Values loaded from the secrets store.
391    pub secrets: ConfigLayer,
392    /// Values loaded from environment variables.
393    pub env: ConfigLayer,
394    /// Values synthesized from CLI flags and arguments.
395    pub cli: ConfigLayer,
396    /// In-memory session overrides.
397    pub session: ConfigLayer,
398}
399
400/// Builder for the standard multi-source config loading pipeline.
401pub 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    /// Creates a pipeline with the required defaults loader.
414    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    /// Adds the ordinary config file loader.
431    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    /// Adds the presentation defaults loader.
440    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    /// Adds the secrets loader.
449    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    /// Adds the environment loader.
458    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    /// Adds the CLI override loader.
467    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    /// Adds the session override loader.
476    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    /// Replaces the schema used during resolution.
485    pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
486        self.schema = schema;
487        self
488    }
489
490    /// Loads every configured source into concrete layers.
491    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    /// Loads all layers and resolves them into a runtime config.
516    ///
517    /// # Examples
518    ///
519    /// ```
520    /// use osp_cli::config::{ConfigLayer, LoaderPipeline, ResolveOptions, StaticLayerLoader};
521    ///
522    /// let mut defaults = ConfigLayer::default();
523    /// defaults.set("profile.default", "default");
524    /// defaults.set("theme.name", "dracula");
525    ///
526    /// let resolved = LoaderPipeline::new(StaticLayerLoader::new(defaults))
527    ///     .resolve(ResolveOptions::default())
528    ///     .unwrap();
529    ///
530    /// assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
531    /// ```
532    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;