use std::path::PathBuf;
use crate::config::{
ConfigError, ConfigLayer, ConfigResolver, ConfigSchema, ConfigValue, ResolveOptions,
ResolvedConfig, core::parse_env_key, store::validate_secrets_permissions, with_path_context,
};
pub trait ConfigLoader: Send + Sync {
fn load(&self) -> Result<ConfigLayer, ConfigError>;
}
fn collect_string_pairs<I, K, V>(vars: I) -> Vec<(String, String)>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
vars.into_iter()
.map(|(key, value)| (key.as_ref().to_string(), value.as_ref().to_string()))
.collect()
}
#[derive(Debug, Clone, Default)]
pub struct StaticLayerLoader {
layer: ConfigLayer,
}
impl StaticLayerLoader {
pub fn new(layer: ConfigLayer) -> Self {
Self { layer }
}
}
impl ConfigLoader for StaticLayerLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
tracing::trace!(
entries = self.layer.entries().len(),
"loaded static config layer"
);
Ok(self.layer.clone())
}
}
#[derive(Debug, Clone)]
#[must_use]
pub struct TomlFileLoader {
path: PathBuf,
missing_ok: bool,
}
impl TomlFileLoader {
pub fn new(path: PathBuf) -> Self {
Self {
path,
missing_ok: true,
}
}
pub fn required(mut self) -> Self {
self.missing_ok = false;
self
}
pub fn optional(mut self) -> Self {
self.missing_ok = true;
self
}
}
impl ConfigLoader for TomlFileLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
tracing::debug!(
path = %self.path.display(),
missing_ok = self.missing_ok,
"loading TOML config layer"
);
if !self.path.exists() {
if self.missing_ok {
tracing::debug!(path = %self.path.display(), "optional TOML config file missing");
return Ok(ConfigLayer::default());
}
return Err(ConfigError::FileRead {
path: self.path.display().to_string(),
reason: "file not found".to_string(),
});
}
let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
path: self.path.display().to_string(),
reason: err.to_string(),
})?;
let mut layer = ConfigLayer::from_toml_str(&raw)
.map_err(|err| with_path_context(self.path.display().to_string(), err))?;
let origin = self.path.display().to_string();
for entry in &mut layer.entries {
entry.origin = Some(origin.clone());
}
tracing::debug!(
path = %self.path.display(),
entries = layer.entries().len(),
"loaded TOML config layer"
);
Ok(layer)
}
}
#[derive(Debug, Clone, Default)]
pub struct EnvVarLoader {
vars: Vec<(String, String)>,
}
impl EnvVarLoader {
pub fn from_process_env() -> Self {
Self {
vars: std::env::vars().collect(),
}
}
pub fn from_pairs<I, K, V>(vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
Self {
vars: collect_string_pairs(vars),
}
}
}
impl<K, V> std::iter::FromIterator<(K, V)> for EnvVarLoader
where
K: AsRef<str>,
V: AsRef<str>,
{
fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
Self {
vars: collect_string_pairs(iter),
}
}
}
impl ConfigLoader for EnvVarLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
let layer =
ConfigLayer::from_env_iter(self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str())))?;
tracing::debug!(
input_vars = self.vars.len(),
entries = layer.entries().len(),
"loaded environment config layer"
);
Ok(layer)
}
}
#[derive(Debug, Clone)]
#[must_use]
pub struct SecretsTomlLoader {
path: PathBuf,
missing_ok: bool,
strict_permissions: bool,
}
impl SecretsTomlLoader {
pub fn new(path: PathBuf) -> Self {
Self {
path,
missing_ok: true,
strict_permissions: true,
}
}
pub fn required(mut self) -> Self {
self.missing_ok = false;
self
}
pub fn optional(mut self) -> Self {
self.missing_ok = true;
self
}
pub fn with_strict_permissions(mut self, strict: bool) -> Self {
self.strict_permissions = strict;
self
}
}
impl ConfigLoader for SecretsTomlLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
tracing::debug!(
path = %self.path.display(),
missing_ok = self.missing_ok,
strict_permissions = self.strict_permissions,
"loading TOML secrets layer"
);
if !self.path.exists() {
if self.missing_ok {
tracing::debug!(path = %self.path.display(), "optional TOML secrets file missing");
return Ok(ConfigLayer::default());
}
return Err(ConfigError::FileRead {
path: self.path.display().to_string(),
reason: "file not found".to_string(),
});
}
validate_secrets_permissions(&self.path, self.strict_permissions)?;
let raw = std::fs::read_to_string(&self.path).map_err(|err| ConfigError::FileRead {
path: self.path.display().to_string(),
reason: err.to_string(),
})?;
let mut layer = ConfigLayer::from_toml_str(&raw)
.map_err(|err| with_path_context(self.path.display().to_string(), err))?;
let origin = self.path.display().to_string();
for entry in &mut layer.entries {
entry.origin = Some(origin.clone());
}
layer.mark_all_secret();
tracing::debug!(
path = %self.path.display(),
entries = layer.entries().len(),
"loaded TOML secrets layer"
);
Ok(layer)
}
}
#[derive(Debug, Clone, Default)]
pub struct EnvSecretsLoader {
vars: Vec<(String, String)>,
}
impl EnvSecretsLoader {
pub fn from_process_env() -> Self {
Self {
vars: std::env::vars().collect(),
}
}
pub fn from_pairs<I, K, V>(vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<str>,
{
Self {
vars: collect_string_pairs(vars),
}
}
}
impl<K, V> std::iter::FromIterator<(K, V)> for EnvSecretsLoader
where
K: AsRef<str>,
V: AsRef<str>,
{
fn from_iter<T: IntoIterator<Item = (K, V)>>(iter: T) -> Self {
Self {
vars: collect_string_pairs(iter),
}
}
}
impl ConfigLoader for EnvSecretsLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
let mut layer = ConfigLayer::default();
for (name, value) in &self.vars {
let Some(rest) = name.strip_prefix("OSP_SECRET__") else {
continue;
};
let synthetic = format!("OSP__{rest}");
let spec = parse_env_key(&synthetic)?;
ConfigSchema::default().validate_writable_key(&spec.key)?;
layer.insert_with_origin(
spec.key,
ConfigValue::String(value.clone()).into_secret(),
spec.scope,
Some(name.clone()),
);
}
tracing::debug!(
input_vars = self.vars.len(),
entries = layer.entries().len(),
"loaded environment secrets layer"
);
Ok(layer)
}
}
#[derive(Default)]
#[must_use]
pub struct ChainedLoader {
loaders: Vec<Box<dyn ConfigLoader>>,
}
impl ChainedLoader {
pub fn new<L>(loader: L) -> Self
where
L: ConfigLoader + 'static,
{
Self {
loaders: vec![Box::new(loader)],
}
}
pub fn with<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.loaders.push(Box::new(loader));
self
}
}
impl ConfigLoader for ChainedLoader {
fn load(&self) -> Result<ConfigLayer, ConfigError> {
let mut merged = ConfigLayer::default();
tracing::debug!(
loader_count = self.loaders.len(),
"loading chained config layer"
);
for loader in &self.loaders {
let layer = loader.load()?;
merged.entries.extend(layer.entries);
}
tracing::debug!(
entries = merged.entries().len(),
"loaded chained config layer"
);
Ok(merged)
}
}
#[derive(Debug, Clone, Default)]
pub struct LoadedLayers {
pub defaults: ConfigLayer,
pub presentation: ConfigLayer,
pub file: ConfigLayer,
pub secrets: ConfigLayer,
pub env: ConfigLayer,
pub cli: ConfigLayer,
pub session: ConfigLayer,
}
#[must_use]
pub struct LoaderPipeline {
defaults: Box<dyn ConfigLoader>,
presentation: Option<Box<dyn ConfigLoader>>,
file: Option<Box<dyn ConfigLoader>>,
secrets: Option<Box<dyn ConfigLoader>>,
env: Option<Box<dyn ConfigLoader>>,
cli: Option<Box<dyn ConfigLoader>>,
session: Option<Box<dyn ConfigLoader>>,
schema: ConfigSchema,
}
impl LoaderPipeline {
pub fn new<L>(defaults: L) -> Self
where
L: ConfigLoader + 'static,
{
Self {
defaults: Box::new(defaults),
presentation: None,
file: None,
secrets: None,
env: None,
cli: None,
session: None,
schema: ConfigSchema::default(),
}
}
pub fn with_file<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.file = Some(Box::new(loader));
self
}
pub fn with_presentation<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.presentation = Some(Box::new(loader));
self
}
pub fn with_secrets<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.secrets = Some(Box::new(loader));
self
}
pub fn with_env<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.env = Some(Box::new(loader));
self
}
pub fn with_cli<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.cli = Some(Box::new(loader));
self
}
pub fn with_session<L>(mut self, loader: L) -> Self
where
L: ConfigLoader + 'static,
{
self.session = Some(Box::new(loader));
self
}
pub fn with_schema(mut self, schema: ConfigSchema) -> Self {
self.schema = schema;
self
}
pub fn load_layers(&self) -> Result<LoadedLayers, ConfigError> {
tracing::debug!("loading config layers");
let layers = LoadedLayers {
defaults: self.defaults.load()?,
presentation: load_optional_loader(self.presentation.as_deref())?,
file: load_optional_loader(self.file.as_deref())?,
secrets: load_optional_loader(self.secrets.as_deref())?,
env: load_optional_loader(self.env.as_deref())?,
cli: load_optional_loader(self.cli.as_deref())?,
session: load_optional_loader(self.session.as_deref())?,
};
tracing::debug!(
defaults = layers.defaults.entries().len(),
presentation = layers.presentation.entries().len(),
file = layers.file.entries().len(),
secrets = layers.secrets.entries().len(),
env = layers.env.entries().len(),
cli = layers.cli.entries().len(),
session = layers.session.entries().len(),
"loaded config layers"
);
Ok(layers)
}
pub fn resolver(&self) -> Result<ConfigResolver, ConfigError> {
let layers = self.load_layers()?;
Ok(ConfigResolver::from_loaded_layers_with_schema(
layers,
self.schema.clone(),
))
}
pub fn resolve(&self, options: ResolveOptions) -> Result<ResolvedConfig, ConfigError> {
self.resolver()?.resolve(options)
}
}
fn load_optional_loader(loader: Option<&dyn ConfigLoader>) -> Result<ConfigLayer, ConfigError> {
match loader {
Some(loader) => loader.load(),
None => Ok(ConfigLayer::default()),
}
}
#[cfg(test)]
mod tests;