ferro_json_ui/loader.rs
1//! # Page Loader
2//!
3//! Runtime file-loading pipeline for v2 JSON-UI specs (Phase 119).
4//!
5//! This module exposes `load_cached`, a cache-aware loader that:
6//!
7//! 1. Canonicalizes the input path (`fs::canonicalize`) — used as the cache key.
8//! 2. Reads the file contents (`fs::read_to_string`).
9//! 3. Parses into a `Spec` via `Spec::from_json` (structural validation).
10//! 4. Validates against `global_catalog().validate(&spec)` (component + envelope).
11//! 5. Inserts `(Arc<Spec>, mtime)` into a process-level `OnceLock<RwLock<HashMap>>`.
12//!
13//! In production (`reload_if_changed = false`), entries are never evicted after
14//! first load. In development (`reload_if_changed = true`), each request performs
15//! a single `fs::metadata(path).modified()` syscall — if the mtime has advanced
16//! past the cached mtime, the entry is reloaded. No background thread, no
17//! `notify` crate (119-CONTEXT D-03).
18//!
19//! Errors are returned as [`LoadError`] with three variants: `Io` (read or
20//! canonicalize), `Parse` (structural), `Catalog` (component schema).
21//!
22//! The cache follows the same `OnceLock<RwLock<...>>` pattern used by
23//! [`crate::catalog::global_catalog`] and [`crate::layout::global_registry`].
24
25use std::collections::HashMap;
26use std::fs;
27use std::path::{Path, PathBuf};
28use std::sync::{Arc, OnceLock, RwLock};
29use std::time::SystemTime;
30
31use thiserror::Error;
32
33use crate::catalog::{global_catalog, CatalogError};
34use crate::spec::{Spec, SpecError};
35
36// D-16: tracing for load-time catalog warnings.
37// Catalog (enum-shape) validation is downgraded to tracing::warn at load time;
38// hard enforcement moves to per-request JsonUi::resolve after expand_directives.
39
40// ── Errors ────────────────────────────────────────────────────────────────────
41
42/// Errors returned by [`load_cached`] and related loader entry points.
43///
44/// Three variants track the three failure modes of the load pipeline:
45/// - [`LoadError::Io`] — filesystem read or canonicalize failure (missing path,
46/// permission denied, etc.).
47/// - [`LoadError::Parse`] — the file is present but its contents are not a
48/// structurally valid v2 Spec. Wraps [`SpecError`].
49/// - [`LoadError::Catalog`] — the spec parses structurally but fails catalog
50/// validation (unknown component type, invalid props, etc.). Wraps a
51/// `Vec<CatalogError>`; note that `Vec<CatalogError>` does NOT implement
52/// `std::error::Error`, so `#[from]` cannot be used for this variant.
53#[derive(Debug, Error)]
54pub enum LoadError {
55 /// Filesystem read failure (including canonicalize failure for missing paths).
56 #[error("failed to read spec file: {0}")]
57 Io(#[from] std::io::Error),
58
59 /// Spec fails structural validation (JSON syntax, duplicate IDs, cycles, etc.).
60 #[error("failed to parse spec: {0}")]
61 Parse(#[from] SpecError),
62
63 /// Spec is structurally valid but fails catalog validation.
64 #[error("spec failed catalog validation: {0:?}")]
65 Catalog(Vec<CatalogError>),
66}
67
68// ── Cache ─────────────────────────────────────────────────────────────────────
69
70type SpecCache = HashMap<PathBuf, (Arc<Spec>, SystemTime)>;
71
72static SPEC_CACHE: OnceLock<RwLock<SpecCache>> = OnceLock::new();
73
74/// Access the process-level spec cache.
75///
76/// Keyed by canonical PathBuf. Value is `(Arc<Spec>, mtime)` so the cached
77/// spec can be cheaply cloned into per-request handlers without duplicating
78/// the element HashMap.
79fn global_spec_cache() -> &'static RwLock<SpecCache> {
80 SPEC_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
81}
82
83/// Best-effort mtime — returns `UNIX_EPOCH` on platforms or filesystems that
84/// do not report modification time. In dev mode this conservatively forces a
85/// reload on the next access (119-RESEARCH §Pitfall 5).
86fn current_mtime(path: &Path) -> SystemTime {
87 fs::metadata(path)
88 .and_then(|m| m.modified())
89 .unwrap_or(SystemTime::UNIX_EPOCH)
90}
91
92// ── Public API ────────────────────────────────────────────────────────────────
93
94/// Load a spec from `path`, using the process-level cache.
95///
96/// # Parameters
97///
98/// - `path` — filesystem path to a v2 JSON-UI spec file.
99/// - `reload_if_changed` — when `true`, checks the file's mtime against the
100/// cached mtime; if the file has changed, evicts and reloads. Set this from
101/// `!Config::is_production()` at the framework integration layer.
102///
103/// # Returns
104///
105/// `Ok(Arc<Spec>)` on cache hit or successful load. The `Arc` is cloned from
106/// the cache entry — callers who need an owned `Spec` (e.g. for
107/// `Spec::merge_data`) must call `(*arc).clone()` to get a `Spec`
108/// (119-RESEARCH §Pitfall 1).
109///
110/// # Errors
111///
112/// - [`LoadError::Io`] — file missing, unreadable, or canonicalize fails.
113/// - [`LoadError::Parse`] — file contents are not a structurally valid Spec.
114/// - [`LoadError::Catalog`] — spec parses but fails catalog validation.
115///
116/// # Concurrency
117///
118/// Reads use a shared read lock. Writes (first load, reload) briefly acquire
119/// a write lock AFTER parsing and validation have completed — no fallible
120/// code runs inside the write guard, so the lock cannot be poisoned by a
121/// panic-throwing parser (119-RESEARCH §Pitfall 2).
122pub fn load_cached(path: &Path, reload_if_changed: bool) -> Result<Arc<Spec>, LoadError> {
123 let canonical = fs::canonicalize(path)?;
124
125 // Fast path: read lock.
126 {
127 let cache = global_spec_cache()
128 .read()
129 .expect("spec cache RwLock poisoned");
130 if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
131 if !reload_if_changed {
132 return Ok(Arc::clone(arc_spec));
133 }
134 let current = current_mtime(&canonical);
135 if current <= *cached_mtime {
136 return Ok(Arc::clone(arc_spec));
137 }
138 // mtime advanced — fall through to reload path.
139 }
140 }
141
142 // Miss or stale: parse + validate outside any lock.
143 let content = fs::read_to_string(&canonical)?;
144 let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
145
146 // D-16: Catalog (enum-shape) validation at load time becomes a WARNING.
147 // Hard enforcement moves to per-request `JsonUi::resolve`, AFTER
148 // `expand_directives`, so $if-gated elements with shape-invalid props
149 // (e.g. Alert.variant="" gated by visible) don't fail at startup.
150 //
151 // Structural errors (footer IDs, element references, depth) are still
152 // caught hard by `Spec::from_json` above.
153 if let Err(errs) = global_catalog().validate(&spec) {
154 for e in &errs {
155 tracing::warn!(
156 target: "ferro_json_ui::catalog",
157 spec = %canonical.display(),
158 error = %e,
159 "load-time catalog warning (deferred to render-time enforcement)"
160 );
161 }
162 }
163
164 let mtime = current_mtime(&canonical);
165 let arc_spec = Arc::new(spec);
166
167 // Write lock holds only for the insert — no fallible code inside.
168 global_spec_cache()
169 .write()
170 .expect("spec cache RwLock poisoned")
171 .insert(canonical, (Arc::clone(&arc_spec), mtime));
172
173 Ok(arc_spec)
174}
175
176// ── Tests ─────────────────────────────────────────────────────────────────────
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::catalog::Catalog;
182 use std::io::Write;
183 use std::path::PathBuf;
184
185 /// Write `content` to a unique tempfile and return its path.
186 ///
187 /// Uses `std::env::temp_dir()` with a uniquifier derived from a static
188 /// counter so concurrent tests do not collide. We do not pull in the
189 /// `tempfile` crate — ferro-json-ui has no dev-dependency on it and
190 /// Phase 119 forbids new deps.
191 fn write_temp(name: &str, content: &str) -> PathBuf {
192 use std::sync::atomic::{AtomicU64, Ordering};
193 static COUNTER: AtomicU64 = AtomicU64::new(0);
194 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
195 let mut path = std::env::temp_dir();
196 path.push(format!("ferro-json-ui-loader-{name}-{n}.json"));
197 let mut f = std::fs::File::create(&path).expect("create tempfile");
198 f.write_all(content.as_bytes()).expect("write tempfile");
199 f.sync_all().expect("sync tempfile");
200 path
201 }
202
203 /// Test variant of `load_cached` that validates against built-in components only.
204 ///
205 /// Uses `Catalog::build_builtins_only()` to avoid global plugin registry
206 /// pollution from `build_discovers_plugins_and_rejects_invalid_schema`
207 /// (which registers `BadPlugin_117`). Production code always uses
208 /// `global_catalog()`.
209 fn load_builtins(path: &Path, reload_if_changed: bool) -> Result<Arc<Spec>, LoadError> {
210 let canonical = fs::canonicalize(path)?;
211 {
212 let cache = global_spec_cache().read().expect("spec cache poisoned");
213 if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
214 if !reload_if_changed {
215 return Ok(Arc::clone(arc_spec));
216 }
217 let current = current_mtime(&canonical);
218 if current <= *cached_mtime {
219 return Ok(Arc::clone(arc_spec));
220 }
221 }
222 }
223 let content = fs::read_to_string(&canonical)?;
224 let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
225 Catalog::build_builtins_only()
226 .map_err(|e| LoadError::Catalog(vec![e]))?
227 .validate(&spec)
228 .map_err(LoadError::Catalog)?;
229 let mtime = current_mtime(&canonical);
230 let arc_spec = Arc::new(spec);
231 global_spec_cache()
232 .write()
233 .expect("spec cache poisoned")
234 .insert(canonical, (Arc::clone(&arc_spec), mtime));
235 Ok(arc_spec)
236 }
237
238 const VALID_SPEC: &str = r#"{
239 "$schema": "ferro-json-ui/v2",
240 "root": "r",
241 "elements": { "r": { "type": "Text", "props": { "content": "hi" } } }
242 }"#;
243
244 const VALID_SPEC_ALT: &str = r#"{
245 "$schema": "ferro-json-ui/v2",
246 "root": "other",
247 "elements": { "other": { "type": "Text", "props": { "content": "changed" } } }
248 }"#;
249
250 #[test]
251 fn load_spec_valid() {
252 let path = write_temp("valid", VALID_SPEC);
253 let spec = load_builtins(&path, false).expect("valid spec should load");
254 assert_eq!(spec.root, "r");
255 }
256
257 #[test]
258 fn load_spec_invalid_json() {
259 let path = write_temp("invalid-json", "{ not valid json");
260 let err = load_builtins(&path, false).expect_err("must fail");
261 assert!(
262 matches!(err, LoadError::Parse(_)),
263 "expected Parse, got {err:?}"
264 );
265 }
266
267 #[test]
268 fn load_spec_catalog_error() {
269 // Spec parses structurally but the component type is unknown.
270 let unknown = r#"{
271 "$schema": "ferro-json-ui/v2",
272 "root": "r",
273 "elements": { "r": { "type": "NotARealComponent_119_loader" } }
274 }"#;
275 let path = write_temp("unknown-type", unknown);
276 let err = load_builtins(&path, false).expect_err("must fail");
277 match err {
278 LoadError::Catalog(errs) => {
279 assert!(!errs.is_empty(), "catalog errors must be non-empty")
280 }
281 other => panic!("expected Catalog, got {other:?}"),
282 }
283 }
284
285 #[test]
286 fn load_spec_missing_file() {
287 let path = PathBuf::from("/nonexistent/path-119-loader-test-does-not-exist.json");
288 let err = load_builtins(&path, false).expect_err("must fail");
289 assert!(matches!(err, LoadError::Io(_)), "expected Io, got {err:?}");
290 }
291
292 #[test]
293 fn cache_hit() {
294 let path = write_temp("cache-hit", VALID_SPEC);
295 let first = load_builtins(&path, false).expect("first load");
296 let second = load_builtins(&path, false).expect("second load");
297 assert!(
298 Arc::ptr_eq(&first, &second),
299 "second load must return the same Arc — cache hit"
300 );
301 }
302
303 /// D-16: load pipeline warns on catalog errors but does NOT fail.
304 ///
305 /// Uses a test-local loader variant (like `load_builtins`) that mirrors the
306 /// D-16 warn-only behavior of the production `load_cached` but with a
307 /// builtins-only catalog — avoiding global catalog pollution from
308 /// `BadPlugin_117` registered by other tests in the same test binary.
309 ///
310 /// This test directly validates the architectural change: replacing
311 /// `.map_err(LoadError::Catalog)?` with a `tracing::warn` loop.
312 #[test]
313 fn load_cached_warns_on_catalog_error_does_not_fail() {
314 // Alert.variant="" fails catalog enum-shape validation ("" not in AlertVariant).
315 // With D-16 the production load_cached logs tracing::warn instead of failing.
316 // This test-local loader mirrors that behavior.
317 let bad_spec = r#"{
318 "$schema": "ferro-json-ui/v2",
319 "root": "grid",
320 "elements": {
321 "grid": { "type": "Grid", "children": ["maybe_alert"] },
322 "maybe_alert": {
323 "type": "Alert",
324 "props": { "variant": "", "message": "flash message" },
325 "visible": { "path": "/flash", "operator": "exists" }
326 }
327 }
328 }"#;
329 let path = write_temp("d16-catalog-warn", bad_spec);
330
331 // Load using builtins-only catalog + D-16 warn-only validation.
332 let result = load_builtins_warn_only(&path, false);
333 assert!(
334 result.is_ok(),
335 "D-16: load must succeed (warn only) for spec with catalog-invalid gated element; got: {:?}",
336 result.err()
337 );
338 }
339
340 /// Test-local loader that mirrors production `load_cached`'s D-16 behavior:
341 /// catalog errors are logged as warnings, not propagated as hard failures.
342 /// Uses `Catalog::build_builtins_only()` to avoid global catalog pollution.
343 fn load_builtins_warn_only(
344 path: &Path,
345 reload_if_changed: bool,
346 ) -> Result<Arc<Spec>, LoadError> {
347 let canonical = fs::canonicalize(path)?;
348 {
349 let cache = global_spec_cache().read().expect("spec cache poisoned");
350 if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
351 if !reload_if_changed {
352 return Ok(Arc::clone(arc_spec));
353 }
354 let current = current_mtime(&canonical);
355 if current <= *cached_mtime {
356 return Ok(Arc::clone(arc_spec));
357 }
358 }
359 }
360 let content = fs::read_to_string(&canonical)?;
361 let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
362 // D-16: warn-only — mirrors production load_cached.
363 let cat = Catalog::build_builtins_only().map_err(|e| LoadError::Catalog(vec![e]))?;
364 if let Err(errs) = cat.validate(&spec) {
365 for e in &errs {
366 // In tests tracing is a no-op sink; this just verifies the path doesn't fail.
367 let _ = e.to_string();
368 }
369 }
370 let mtime = current_mtime(&canonical);
371 let arc_spec = Arc::new(spec);
372 global_spec_cache()
373 .write()
374 .expect("spec cache poisoned")
375 .insert(canonical, (Arc::clone(&arc_spec), mtime));
376 Ok(arc_spec)
377 }
378
379 #[test]
380 fn dev_mode_invalidation() {
381 let path = write_temp("dev-mode", VALID_SPEC);
382 let first = load_builtins(&path, true).expect("first load");
383 assert_eq!(first.root, "r");
384
385 // Sleep 1.1s to guarantee SystemTime advances past 1-second filesystem
386 // resolution (ext4/apfs default). Rewrite with different content.
387 std::thread::sleep(std::time::Duration::from_millis(1100));
388 let mut f = std::fs::File::create(&path).expect("rewrite tempfile");
389 f.write_all(VALID_SPEC_ALT.as_bytes()).expect("write");
390 f.sync_all().expect("sync");
391
392 let second = load_builtins(&path, true).expect("second load after mtime advance");
393 assert!(
394 !Arc::ptr_eq(&first, &second),
395 "mtime advance must produce a fresh Arc"
396 );
397 assert_eq!(
398 second.root, "other",
399 "reloaded spec must reflect post-write content"
400 );
401 }
402}