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};
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}