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)]
60#[must_use]
61pub struct TomlFileLoader {
62    path: PathBuf,
63    missing_ok: bool,
64}
65
66impl TomlFileLoader {
67    /// Creates a loader for the given TOML file path.
68    pub fn new(path: PathBuf) -> Self {
69        Self {
70            path,
71            missing_ok: true,
72        }
73    }
74
75    /// Requires the file to exist.
76    pub fn required(mut self) -> Self {
77        self.missing_ok = false;
78        self
79    }
80
81    /// Allows the file to be absent.
82    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/// Loader for `OSP__...` environment variables.
127#[derive(Debug, Clone, Default)]
128pub struct EnvVarLoader {
129    vars: Vec<(String, String)>,
130}
131
132impl EnvVarLoader {
133    /// Captures the current process environment.
134    pub fn from_process_env() -> Self {
135        Self {
136            vars: std::env::vars().collect(),
137        }
138    }
139
140    /// Creates a loader from explicit key-value pairs.
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use osp_cli::config::{ConfigLoader, EnvVarLoader};
146    ///
147    /// let loader = EnvVarLoader::from_pairs([("OSP__output__format", "json")]);
148    /// let layer = loader.load().unwrap();
149    ///
150    /// assert_eq!(layer.entries()[0].key, "output.format");
151    /// ```
152    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/// Loader for TOML secrets files whose values are marked secret.
190#[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    /// Creates a loader for the given secrets file path.
200    pub fn new(path: PathBuf) -> Self {
201        Self {
202            path,
203            missing_ok: true,
204            strict_permissions: true,
205        }
206    }
207
208    /// Requires the file to exist.
209    pub fn required(mut self) -> Self {
210        self.missing_ok = false;
211        self
212    }
213
214    /// Allows the file to be absent.
215    pub fn optional(mut self) -> Self {
216        self.missing_ok = true;
217        self
218    }
219
220    /// Enables or disables permission checks before loading.
221    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/// Loader for `OSP_SECRET__...` environment variables.
270#[derive(Debug, Clone, Default)]
271pub struct EnvSecretsLoader {
272    vars: Vec<(String, String)>,
273}
274
275impl EnvSecretsLoader {
276    /// Captures secret variables from the current process environment.
277    pub fn from_process_env() -> Self {
278        Self {
279            vars: std::env::vars().collect(),
280        }
281    }
282
283    /// Creates a loader from explicit key-value pairs.
284    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/// Loader that merges multiple loaders in the order they are added.
338#[derive(Default)]
339#[must_use]
340pub struct ChainedLoader {
341    loaders: Vec<Box<dyn ConfigLoader>>,
342}
343
344impl ChainedLoader {
345    /// Starts a chain with one loader.
346    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    /// Appends another loader to the chain.
356    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/// Materialized config layers grouped by source priority.
385#[derive(Debug, Clone, Default)]
386pub struct LoadedLayers {
387    /// Built-in defaults loaded before any user input.
388    pub defaults: ConfigLayer,
389    /// Presentation-specific defaults layered above built-ins.
390    pub presentation: ConfigLayer,
391    /// Values loaded from the ordinary config file.
392    pub file: ConfigLayer,
393    /// Values loaded from the secrets store.
394    pub secrets: ConfigLayer,
395    /// Values loaded from environment variables.
396    pub env: ConfigLayer,
397    /// Values synthesized from CLI flags and arguments.
398    pub cli: ConfigLayer,
399    /// In-memory session overrides.
400    pub session: ConfigLayer,
401}
402
403/// Builder for the standard multi-source config loading pipeline.
404#[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    /// Creates a pipeline with the required defaults loader.
418    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    /// Adds the ordinary config file loader.
435    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    /// Adds the presentation defaults loader.
444    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    /// Adds the secrets loader.
453    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    /// Adds the environment loader.
462    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    /// Adds the CLI override loader.
471    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    /// Adds the session override loader.
480    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    /// Replaces the schema used during resolution.
489    pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
490        self.schema = schema;
491        self
492    }
493
494    /// Loads every configured source into concrete layers.
495    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    /// Loads all layers and resolves them into a runtime config.
520    ///
521    /// # Examples
522    ///
523    /// ```
524    /// use osp_cli::config::{ConfigLayer, LoaderPipeline, ResolveOptions, StaticLayerLoader};
525    ///
526    /// let mut defaults = ConfigLayer::default();
527    /// defaults.set("profile.default", "default");
528    /// defaults.set("theme.name", "dracula");
529    ///
530    /// let resolved = LoaderPipeline::new(StaticLayerLoader::new(defaults))
531    ///     .resolve(ResolveOptions::default())
532    ///     .unwrap();
533    ///
534    /// assert_eq!(resolved.get_string("theme.name"), Some("dracula"));
535    /// ```
536    pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
537        let layers = self.load_layers()?;
538        let mut resolver = ConfigResolver::from_loaded_layers(layers);
539        resolver.set_schema(self.schema.clone());
540        resolver.resolve(options)
541    }
542}
543
544fn load_optional_loader(loader: Option<&dyn ConfigLoader>) -> Result<ConfigLayer, ConfigError> {
545    match loader {
546        Some(loader) => loader.load(),
547        None => Ok(ConfigLayer::default()),
548    }
549}
550
551#[cfg(test)]
552mod tests;