Skip to main content

siderite_config/
builder.rs

1//! Layered configuration builder.
2
3use crate::error::ConfigError;
4use crate::explain::ConfigEntry;
5use crate::settings::{Settings, default_values};
6use figment::Figment;
7use figment::providers::{Env, Format, Serialized, Toml};
8use figment::value::UncasedStr;
9use serde::Serialize;
10use serde::de::DeserializeOwned;
11use std::path::{Path, PathBuf};
12
13/// Optional TOML file consulted by [`crate::load`].
14pub const DEFAULT_CONFIG_FILE: &str = "siderite.toml";
15
16/// Environment prefix used by [`crate::load`] (`SIDERITE_APP__NAME`, …).
17pub const DEFAULT_ENV_PREFIX: &str = "SIDERITE_";
18
19/// Builds [`Settings`] from defaults, files, environment variables, and
20/// programmatic overrides.
21///
22/// Precedence, lowest to highest: defaults, TOML file, environment (including
23/// `DATABASE_URL` / `ADDR` aliases when [`Self::env_prefix`] is used),
24/// [`Self::set`]. Overrides win regardless of call order; files and the
25/// environment merge in the order they are added.
26///
27/// Nested environment keys use `__` as the separator after the prefix is
28/// stripped: `SIDERITE_DATABASES__DEFAULT__URL` maps to
29/// `databases.default.url`.
30pub struct ConfigBuilder {
31    figment: Figment,
32    /// Programmatic overrides, merged last at extraction time.
33    overrides: Figment,
34    required_files: Vec<PathBuf>,
35}
36
37impl Default for ConfigBuilder {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl ConfigBuilder {
44    /// Defaults only: `app.name = "siderite"`, `server.addr = "127.0.0.1:8000"`,
45    /// `log.level = "info"`, `cache.max_entries = 1024`, empty `databases`.
46    #[must_use]
47    pub fn new() -> Self {
48        Self {
49            figment: Figment::new().merge(Serialized::defaults(default_values())),
50            overrides: Figment::new(),
51            required_files: Vec::new(),
52        }
53    }
54
55    /// Merge a TOML file that must exist at extraction time.
56    #[must_use]
57    pub fn file(self, path: impl AsRef<Path>) -> Self {
58        let path = path.as_ref().to_path_buf();
59        let mut required_files = self.required_files;
60        required_files.push(path.clone());
61        Self {
62            figment: self.figment.merge(Toml::file(&path)),
63            required_files,
64            ..self
65        }
66    }
67
68    /// Merge a TOML file; a missing file is ignored.
69    #[must_use]
70    pub fn file_optional(self, path: impl AsRef<Path>) -> Self {
71        Self {
72            figment: self.figment.merge(Toml::file(path.as_ref())),
73            ..self
74        }
75    }
76
77    /// Merge environment variables with `prefix`, nested on `__`.
78    ///
79    /// Also maps the unprefixed aliases `DATABASE_URL` → `databases.default.url`
80    /// and `ADDR` → `server.addr`. Prefixed variables override those aliases.
81    #[must_use]
82    pub fn env_prefix(self, prefix: &str) -> Self {
83        Self {
84            figment: self
85                .figment
86                .merge(well_known_env())
87                .merge(Env::prefixed(prefix).split("__")),
88            ..self
89        }
90    }
91
92    /// Override a dotted key (`"server.addr"`, `"app.debug"`, …). Overrides
93    /// take precedence over every file and environment source, whenever they
94    /// were added; among overrides, later [`Self::set`] calls win.
95    #[must_use]
96    pub fn set(self, key: &str, value: impl Serialize) -> Self {
97        Self {
98            overrides: self.overrides.merge(Serialized::default(key, value)),
99            ..self
100        }
101    }
102
103    /// Deserialize the merged configuration into `T`.
104    ///
105    /// # Errors
106    /// Returns [`ConfigError::MissingFile`] if a required file is absent, or
107    /// [`ConfigError::Extract`] if the merged tree cannot be deserialized.
108    /// Extract messages never include secret values.
109    pub fn extract<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
110        self.ensure_required_files()?;
111        self.figment
112            .clone()
113            .merge(self.overrides.clone())
114            .extract()
115            .map_err(ConfigError::from)
116    }
117
118    /// Deserialize the merged configuration as [`Settings`].
119    ///
120    /// # Errors
121    /// Same as [`Self::extract`].
122    pub fn build(&self) -> Result<Settings, ConfigError> {
123        self.extract()
124    }
125
126    /// Every resolved value with the source that set it (a default, a
127    /// file, an environment variable or an override), redacted, sorted by
128    /// key. It reads the same merged sources as [`Self::build`].
129    ///
130    /// # Errors
131    /// [`ConfigError::MissingFile`] for a missing required file.
132    pub fn explain(&self) -> Result<Vec<ConfigEntry>, ConfigError> {
133        self.ensure_required_files()?;
134        let merged = self.figment.clone().merge(self.overrides.clone());
135        Ok(crate::explain::entries(&merged, &self.overrides))
136    }
137
138    fn ensure_required_files(&self) -> Result<(), ConfigError> {
139        for path in &self.required_files {
140            if !path.exists() {
141                return Err(ConfigError::MissingFile(path.clone()));
142            }
143        }
144        Ok(())
145    }
146}
147
148/// Unprefixed environment variables and the keys they set.
149pub(crate) const ALIASES: [(&str, &str); 2] = [
150    ("DATABASE_URL", "databases.default.url"),
151    ("ADDR", "server.addr"),
152];
153
154fn well_known_env() -> Env {
155    Env::raw().filter_map(|key: &UncasedStr| {
156        let alias = ALIASES.iter().find(|(var, _)| key == *var);
157        alias.map(|(_, path)| (*path).into())
158    })
159}
160
161/// Load [`Settings`] from optional `siderite.toml`, `SIDERITE_` environment
162/// variables, and the `DATABASE_URL` / `ADDR` aliases.
163///
164/// # Errors
165/// Returns [`ConfigError::Extract`] when the merged configuration cannot be
166/// deserialized.
167pub fn load() -> Result<Settings, ConfigError> {
168    ConfigBuilder::new()
169        .file_optional(DEFAULT_CONFIG_FILE)
170        .env_prefix(DEFAULT_ENV_PREFIX)
171        .build()
172}