rtb_config/config.rs
1//! The typed, layered [`Config`] container.
2
3use std::marker::PhantomData;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use arc_swap::ArcSwap;
8use figment::providers::{Env, Format};
9
10use crate::yaml::Yaml;
11use figment::Figment;
12use serde::de::DeserializeOwned;
13use tokio::sync::watch;
14
15use crate::error::ConfigError;
16
17/// Typed, layered configuration container.
18///
19/// `C` is the caller's `serde::Deserialize` struct describing the
20/// configuration shape. It defaults to `()` so downstream code that
21/// holds an `Arc<Config>` (notably `rtb_app::app::App`) does not
22/// need to carry the type parameter explicitly.
23///
24/// See [`ConfigBuilder`] for the layered construction API and
25/// [`Config::reload`] for the atomic-swap reload flow.
26pub struct Config<C = ()>
27where
28 C: DeserializeOwned + Send + Sync + 'static,
29{
30 current: Arc<ArcSwap<C>>,
31 tx: Arc<watch::Sender<Arc<C>>>,
32 pub(crate) sources: Arc<Sources>,
33}
34
35impl<C> std::fmt::Debug for Config<C>
36where
37 C: DeserializeOwned + Send + Sync + 'static,
38{
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 // Don't render C — it may carry secrets or Debug-incompatible
41 // types. Surface the source inventory instead. `finish_non_exhaustive`
42 // silences `clippy::missing_fields_in_debug` — the stored
43 // value and watch sender are deliberately omitted.
44 f.debug_struct("Config")
45 .field("files", &self.sources.files)
46 .field("env_prefixes", &self.sources.envs)
47 .field("embedded_layers", &self.sources.embedded.len())
48 .finish_non_exhaustive()
49 }
50}
51
52impl<C> Clone for Config<C>
53where
54 C: DeserializeOwned + Send + Sync + 'static,
55{
56 fn clone(&self) -> Self {
57 Self {
58 current: Arc::clone(&self.current),
59 tx: Arc::clone(&self.tx),
60 sources: Arc::clone(&self.sources),
61 }
62 }
63}
64
65impl<C> Default for Config<C>
66where
67 C: DeserializeOwned + Default + Send + Sync + 'static,
68{
69 fn default() -> Self {
70 let initial = Arc::new(C::default());
71 let (tx, _rx) = watch::channel(Arc::clone(&initial));
72 Self {
73 current: Arc::new(ArcSwap::from(initial)),
74 tx: Arc::new(tx),
75 sources: Arc::new(Sources::default()),
76 }
77 }
78}
79
80impl<C> Config<C>
81where
82 C: DeserializeOwned + Send + Sync + 'static,
83{
84 /// Start a new builder for layered construction.
85 pub fn builder() -> ConfigBuilder<C> {
86 ConfigBuilder::new()
87 }
88
89 /// Construct a [`Config`] holding `value` directly, with no
90 /// figment sources behind it.
91 ///
92 /// Primarily useful in tests and for the
93 /// `rtb_test_support::TestAppBuilder::config_value` shortcut —
94 /// production tools should reach for [`Self::builder`] so layered
95 /// defaults / user files / env vars all participate.
96 ///
97 /// Calling [`Self::reload`] on the result re-parses the empty
98 /// source set, which (for `C: Default`-shaped types) overwrites
99 /// the stored value with `C::default()` and (for everything
100 /// else) returns a parse error. Tests that need stable values
101 /// across a reload should use [`Self::builder`] with an
102 /// `embedded_default`.
103 #[must_use]
104 pub fn with_value(value: C) -> Self {
105 let initial = Arc::new(value);
106 let (tx, _rx) = watch::channel(Arc::clone(&initial));
107 Self {
108 current: Arc::new(ArcSwap::from(initial)),
109 tx: Arc::new(tx),
110 sources: Arc::new(Sources::default()),
111 }
112 }
113
114 /// Snapshot the currently-stored value. Cheap — no parse.
115 ///
116 /// Calls that hold the returned `Arc<C>` across a [`Self::reload`]
117 /// see the pre-reload value; the next `get()` observes the
118 /// post-reload value. There is no tearing.
119 #[must_use]
120 pub fn get(&self) -> Arc<C> {
121 self.current.load_full()
122 }
123
124 /// Re-read every registered source and atomically swap the stored
125 /// value.
126 ///
127 /// Errors leave the stored value untouched.
128 pub fn reload(&self) -> Result<(), ConfigError> {
129 let parsed = Arc::new(self.sources.parse::<C>()?);
130 self.current.store(Arc::clone(&parsed));
131 // `send_replace` (not `send`) — it unconditionally overwrites
132 // the stored watch value, so a late `subscribe()` after the
133 // last receiver was dropped still observes the newest
134 // value. `send` would return SendError and leave the stale
135 // initial value in the channel.
136 self.tx.send_replace(parsed);
137 Ok(())
138 }
139
140 /// Subscribe to configuration changes.
141 ///
142 /// The returned [`watch::Receiver`] sees the current value
143 /// immediately via [`watch::Receiver::borrow`]; each successful
144 /// [`Self::reload`] wakes `.changed().await`. Failed reloads
145 /// don't wake subscribers — callers only ever observe values that
146 /// are also stored in [`Self::get`].
147 #[must_use]
148 pub fn subscribe(&self) -> watch::Receiver<Arc<C>> {
149 self.tx.subscribe()
150 }
151}
152
153/// Retained copies of the sources the builder registered, used by
154/// [`Config::reload`] to re-parse on demand.
155#[derive(Default)]
156pub(crate) struct Sources {
157 embedded: Vec<&'static str>,
158 pub(crate) files: Vec<PathBuf>,
159 envs: Vec<String>,
160}
161
162impl Sources {
163 /// Build a `Figment` from the retained sources and deserialise
164 /// into `C`.
165 fn parse<C: DeserializeOwned>(&self) -> Result<C, ConfigError> {
166 let mut figment = Figment::new();
167 for yaml in &self.embedded {
168 figment = figment.merge(Yaml::string(yaml));
169 }
170 for path in &self.files {
171 // Format::file is a no-op for absent
172 // files by design (Kind::NotFound is silently ignored).
173 // A path that exists but is unreadable (e.g. a directory)
174 // surfaces here as a figment::Error, which we map via the
175 // From impl.
176 if path.exists() && !path.is_file() {
177 return Err(ConfigError::Io {
178 path: path.clone(),
179 source: std::io::Error::new(
180 std::io::ErrorKind::InvalidInput,
181 "config path is not a regular file",
182 ),
183 });
184 }
185 figment = figment.merge(Yaml::file(path));
186 }
187 for prefix in &self.envs {
188 // `.split("_")` tells figment to interpret the underscore
189 // as a key-nesting delimiter. Without it, `FOO_HTTP_PORT`
190 // would be a flat key; with it, it becomes `http.port`.
191 figment = figment.merge(Env::prefixed(prefix).split("_"));
192 }
193 let parsed: C = figment.extract()?;
194 Ok(parsed)
195 }
196}
197
198/// Fluent builder for [`Config`].
199///
200/// Source kinds merge in a fixed order, embedded defaults then user files
201/// then environment variables, whatever order the methods are called in;
202/// within one kind the last registration wins. The reason is in [layering and precedence](https://config.rust.phpboyscout.uk/explanation/layering-and-precedence/#why-the-order-is-fixed-rather-than-the-order-you-register).
203#[must_use]
204pub struct ConfigBuilder<C>
205where
206 C: DeserializeOwned + Send + Sync + 'static,
207{
208 sources: Sources,
209 // `PhantomData<fn() -> C>` rather than `PhantomData<C>` gives us
210 // the right variance (covariant in C, never holding a C value)
211 // without tripping drop-check invariants. `fn() -> C` is a
212 // function-pointer type, which is always `Send + Sync` even when
213 // C is neither — a useful property for a marker.
214 _phantom: PhantomData<fn() -> C>,
215}
216
217impl<C> Default for ConfigBuilder<C>
218where
219 C: DeserializeOwned + Send + Sync + 'static,
220{
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226impl<C> ConfigBuilder<C>
227where
228 C: DeserializeOwned + Send + Sync + 'static,
229{
230 /// Construct an empty builder. Equivalent to
231 /// [`Config::builder`].
232 pub fn new() -> Self {
233 Self { sources: Sources::default(), _phantom: PhantomData }
234 }
235
236 /// Add a YAML string baked into the binary as the lowest-priority
237 /// layer.
238 pub fn embedded_default(mut self, yaml: &'static str) -> Self {
239 self.sources.embedded.push(yaml);
240 self
241 }
242
243 /// Add a YAML file on disk. Missing files are *not* an error —
244 /// they contribute no keys. Present but malformed YAML *is* an
245 /// error. See [`ConfigError::Io`] for the distinction.
246 pub fn user_file(mut self, path: impl Into<PathBuf>) -> Self {
247 self.sources.files.push(path.into());
248 self
249 }
250
251 /// Add an environment-variable source with the given prefix.
252 ///
253 /// Prefix translation follows figment's `Env::prefixed` — the
254 /// prefix is stripped and the remainder is lower-cased; underscore
255 /// is the key separator, so `MYTOOL_HTTP_PORT` populates
256 /// `http.port` on a nested config struct.
257 pub fn env_prefixed(mut self, prefix: impl Into<String>) -> Self {
258 self.sources.envs.push(prefix.into());
259 self
260 }
261
262 /// Finalise construction: parse all layers and wrap the result in
263 /// a [`Config`].
264 pub fn build(self) -> Result<Config<C>, ConfigError> {
265 let parsed = Arc::new(self.sources.parse::<C>()?);
266 let (tx, _rx) = watch::channel(Arc::clone(&parsed));
267 Ok(Config {
268 current: Arc::new(ArcSwap::from(parsed)),
269 tx: Arc::new(tx),
270 sources: Arc::new(self.sources),
271 })
272 }
273}