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>>(path: P) -> impl std::future::Future<Output = Result<ServerConfig, PodError>> {
138        let p = path.as_ref().to_path_buf();
139        async move {
140            ConfigLoader::new()
141                .with_defaults()
142                .with_file(p)
143                .load()
144                .await
145        }
146    }
147
148    /// Resolve all sources in order, merge them, deserialise, and
149    /// validate.
150    ///
151    /// `async` for symmetry with JSS's `loadConfig` and to leave room
152    /// for an eventual remote-config source (e.g. Consul, Vault)
153    /// without another breaking change. No `await` points today.
154    pub async fn load(mut self) -> Result<ServerConfig, PodError> {
155        // If no sources were registered at all, inject Defaults so the
156        // merged tree is always complete before the final deser pass.
157        if self.sources.is_empty() {
158            self.sources.push(ConfigSource::Defaults);
159        }
160
161        let mut tree = Value::Object(Default::default());
162
163        for source in &self.sources {
164            let overlay = resolve_source(source)?;
165            merge_json(&mut tree, overlay);
166
167            // Cross-source warning: JSS_STORAGE_TYPE=memory +
168            // JSS_STORAGE_ROOT set. The env loader already dropped the
169            // root value on our side, but we warn the operator.
170            if let ConfigSource::EnvVars = source {
171                let type_is_memory = tree
172                    .get("storage")
173                    .and_then(|s| s.get("type"))
174                    .and_then(|t| t.as_str())
175                    == Some("memory");
176                let root_was_set = std::env::var("JSS_STORAGE_ROOT").is_ok()
177                    || std::env::var("JSS_ROOT").is_ok();
178                if type_is_memory && root_was_set {
179                    self.warnings.push(
180                        "JSS_STORAGE_TYPE=memory with JSS_STORAGE_ROOT/JSS_ROOT set: \
181                         memory backend wins, root ignored"
182                            .to_string(),
183                    );
184                }
185            }
186        }
187
188        // Emit warnings via `tracing` if the operator has a subscriber
189        // installed; no-op otherwise.
190        for w in &self.warnings {
191            tracing::warn!(target: "solid_pod_rs::config", "{w}");
192        }
193
194        let cfg: ServerConfig = serde_json::from_value(tree).map_err(|e| {
195            PodError::Backend(format!("config merge produced invalid shape: {e}"))
196        })?;
197
198        cfg.validate().map_err(PodError::Backend)?;
199
200        Ok(cfg)
201    }
202
203    /// Accessor for emitted warnings. Populated as a side-effect of
204    /// [`Self::load`] if it is called; empty otherwise. Provided so
205    /// test code can assert on warning behaviour without relying on a
206    /// `tracing` subscriber.
207    pub fn warnings(&self) -> &[String] {
208        &self.warnings
209    }
210}
211
212// ---------------------------------------------------------------------------
213// CLI overlay — the top of the precedence stack.
214// ---------------------------------------------------------------------------
215
216/// CLI-derived overlay values. Each field is `Option<_>` so the
217/// operator can leave every flag unset (yielding a no-op overlay).
218///
219/// The binary crate (`solid-pod-rs-server/src/main.rs`) constructs this
220/// from clap-parsed args and passes it to
221/// [`ConfigLoader::with_cli_overlay`]. Framework-agnostic callers can
222/// use the plain struct-literal form.
223///
224/// Sprint 11 (row 121): highest-precedence layer. The field set is the
225/// subset of [`crate::config::schema::ServerConfig`] that CLI
226/// operators routinely override at boot.
227#[derive(Debug, Clone, Default)]
228pub struct CliArgs {
229    pub host: Option<String>,
230    pub port: Option<u16>,
231    pub base_url: Option<String>,
232    pub storage_root: Option<String>,
233    pub storage_type: Option<String>,
234    pub oidc_enabled: Option<bool>,
235    pub oidc_issuer: Option<String>,
236    pub nip98_enabled: Option<bool>,
237    pub base_domain: Option<String>,
238    pub subdomains_enabled: Option<bool>,
239}
240
241impl CliArgs {
242    /// Render as a sparse overlay JSON value. Only fields explicitly set
243    /// appear; everything else is absent so the deep-merge leaves lower
244    /// layers intact.
245    pub(crate) fn to_overlay(&self) -> Value {
246        let mut out = Map::new();
247        let mut server = Map::new();
248        let mut storage = Map::new();
249        let mut auth = Map::new();
250        let mut extras = Map::new();
251
252        if let Some(v) = &self.host {
253            server.insert("host".into(), Value::String(v.clone()));
254        }
255        if let Some(v) = self.port {
256            server.insert("port".into(), Value::Number(v.into()));
257        }
258        if let Some(v) = &self.base_url {
259            server.insert("base_url".into(), Value::String(v.clone()));
260        }
261        if let Some(v) = &self.storage_type {
262            storage.insert("type".into(), Value::String(v.clone()));
263        }
264        if let Some(v) = &self.storage_root {
265            storage.insert("type".into(), Value::String("fs".into()));
266            storage.insert("root".into(), Value::String(v.clone()));
267        }
268        if let Some(v) = self.oidc_enabled {
269            auth.insert("oidc_enabled".into(), Value::Bool(v));
270        }
271        if let Some(v) = &self.oidc_issuer {
272            auth.insert("oidc_issuer".into(), Value::String(v.clone()));
273        }
274        if let Some(v) = self.nip98_enabled {
275            auth.insert("nip98_enabled".into(), Value::Bool(v));
276        }
277        if let Some(v) = &self.base_domain {
278            extras.insert("base_domain".into(), Value::String(v.clone()));
279        }
280        if let Some(v) = self.subdomains_enabled {
281            extras.insert("subdomains_enabled".into(), Value::Bool(v));
282        }
283
284        if !server.is_empty() {
285            out.insert("server".into(), Value::Object(server));
286        }
287        if !storage.is_empty() {
288            out.insert("storage".into(), Value::Object(storage));
289        }
290        if !auth.is_empty() {
291            out.insert("auth".into(), Value::Object(auth));
292        }
293        if !extras.is_empty() {
294            out.insert("extras".into(), Value::Object(extras));
295        }
296
297        Value::Object(out)
298    }
299}