Skip to main content

solid_pod_rs/config/
loader.rs

1//! Layered config loader.
2//!
3//! Precedence (later overrides earlier):
4//!
5//! ```text
6//! Defaults < File < EnvVars < CLI
7//! ```
8//!
9//! Matches JSS `src/config.js:211-239`. Sprint 11 (row 120-124) closes
10//! the remaining gap by adding the CLI overlay, YAML/TOML file support
11//! (via the `config-loader` feature), and the full JSS env-var map.
12//!
13//! The loader:
14//!
15//! 1. Walks the registered sources in order.
16//! 2. Resolves each into a `serde_json::Value` tree.
17//! 3. Deep-merges each overlay into the accumulator.
18//! 4. Deserialises into [`ServerConfig`].
19//! 5. Runs [`ServerConfig::validate`] and returns the snapshot.
20//!
21//! Unknown JSON fields are tolerated (every sub-struct uses
22//! `#[serde(default)]`), matching the "forward-compat with newer JSS
23//! releases" invariant in the bounded-context doc.
24
25use std::path::{Path, PathBuf};
26
27use serde_json::{Map, Value};
28
29use crate::config::schema::ServerConfig;
30use crate::config::sources::{merge_json, resolve_source, ConfigSource};
31use crate::error::PodError;
32
33// ---------------------------------------------------------------------------
34// ConfigLoader
35// ---------------------------------------------------------------------------
36
37/// Builder for a layered config load.
38///
39/// Sources are applied in the order they were registered. The typical
40/// JSS-parity invocation is:
41///
42/// ```no_run
43/// use solid_pod_rs::config::ConfigLoader;
44///
45/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
46/// let cfg = ConfigLoader::new()
47///     .with_defaults()
48///     .with_file("config.json")
49///     .with_env()
50///     .load()
51///     .await?;
52/// # Ok(()) }
53/// ```
54#[derive(Clone)]
55pub struct ConfigLoader {
56    sources: Vec<ConfigSource>,
57    warnings: Vec<String>,
58}
59
60impl Default for ConfigLoader {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl ConfigLoader {
67    /// Empty loader — add sources explicitly. Prefer
68    /// [`Self::with_defaults`] as the first call so the final snapshot
69    /// is always fully populated.
70    pub fn new() -> Self {
71        Self {
72            sources: Vec::new(),
73            warnings: Vec::new(),
74        }
75    }
76
77    /// Register the hard-coded defaults as the lowest-precedence
78    /// layer. Idempotent — calling twice has no additional effect.
79    pub fn with_defaults(mut self) -> Self {
80        if !self
81            .sources
82            .iter()
83            .any(|s| matches!(s, ConfigSource::Defaults))
84        {
85            self.sources.push(ConfigSource::Defaults);
86        }
87        self
88    }
89
90    /// Register a config file source. Format is auto-detected from the
91    /// extension: `.json` (always supported), `.yaml`/`.yml`, `.toml`
92    /// (requires the `config-loader` feature). Missing / malformed
93    /// files are a hard error at load time.
94    pub fn with_file(mut self, path: impl Into<PathBuf>) -> Self {
95        self.sources.push(ConfigSource::File(path.into()));
96        self
97    }
98
99    /// Register the process environment as a source. Reads `JSS_*`
100    /// vars via [`std::env::var`].
101    pub fn with_env(mut self) -> Self {
102        self.sources.push(ConfigSource::EnvVars);
103        self
104    }
105
106    /// Builder alias matching Sprint 11 naming — mutates the loader
107    /// in-place and returns `&mut Self` so operator scripts can chain
108    /// overlays without rebinding. Equivalent to [`Self::with_env`] on
109    /// a mutable loader.
110    pub fn with_env_overlay(&mut self) -> &mut Self {
111        if !self
112            .sources
113            .iter()
114            .any(|s| matches!(s, ConfigSource::EnvVars))
115        {
116            self.sources.push(ConfigSource::EnvVars);
117        }
118        self
119    }
120
121    /// Register a CLI args overlay as the highest-precedence layer.
122    ///
123    /// Precedence: Defaults < File < Env < **CLI**.
124    ///
125    /// The binary crate (clap) is the canonical caller; passing
126    /// [`CliArgs::default()`] is a no-op overlay (every field `None`).
127    pub fn with_cli_overlay(&mut self, args: &CliArgs) -> &mut Self {
128        self.sources
129            .push(ConfigSource::CliOverlay(args.to_overlay()));
130        self
131    }
132
133    /// Load a config snapshot directly from a single file path,
134    /// bypassing the builder. Format auto-detected from extension. This
135    /// is the Sprint 11 row 120 one-shot helper — equivalent to
136    /// `ConfigLoader::new().with_defaults().with_file(path).load()`.
137    pub fn from_file<P: AsRef<Path>>(
138        path: P,
139    ) -> impl std::future::Future<Output = Result<ServerConfig, PodError>> {
140        let p = path.as_ref().to_path_buf();
141        async move {
142            ConfigLoader::new()
143                .with_defaults()
144                .with_file(p)
145                .load()
146                .await
147        }
148    }
149
150    /// Resolve all sources in order, merge them, deserialise, and
151    /// validate.
152    ///
153    /// `async` for symmetry with JSS's `loadConfig` and to leave room
154    /// for an eventual remote-config source (e.g. Consul, Vault)
155    /// without another breaking change. No `await` points today.
156    pub async fn load(mut self) -> Result<ServerConfig, PodError> {
157        // If no sources were registered at all, inject Defaults so the
158        // merged tree is always complete before the final deser pass.
159        if self.sources.is_empty() {
160            self.sources.push(ConfigSource::Defaults);
161        }
162
163        let mut tree = Value::Object(Default::default());
164
165        for source in &self.sources {
166            let overlay = resolve_source(source)?;
167            merge_json(&mut tree, overlay);
168
169            // Cross-source warning: JSS_STORAGE_TYPE=memory +
170            // JSS_STORAGE_ROOT set. The env loader already dropped the
171            // root value on our side, but we warn the operator.
172            if let ConfigSource::EnvVars = source {
173                let type_is_memory = tree
174                    .get("storage")
175                    .and_then(|s| s.get("type"))
176                    .and_then(|t| t.as_str())
177                    == Some("memory");
178                let root_was_set =
179                    std::env::var("JSS_STORAGE_ROOT").is_ok() || std::env::var("JSS_ROOT").is_ok();
180                if type_is_memory && root_was_set {
181                    self.warnings.push(
182                        "JSS_STORAGE_TYPE=memory with JSS_STORAGE_ROOT/JSS_ROOT set: \
183                         memory backend wins, root ignored"
184                            .to_string(),
185                    );
186                }
187            }
188        }
189
190        // Emit warnings via `tracing` if the operator has a subscriber
191        // installed; no-op otherwise.
192        for w in &self.warnings {
193            tracing::warn!(target: "solid_pod_rs::config", "{w}");
194        }
195
196        let cfg: ServerConfig = serde_json::from_value(tree)
197            .map_err(|e| PodError::Backend(format!("config merge produced invalid shape: {e}")))?;
198
199        cfg.validate().map_err(PodError::Backend)?;
200
201        Ok(cfg)
202    }
203
204    /// Accessor for emitted warnings. Populated as a side-effect of
205    /// [`Self::load`] if it is called; empty otherwise. Provided so
206    /// test code can assert on warning behaviour without relying on a
207    /// `tracing` subscriber.
208    pub fn warnings(&self) -> &[String] {
209        &self.warnings
210    }
211}
212
213// ---------------------------------------------------------------------------
214// CLI overlay — the top of the precedence stack.
215// ---------------------------------------------------------------------------
216
217/// CLI-derived overlay values. Each field is `Option<_>` so the
218/// operator can leave every flag unset (yielding a no-op overlay).
219///
220/// The binary crate (`solid-pod-rs-server/src/main.rs`) constructs this
221/// from clap-parsed args and passes it to
222/// [`ConfigLoader::with_cli_overlay`]. Framework-agnostic callers can
223/// use the plain struct-literal form.
224///
225/// Sprint 11 (row 121): highest-precedence layer. The field set is the
226/// subset of [`crate::config::schema::ServerConfig`] that CLI
227/// operators routinely override at boot.
228#[derive(Debug, Clone, Default)]
229pub struct CliArgs {
230    pub host: Option<String>,
231    pub port: Option<u16>,
232    pub base_url: Option<String>,
233    pub storage_root: Option<String>,
234    pub storage_type: Option<String>,
235    pub oidc_enabled: Option<bool>,
236    pub oidc_issuer: Option<String>,
237    pub nip98_enabled: Option<bool>,
238    pub base_domain: Option<String>,
239    pub subdomains_enabled: Option<bool>,
240}
241
242impl CliArgs {
243    /// Render as a sparse overlay JSON value. Only fields explicitly set
244    /// appear; everything else is absent so the deep-merge leaves lower
245    /// layers intact.
246    pub(crate) fn to_overlay(&self) -> Value {
247        let mut out = Map::new();
248        let mut server = Map::new();
249        let mut storage = Map::new();
250        let mut auth = Map::new();
251        let mut extras = Map::new();
252
253        if let Some(v) = &self.host {
254            server.insert("host".into(), Value::String(v.clone()));
255        }
256        if let Some(v) = self.port {
257            server.insert("port".into(), Value::Number(v.into()));
258        }
259        if let Some(v) = &self.base_url {
260            server.insert("base_url".into(), Value::String(v.clone()));
261        }
262        if let Some(v) = &self.storage_type {
263            storage.insert("type".into(), Value::String(v.clone()));
264        }
265        if let Some(v) = &self.storage_root {
266            storage.insert("type".into(), Value::String("fs".into()));
267            storage.insert("root".into(), Value::String(v.clone()));
268        }
269        if let Some(v) = self.oidc_enabled {
270            auth.insert("oidc_enabled".into(), Value::Bool(v));
271        }
272        if let Some(v) = &self.oidc_issuer {
273            auth.insert("oidc_issuer".into(), Value::String(v.clone()));
274        }
275        if let Some(v) = self.nip98_enabled {
276            auth.insert("nip98_enabled".into(), Value::Bool(v));
277        }
278        if let Some(v) = &self.base_domain {
279            extras.insert("base_domain".into(), Value::String(v.clone()));
280        }
281        if let Some(v) = self.subdomains_enabled {
282            extras.insert("subdomains_enabled".into(), Value::Bool(v));
283        }
284
285        if !server.is_empty() {
286            out.insert("server".into(), Value::Object(server));
287        }
288        if !storage.is_empty() {
289            out.insert("storage".into(), Value::Object(storage));
290        }
291        if !auth.is_empty() {
292            out.insert("auth".into(), Value::Object(auth));
293        }
294        if !extras.is_empty() {
295            out.insert("extras".into(), Value::Object(extras));
296        }
297
298        Value::Object(out)
299    }
300}