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}