Skip to main content

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}