use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use std::time::SystemTime;
use thiserror::Error;
use crate::catalog::{global_catalog, CatalogError};
use crate::spec::{Spec, SpecError};
#[derive(Debug, Error)]
pub enum LoadError {
#[error("failed to read spec file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse spec: {0}")]
Parse(#[from] SpecError),
#[error("spec failed catalog validation: {0:?}")]
Catalog(Vec<CatalogError>),
}
type SpecCache = HashMap<PathBuf, (Arc<Spec>, SystemTime)>;
static SPEC_CACHE: OnceLock<RwLock<SpecCache>> = OnceLock::new();
fn global_spec_cache() -> &'static RwLock<SpecCache> {
SPEC_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
fn current_mtime(path: &Path) -> SystemTime {
fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub fn load_cached(path: &Path, reload_if_changed: bool) -> Result<Arc<Spec>, LoadError> {
let canonical = fs::canonicalize(path)?;
{
let cache = global_spec_cache()
.read()
.expect("spec cache RwLock poisoned");
if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
if !reload_if_changed {
return Ok(Arc::clone(arc_spec));
}
let current = current_mtime(&canonical);
if current <= *cached_mtime {
return Ok(Arc::clone(arc_spec));
}
}
}
let content = fs::read_to_string(&canonical)?;
let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
if let Err(errs) = global_catalog().validate(&spec) {
for e in &errs {
tracing::warn!(
target: "ferro_json_ui::catalog",
spec = %canonical.display(),
error = %e,
"load-time catalog warning (deferred to render-time enforcement)"
);
}
}
let mtime = current_mtime(&canonical);
let arc_spec = Arc::new(spec);
global_spec_cache()
.write()
.expect("spec cache RwLock poisoned")
.insert(canonical, (Arc::clone(&arc_spec), mtime));
Ok(arc_spec)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
use std::io::Write;
use std::path::PathBuf;
fn write_temp(name: &str, content: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let mut path = std::env::temp_dir();
path.push(format!("ferro-json-ui-loader-{name}-{n}.json"));
let mut f = std::fs::File::create(&path).expect("create tempfile");
f.write_all(content.as_bytes()).expect("write tempfile");
f.sync_all().expect("sync tempfile");
path
}
fn load_builtins(path: &Path, reload_if_changed: bool) -> Result<Arc<Spec>, LoadError> {
let canonical = fs::canonicalize(path)?;
{
let cache = global_spec_cache().read().expect("spec cache poisoned");
if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
if !reload_if_changed {
return Ok(Arc::clone(arc_spec));
}
let current = current_mtime(&canonical);
if current <= *cached_mtime {
return Ok(Arc::clone(arc_spec));
}
}
}
let content = fs::read_to_string(&canonical)?;
let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
Catalog::build_builtins_only()
.map_err(|e| LoadError::Catalog(vec![e]))?
.validate(&spec)
.map_err(LoadError::Catalog)?;
let mtime = current_mtime(&canonical);
let arc_spec = Arc::new(spec);
global_spec_cache()
.write()
.expect("spec cache poisoned")
.insert(canonical, (Arc::clone(&arc_spec), mtime));
Ok(arc_spec)
}
const VALID_SPEC: &str = r#"{
"$schema": "ferro-json-ui/v2",
"root": "r",
"elements": { "r": { "type": "Text", "props": { "content": "hi" } } }
}"#;
const VALID_SPEC_ALT: &str = r#"{
"$schema": "ferro-json-ui/v2",
"root": "other",
"elements": { "other": { "type": "Text", "props": { "content": "changed" } } }
}"#;
#[test]
fn load_spec_valid() {
let path = write_temp("valid", VALID_SPEC);
let spec = load_builtins(&path, false).expect("valid spec should load");
assert_eq!(spec.root, "r");
}
#[test]
fn load_spec_invalid_json() {
let path = write_temp("invalid-json", "{ not valid json");
let err = load_builtins(&path, false).expect_err("must fail");
assert!(
matches!(err, LoadError::Parse(_)),
"expected Parse, got {err:?}"
);
}
#[test]
fn load_spec_catalog_error() {
let unknown = r#"{
"$schema": "ferro-json-ui/v2",
"root": "r",
"elements": { "r": { "type": "NotARealComponent_119_loader" } }
}"#;
let path = write_temp("unknown-type", unknown);
let err = load_builtins(&path, false).expect_err("must fail");
match err {
LoadError::Catalog(errs) => {
assert!(!errs.is_empty(), "catalog errors must be non-empty")
}
other => panic!("expected Catalog, got {other:?}"),
}
}
#[test]
fn load_spec_missing_file() {
let path = PathBuf::from("/nonexistent/path-119-loader-test-does-not-exist.json");
let err = load_builtins(&path, false).expect_err("must fail");
assert!(matches!(err, LoadError::Io(_)), "expected Io, got {err:?}");
}
#[test]
fn cache_hit() {
let path = write_temp("cache-hit", VALID_SPEC);
let first = load_builtins(&path, false).expect("first load");
let second = load_builtins(&path, false).expect("second load");
assert!(
Arc::ptr_eq(&first, &second),
"second load must return the same Arc — cache hit"
);
}
#[test]
fn load_cached_warns_on_catalog_error_does_not_fail() {
let bad_spec = r#"{
"$schema": "ferro-json-ui/v2",
"root": "grid",
"elements": {
"grid": { "type": "Grid", "children": ["maybe_alert"] },
"maybe_alert": {
"type": "Alert",
"props": { "variant": "", "message": "flash message" },
"visible": { "path": "/flash", "operator": "exists" }
}
}
}"#;
let path = write_temp("d16-catalog-warn", bad_spec);
let result = load_builtins_warn_only(&path, false);
assert!(
result.is_ok(),
"D-16: load must succeed (warn only) for spec with catalog-invalid gated element; got: {:?}",
result.err()
);
}
fn load_builtins_warn_only(
path: &Path,
reload_if_changed: bool,
) -> Result<Arc<Spec>, LoadError> {
let canonical = fs::canonicalize(path)?;
{
let cache = global_spec_cache().read().expect("spec cache poisoned");
if let Some((arc_spec, cached_mtime)) = cache.get(&canonical) {
if !reload_if_changed {
return Ok(Arc::clone(arc_spec));
}
let current = current_mtime(&canonical);
if current <= *cached_mtime {
return Ok(Arc::clone(arc_spec));
}
}
}
let content = fs::read_to_string(&canonical)?;
let spec = Spec::from_json(&content).map_err(LoadError::Parse)?;
let cat = Catalog::build_builtins_only().map_err(|e| LoadError::Catalog(vec![e]))?;
if let Err(errs) = cat.validate(&spec) {
for e in &errs {
let _ = e.to_string();
}
}
let mtime = current_mtime(&canonical);
let arc_spec = Arc::new(spec);
global_spec_cache()
.write()
.expect("spec cache poisoned")
.insert(canonical, (Arc::clone(&arc_spec), mtime));
Ok(arc_spec)
}
#[test]
fn dev_mode_invalidation() {
let path = write_temp("dev-mode", VALID_SPEC);
let first = load_builtins(&path, true).expect("first load");
assert_eq!(first.root, "r");
std::thread::sleep(std::time::Duration::from_millis(1100));
let mut f = std::fs::File::create(&path).expect("rewrite tempfile");
f.write_all(VALID_SPEC_ALT.as_bytes()).expect("write");
f.sync_all().expect("sync");
let second = load_builtins(&path, true).expect("second load after mtime advance");
assert!(
!Arc::ptr_eq(&first, &second),
"mtime advance must produce a fresh Arc"
);
assert_eq!(
second.root, "other",
"reloaded spec must reflect post-write content"
);
}
}