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