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}