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}