Skip to main content

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}