use std::collections::{HashMap, HashSet};
use std::fmt;
use schemars::JsonSchema;
use serde::de::{Deserialize as DeserializeTrait, Deserializer, MapAccess, Visitor};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
use crate::action::Action;
use crate::visibility::Visibility;
pub const SCHEMA_VERSION: &str = "ferro-json-ui/v2";
pub const MAX_NESTING_DEPTH: usize = 16;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum TitleBinding {
Literal(String),
Binding(DataRef),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct DataRef {
#[serde(rename = "$data")]
pub data: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Spec {
#[serde(rename = "$schema")]
pub schema: String,
pub root: String,
pub elements: HashMap<String, Element>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<TitleBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<String>,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub data: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Element {
#[serde(rename = "type")]
pub type_name: String,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub props: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<Action>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visible: Option<Visibility>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "$each")]
pub each: Option<EachDirective>,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "$if")]
pub if_: Option<Visibility>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EachDirective {
pub path: String,
#[serde(rename = "as")]
pub as_: String,
}
#[derive(Debug, Error)]
pub enum SpecError {
#[error("failed to parse JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("duplicate element ID in spec: {0}")]
DuplicateId(String),
#[error("root element '{0}' not found in elements map")]
RootMissing(String),
#[error("element '{element}' references child '{child}' which does not exist")]
DanglingChild { element: String, child: String },
#[error("cycle detected in element graph: {}", path.join(" -> "))]
Cycle { path: Vec<String> },
#[error(
"nesting depth exceeds maximum of {max}: found depth {found} at {}",
path.join(" -> ")
)]
DepthExceeded {
max: usize,
found: usize,
path: Vec<String>,
},
#[error("invalid element ID '{0}' — must match ^[A-Za-z_][A-Za-z0-9_-]{{0,127}}$")]
InvalidId(String),
#[error("element '{element_id}' has footer reference '{footer_id}' not found in elements")]
FooterMissing {
element_id: String,
footer_id: String,
},
#[error("element '{element_id}' has `$each.path = \"{path}\"` resolving to a non-array value in spec.data")]
EachPathNotArray { element_id: String, path: String },
#[error("element '{element_id}' has `$if.path = \"{path}\"` referencing a key absent from spec.data")]
IfPathMissing { element_id: String, path: String },
#[error("element '{element_id}' has `$each.as = \"{name}\"` which is a reserved name (one of: data, root, _root, _each, this, self)")]
EachAsReservedName { element_id: String, name: String },
#[error("nested `$each` is not supported in Phase 163: element '{outer}' templates element '{inner}' which is also `$each`-templated")]
NestedEach { outer: String, inner: String },
#[error("element '{parent}' (`$each` over '{parent_path}') references child '{child}' which is `$each` over a different path '{child_path}' — mismatched each siblings")]
MismatchedEach {
parent: String,
parent_path: String,
child: String,
child_path: String,
},
}
impl Spec {
pub fn builder() -> SpecBuilder {
SpecBuilder::new()
}
pub fn merge_data(mut self, handler_data: serde_json::Value) -> Self {
debug_assert!(
handler_data.is_null() || handler_data.is_object(),
"merge_data expects an Object or Null; non-Object handler_data ignored"
);
if let Some(obj) = handler_data.as_object() {
if self.data.is_null() {
self.data = Value::Object(Map::new());
}
if let Some(data_map) = self.data.as_object_mut() {
for (k, v) in obj {
data_map.insert(k.clone(), v.clone());
}
}
}
self
}
pub fn from_json(json: &str) -> Result<Spec, SpecError> {
let raw: SpecWire = match serde_json::from_str::<SpecWire>(json) {
Ok(r) => r,
Err(e) => {
let msg = e.to_string();
if let Some(idx) = msg.find(DUP_ID_SENTINEL) {
let after = &msg[idx + DUP_ID_SENTINEL.len()..];
let id: String = after
.chars()
.take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'' && *c != ',')
.collect();
return Err(SpecError::DuplicateId(id));
}
return Err(SpecError::Json(e));
}
};
let spec = Spec {
schema: raw.schema,
root: raw.root,
elements: raw.elements.0,
title: raw.title,
layout: raw.layout,
data: raw.data,
};
validate_structure(&spec)?;
Ok(spec)
}
}
impl Element {
#[allow(clippy::new_ret_no_self)]
pub fn new(type_name: impl Into<String>) -> ElementBuilder {
ElementBuilder {
type_name: type_name.into(),
props: Map::new(),
children: Vec::new(),
action: None,
visible: None,
each: None,
if_: None,
}
}
}
#[derive(Debug, Default)]
pub struct SpecBuilder {
title: Option<TitleBinding>,
layout: Option<String>,
data: Value,
root: Option<String>,
elements: HashMap<String, Element>,
}
impl SpecBuilder {
fn new() -> Self {
Self {
title: None,
layout: None,
data: Value::Null,
root: None,
elements: HashMap::new(),
}
}
pub fn title(mut self, t: impl Into<String>) -> Self {
self.title = Some(TitleBinding::Literal(t.into()));
self
}
pub fn title_binding(mut self, path: impl Into<String>) -> Self {
self.title = Some(TitleBinding::Binding(DataRef { data: path.into() }));
self
}
pub fn layout(mut self, l: impl Into<String>) -> Self {
self.layout = Some(l.into());
self
}
pub fn data(mut self, d: Value) -> Self {
self.data = d;
self
}
pub fn root(mut self, id: impl Into<String>) -> Self {
self.root = Some(id.into());
self
}
pub fn element(mut self, id: impl Into<String>, el: ElementBuilder) -> Self {
let id: String = id.into();
if self.root.is_none() {
self.root = Some(id.clone());
}
self.elements.insert(id, el.build());
self
}
pub fn element_nested(mut self, id: impl Into<String>, el: NestedElement) -> Self {
let id: String = id.into();
if self.root.is_none() {
self.root = Some(id.clone());
}
flatten_nested(&mut self.elements, &id, el);
self
}
pub fn build(self) -> Result<Spec, SpecError> {
let root = self.root.ok_or_else(|| {
SpecError::RootMissing(String::new())
})?;
let spec = Spec {
schema: SCHEMA_VERSION.to_string(),
root,
elements: self.elements,
title: self.title,
layout: self.layout,
data: self.data,
};
validate_structure(&spec)?;
Ok(spec)
}
}
#[derive(Debug)]
pub struct ElementBuilder {
type_name: String,
props: Map<String, Value>,
children: Vec<String>,
action: Option<Action>,
visible: Option<Visibility>,
each: Option<EachDirective>,
if_: Option<Visibility>,
}
impl ElementBuilder {
pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
self.props.insert(k.into(), v.into());
self
}
pub fn child(mut self, id: impl Into<String>) -> Self {
self.children.push(id.into());
self
}
pub fn action(mut self, a: Action) -> Self {
self.action = Some(a);
self
}
pub fn visible(mut self, v: Visibility) -> Self {
self.visible = Some(v);
self
}
pub(crate) fn build(self) -> Element {
let props = if self.props.is_empty() {
Value::Null
} else {
Value::Object(self.props)
};
Element {
type_name: self.type_name,
props,
children: self.children,
action: self.action,
visible: self.visible,
each: self.each,
if_: self.if_,
}
}
}
#[derive(Debug)]
pub struct NestedElement {
type_name: String,
props: Map<String, Value>,
children: Vec<NestedElement>,
action: Option<Action>,
visible: Option<Visibility>,
}
impl NestedElement {
pub fn new(type_name: impl Into<String>) -> Self {
Self {
type_name: type_name.into(),
props: Map::new(),
children: Vec::new(),
action: None,
visible: None,
}
}
pub fn prop(mut self, k: impl Into<String>, v: impl Into<Value>) -> Self {
self.props.insert(k.into(), v.into());
self
}
pub fn child(mut self, c: NestedElement) -> Self {
self.children.push(c);
self
}
pub fn action(mut self, a: Action) -> Self {
self.action = Some(a);
self
}
pub fn visible(mut self, v: Visibility) -> Self {
self.visible = Some(v);
self
}
#[cfg(test)]
pub(crate) fn build_for_test(self) -> Element {
let props = if self.props.is_empty() {
Value::Null
} else {
Value::Object(self.props)
};
Element {
type_name: self.type_name,
props,
children: Vec::new(),
action: self.action,
visible: self.visible,
each: None,
if_: None,
}
}
}
fn flatten_nested(elements: &mut HashMap<String, Element>, id: &str, el: NestedElement) {
let mut child_ids: Vec<String> = Vec::with_capacity(el.children.len());
for (idx, child) in el.children.into_iter().enumerate() {
let child_id = format!("{id}-{idx}");
flatten_nested(elements, &child_id, child);
child_ids.push(child_id);
}
let props = if el.props.is_empty() {
Value::Null
} else {
Value::Object(el.props)
};
let element = Element {
type_name: el.type_name,
props,
children: child_ids,
action: el.action,
visible: el.visible,
each: None,
if_: None,
};
elements.insert(id.to_string(), element);
}
const DUP_ID_SENTINEL: &str = "__FERRO_DUPLICATE_ID__";
#[derive(Deserialize)]
struct SpecWire {
#[serde(rename = "$schema", default = "default_schema")]
schema: String,
root: String,
elements: ElementsMap,
#[serde(default)]
title: Option<TitleBinding>,
#[serde(default)]
layout: Option<String>,
#[serde(default)]
data: Value,
}
fn default_schema() -> String {
SCHEMA_VERSION.to_string()
}
struct ElementsMap(HashMap<String, Element>);
impl<'de> DeserializeTrait<'de> for ElementsMap {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = ElementsMap;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON object with unique element IDs")
}
fn visit_map<M: MapAccess<'de>>(self, mut m: M) -> Result<ElementsMap, M::Error> {
let mut map: HashMap<String, Element> = HashMap::new();
while let Some(k) = m.next_key::<String>()? {
if map.contains_key(&k) {
return Err(serde::de::Error::custom(format!("{DUP_ID_SENTINEL}{k}")));
}
let v: Element = m.next_value()?;
map.insert(k, v);
}
Ok(ElementsMap(map))
}
}
d.deserialize_map(V)
}
}
fn validate_structure(spec: &Spec) -> Result<(), SpecError> {
validate_ids(&spec.elements)?;
if !spec.elements.contains_key(&spec.root) {
return Err(SpecError::RootMissing(spec.root.clone()));
}
validate_no_dangling(&spec.elements)?;
validate_directives(spec)?;
validate_footer_ids(spec)?;
detect_cycle(&spec.elements, &spec.root)?;
check_depth(&spec.elements, &spec.root)?;
Ok(())
}
fn is_valid_id(s: &str) -> bool {
if s.is_empty() || s.len() > 128 {
return false;
}
let bytes = s.as_bytes();
let first = bytes[0];
let first_ok = first.is_ascii_alphabetic() || first == b'_';
if !first_ok {
return false;
}
bytes[1..]
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn validate_ids(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
for (id, el) in elements {
if !is_valid_id(id) {
return Err(SpecError::InvalidId(id.clone()));
}
for child in &el.children {
if !is_valid_id(child) {
return Err(SpecError::InvalidId(child.clone()));
}
}
}
Ok(())
}
fn validate_no_dangling(elements: &HashMap<String, Element>) -> Result<(), SpecError> {
for (id, el) in elements {
for child in &el.children {
if !elements.contains_key(child) {
return Err(SpecError::DanglingChild {
element: id.clone(),
child: child.clone(),
});
}
}
}
Ok(())
}
fn validate_footer_ids(spec: &Spec) -> Result<(), SpecError> {
for (element_id, el) in &spec.elements {
let footer_ids: Vec<String> = el
.props
.get("footer")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for footer_id in &footer_ids {
if !spec.elements.contains_key(footer_id) {
return Err(SpecError::FooterMissing {
element_id: element_id.clone(),
footer_id: footer_id.clone(),
});
}
if el.children.iter().any(|c| c == footer_id) {
eprintln!(
"ferro-json-ui: element '{element_id}' has '{footer_id}' in both \
props.footer and children — the element renders once (in footer); \
remove the duplicate from children"
);
}
}
}
Ok(())
}
const RESERVED_EACH_AS: &[&str] = &["data", "root", "_root", "_each", "this", "self"];
fn validate_directives(spec: &Spec) -> Result<(), SpecError> {
let templated: HashMap<&str, &EachDirective> = spec
.elements
.iter()
.filter_map(|(id, el)| el.each.as_ref().map(|e| (id.as_str(), e)))
.collect();
for (id, el) in &spec.elements {
if let Some(each) = &el.each {
if RESERVED_EACH_AS.contains(&each.as_.as_str()) {
return Err(SpecError::EachAsReservedName {
element_id: id.clone(),
name: each.as_.clone(),
});
}
if !spec.data.is_null() {
if let Some(value) = crate::data::resolve_path(&spec.data, &each.path) {
if !value.is_array() {
return Err(SpecError::EachPathNotArray {
element_id: id.clone(),
path: each.path.clone(),
});
}
}
}
for child in &el.children {
if let Some(child_each) = templated.get(child.as_str()) {
if child_each.path != each.path || child_each.as_ != each.as_ {
return Err(SpecError::MismatchedEach {
parent: id.clone(),
parent_path: each.path.clone(),
child: child.clone(),
child_path: child_each.path.clone(),
});
}
}
}
let direct: HashSet<&str> = el.children.iter().map(|s| s.as_str()).collect();
let mut visited: HashSet<&str> = HashSet::new();
let mut stack: Vec<&str> = Vec::new();
for child in &el.children {
if let Some(child_el) = spec.elements.get(child) {
for gc in &child_el.children {
stack.push(gc.as_str());
}
}
}
while let Some(node) = stack.pop() {
if !visited.insert(node) {
continue;
}
if templated.contains_key(node) && !direct.contains(node) {
return Err(SpecError::NestedEach {
outer: id.clone(),
inner: node.to_string(),
});
}
if let Some(node_el) = spec.elements.get(node) {
for c in &node_el.children {
stack.push(c.as_str());
}
}
}
}
if let Some(vis) = &el.if_ {
if !spec.data.is_null() {
check_visibility_paths(id, vis, &spec.data)?;
}
}
}
Ok(())
}
fn check_visibility_paths(
element_id: &str,
vis: &Visibility,
data: &Value,
) -> Result<(), SpecError> {
match vis {
Visibility::And { and } => {
for v in and {
check_visibility_paths(element_id, v, data)?;
}
}
Visibility::Or { or } => {
for v in or {
check_visibility_paths(element_id, v, data)?;
}
}
Visibility::Not { not } => check_visibility_paths(element_id, not, data)?,
Visibility::Condition(c) => {
if crate::data::resolve_path(data, &c.path).is_none() {
return Err(SpecError::IfPathMissing {
element_id: element_id.to_string(),
path: c.path.clone(),
});
}
}
}
Ok(())
}
fn detect_cycle(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
let mut visited: HashSet<String> = HashSet::new();
let mut on_stack: Vec<String> = Vec::new();
dfs(root, elements, &mut visited, &mut on_stack)
}
fn dfs(
node: &str,
elements: &HashMap<String, Element>,
visited: &mut HashSet<String>,
on_stack: &mut Vec<String>,
) -> Result<(), SpecError> {
if let Some(start) = on_stack.iter().position(|n| n == node) {
let mut path: Vec<String> = on_stack[start..].to_vec();
path.push(node.to_string());
return Err(SpecError::Cycle { path });
}
if visited.contains(node) {
return Ok(());
}
on_stack.push(node.to_string());
if let Some(el) = elements.get(node) {
for child in &el.children {
dfs(child, elements, visited, on_stack)?;
}
}
on_stack.pop();
visited.insert(node.to_string());
Ok(())
}
fn check_depth(elements: &HashMap<String, Element>, root: &str) -> Result<(), SpecError> {
let mut path: Vec<String> = Vec::new();
walk(root, elements, 1, &mut path)
}
fn walk(
node: &str,
elements: &HashMap<String, Element>,
depth: usize,
path: &mut Vec<String>,
) -> Result<(), SpecError> {
path.push(node.to_string());
if depth > MAX_NESTING_DEPTH {
return Err(SpecError::DepthExceeded {
max: MAX_NESTING_DEPTH,
found: depth,
path: path.clone(),
});
}
if let Some(el) = elements.get(node) {
for child in &el.children {
walk(child, elements, depth + 1, path)?;
}
}
path.pop();
Ok(())
}
#[cfg(test)]
#[rustfmt::skip]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_schema_is_v2() {
assert_eq!(default_schema(), SCHEMA_VERSION);
assert_eq!(SCHEMA_VERSION, "ferro-json-ui/v2");
}
#[test]
fn is_valid_id_edge_cases() {
let cases: &[(&str, bool)] = &[
("", false),
("1abc", false),
("a", true),
("_", true),
("a_b-c", true),
("user form", false),
("ABC123", true),
("a.b", false),
("/path", false),
];
for (s, ok) in cases {
assert_eq!(is_valid_id(s), *ok, "mismatch on {s:?}");
}
let ok128: String = "a".repeat(128);
let bad129: String = "a".repeat(129);
assert!(is_valid_id(&ok128));
assert!(!is_valid_id(&bad129));
}
#[test]
fn builder_minimal_round_trips() {
let spec = Spec::builder()
.element("a", Element::new("Text").prop("content", "Hi"))
.build()
.unwrap();
assert_eq!(spec.schema, SCHEMA_VERSION);
assert_eq!(spec.root, "a");
assert_eq!(spec.elements.len(), 1);
let json = serde_json::to_string(&spec).unwrap();
let back = Spec::from_json(&json).unwrap();
assert_eq!(spec, back);
}
#[test]
fn builder_parity_with_json() {
let from_json = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text","props":{"content":"Hi"}}}}"#,
)
.unwrap();
let from_builder = Spec::builder()
.element("a", Element::new("Text").prop("content", "Hi"))
.build()
.unwrap();
assert_eq!(from_json, from_builder);
}
#[test]
fn from_json_rejects_missing_root() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"nope","elements":{"a":{"type":"Text"}}}"#,
)
.unwrap_err();
match err {
SpecError::RootMissing(id) => assert_eq!(id, "nope"),
other => panic!("expected RootMissing, got {other:?}"),
}
}
#[test]
fn from_json_rejects_dangling_child() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Card","children":["ghost"]}}}"#,
)
.unwrap_err();
match err {
SpecError::DanglingChild { element, child } => {
assert_eq!(element, "a");
assert_eq!(child, "ghost");
}
other => panic!("expected DanglingChild, got {other:?}"),
}
}
#[test]
fn validate_allows_children_ref_to_if_gated_element() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "parent",
"elements": {
"parent": {
"type": "Card",
"props": {"title": "parent"},
"children": ["child"]
},
"child": {
"type": "Text",
"props": {"content": "conditional"},
"$if": {"path": "/data/show", "operator": "eq", "value": true}
}
}
}"#;
let spec = Spec::from_json(json);
assert!(
spec.is_ok(),
"$if-gated child must not be rejected as dangling: {:?}",
spec.err()
);
}
#[test]
fn from_json_rejects_self_cycle() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{"A":{"type":"Card","children":["A"]}}}"#,
)
.unwrap_err();
match err {
SpecError::Cycle { path } => {
assert_eq!(path, vec!["A".to_string(), "A".to_string()]);
}
other => panic!("expected Cycle (self), got {other:?}"),
}
}
#[test]
fn from_json_rejects_two_cycle() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{"root":{"type":"Card","children":["A"]},"A":{"type":"Card","children":["root"]}}}"#,
)
.unwrap_err();
match err {
SpecError::Cycle { path } => {
assert!(path.len() >= 3);
assert_eq!(path.first(), path.last());
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn cycle_detector_only_on_revisit() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
"A":{"type":"Card","children":["B"]},
"B":{"type":"Card","children":["A"]}
}}"#,
)
.unwrap_err();
match err {
SpecError::Cycle { path } => {
assert!(
path.iter().any(|p| p == "A"),
"cycle path must contain A; got {path:?}"
);
assert!(
path.iter().any(|p| p == "B"),
"cycle path must contain B; got {path:?}"
);
}
other => panic!("expected Cycle, got {other:?}"),
}
}
#[test]
fn from_json_rejects_depth_17() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
"root":{"type":"Container","children":["e1"]},
"e1":{"type":"Container","children":["e2"]},
"e2":{"type":"Container","children":["e3"]},
"e3":{"type":"Container","children":["e4"]},
"e4":{"type":"Container","children":["e5"]},
"e5":{"type":"Container","children":["e6"]},
"e6":{"type":"Container","children":["e7"]},
"e7":{"type":"Container","children":["e8"]},
"e8":{"type":"Container","children":["e9"]},
"e9":{"type":"Container","children":["e10"]},
"e10":{"type":"Container","children":["e11"]},
"e11":{"type":"Container","children":["e12"]},
"e12":{"type":"Container","children":["e13"]},
"e13":{"type":"Container","children":["e14"]},
"e14":{"type":"Container","children":["e15"]},
"e15":{"type":"Container","children":["e16"]},
"e16":{"type":"Text"}
}}"#,
)
.unwrap_err();
match err {
SpecError::DepthExceeded { max, found, path } => {
assert_eq!(max, 16, "max must equal MAX_NESTING_DEPTH=16");
assert_eq!(found, 17, "found must be 17 (one past the limit)");
assert!(!path.is_empty());
}
other => panic!("expected DepthExceeded, got {other:?}"),
}
}
#[test]
fn from_json_accepts_depth_8() {
let spec = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"dashboard","elements":{
"dashboard":{"type":"Screen","children":["root"]},
"root":{"type":"Container","children":["detail_page"]},
"detail_page":{"type":"DetailPage","children":["tab"]},
"tab":{"type":"Card","children":["card"]},
"card":{"type":"Card","children":["form"]},
"form":{"type":"Form","children":["row"]},
"row":{"type":"Grid","children":["switch_day"]},
"switch_day":{"type":"Switch"}
}}"#,
)
.expect("depth-8 staff-detail spec must parse without DepthExceeded");
assert_eq!(spec.elements.len(), 8);
}
#[test]
fn from_json_rejects_invalid_id_space() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"user form","elements":{"user form":{"type":"Text"}}}"#,
)
.unwrap_err();
match err {
SpecError::InvalidId(id) => assert_eq!(id, "user form"),
other => panic!("expected InvalidId, got {other:?}"),
}
}
#[test]
fn from_json_rejects_duplicate_id() {
let err = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"a","elements":{"a":{"type":"Text"},"a":{"type":"Card"}}}"#,
)
.unwrap_err();
match err {
SpecError::DuplicateId(id) => assert_eq!(id, "a"),
other => panic!("expected DuplicateId, got {other:?}"),
}
}
#[test]
fn from_json_accepts_three_level_nesting() {
let spec = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"root","elements":{
"root":{"type":"Card","children":["section"]},
"section":{"type":"FormSection","children":["leaf"]},
"leaf":{"type":"Text"}
}}"#,
)
.unwrap();
assert_eq!(spec.elements.len(), 3);
}
#[test]
fn from_json_accepts_diamond() {
let spec = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"A","elements":{
"A":{"type":"Card","children":["B","C"]},
"B":{"type":"Card","children":["D"]},
"C":{"type":"Card","children":["D"]},
"D":{"type":"Text"}
}}"#,
)
.unwrap();
assert_eq!(spec.elements.len(), 4);
}
#[test]
fn from_json_wraps_syntax_errors() {
let err = Spec::from_json("{ this is not json ").unwrap_err();
assert!(matches!(err, SpecError::Json(_)), "got {err:?}");
}
#[test]
fn builder_rejects_forward_ref_without_target() {
let err = Spec::builder()
.element("root", Element::new("Card").child("ghost"))
.build()
.unwrap_err();
match err {
SpecError::DanglingChild { element, child } => {
assert_eq!(element, "root");
assert_eq!(child, "ghost");
}
other => panic!("expected DanglingChild, got {other:?}"),
}
}
#[test]
fn builder_data_payload_survives_round_trip() {
let spec = Spec::builder()
.element("a", Element::new("Text"))
.data(json!({"user":{"name":"Alice"}}))
.build()
.unwrap();
let json = serde_json::to_string(&spec).unwrap();
let back = Spec::from_json(&json).unwrap();
assert_eq!(back.data, json!({"user":{"name":"Alice"}}));
}
#[test]
fn element_omits_optional_fields_when_absent() {
let spec = Spec::builder()
.element("bare", Element::new("Text"))
.build()
.unwrap();
let json = serde_json::to_string(&spec).unwrap();
assert!(!json.contains("children"));
assert!(!json.contains("props"));
assert!(!json.contains("action"));
assert!(!json.contains("visible"));
}
#[test]
fn merge_data_handler_wins() {
let spec = Spec::builder()
.element("a", Element::new("Text"))
.data(json!({"a": 1, "b": 2}))
.build()
.unwrap();
let merged = spec.merge_data(json!({"b": 99, "c": 3}));
assert_eq!(merged.data, json!({"a": 1, "b": 99, "c": 3}));
}
#[test]
fn merge_data_ignores_non_object() {
let spec = Spec::builder()
.element("a", Element::new("Text"))
.data(json!({"a": 1}))
.build()
.unwrap();
let merged = spec.merge_data(Value::Null);
assert_eq!(merged.data, json!({"a": 1}));
}
#[test]
fn merge_data_initializes_null_data() {
let spec = Spec::builder()
.element("a", Element::new("Text"))
.build() .unwrap();
assert_eq!(spec.data, Value::Null);
let merged = spec.merge_data(json!({"k": "v"}));
assert_eq!(merged.data, json!({"k": "v"}));
}
#[test]
fn merge_data_empty_handler_no_op() {
let spec = Spec::builder()
.element("a", Element::new("Text"))
.data(json!({"a": 1}))
.build()
.unwrap();
let merged = spec.merge_data(json!({}));
assert_eq!(merged.data, json!({"a": 1}));
}
#[test]
fn from_json_rejects_missing_footer_id() {
let err = Spec::from_json(
r#"{
"$schema": "ferro-json-ui/v2",
"root": "card",
"elements": {
"card": {
"type": "Card",
"props": {"title": "T", "footer": ["ghost"]}
}
}
}"#,
)
.unwrap_err();
match err {
SpecError::FooterMissing {
element_id,
footer_id,
} => {
assert_eq!(element_id, "card");
assert_eq!(footer_id, "ghost");
}
other => panic!("expected FooterMissing, got {other:?}"),
}
}
#[test]
fn from_json_rejects_missing_modal_footer_id() {
let err = Spec::from_json(
r#"{
"$schema": "ferro-json-ui/v2",
"root": "modal",
"elements": {
"modal": {
"type": "Modal",
"props": {"id": "m", "title": "T", "footer": ["ghost"]}
}
}
}"#,
)
.unwrap_err();
match err {
SpecError::FooterMissing {
element_id,
footer_id,
} => {
assert_eq!(element_id, "modal");
assert_eq!(footer_id, "ghost");
}
other => panic!("expected FooterMissing on Modal, got {other:?}"),
}
}
#[test]
fn spec_warns_duplicate_footer_child() {
let spec = Spec::from_json(
r#"{
"$schema": "ferro-json-ui/v2",
"root": "card",
"elements": {
"card": {
"type": "Card",
"props": {"title": "T", "footer": ["btn"]},
"children": ["btn"]
},
"btn": {
"type": "Button",
"props": {"label": "Save"}
}
}
}"#,
)
.expect("D-08 warning is non-fatal; parse must succeed");
assert_eq!(spec.root, "card");
}
#[test]
fn each_directive_round_trips() {
let json = serde_json::json!({"path": "/orders", "as": "order"});
let parsed: EachDirective = serde_json::from_value(json.clone()).expect("decode");
assert_eq!(parsed.path, "/orders");
assert_eq!(parsed.as_, "order");
let reserialized = serde_json::to_value(&parsed).expect("encode");
assert_eq!(reserialized, json);
assert!(reserialized.get("as").is_some());
assert!(reserialized.get("as_").is_none());
}
#[test]
fn element_with_each_round_trips() {
let json = serde_json::json!({
"type": "Card",
"$each": {"path": "/orders", "as": "order"},
"props": {"title": "x"}
});
let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
assert!(parsed.each.is_some());
let each = parsed.each.as_ref().unwrap();
assert_eq!(each.path, "/orders");
assert_eq!(each.as_, "order");
let reserialized = serde_json::to_value(&parsed).expect("encode");
assert!(reserialized.get("$each").is_some());
}
#[test]
fn element_without_each_omits_field() {
let spec = Spec::builder()
.element("card", Element::new("Card").prop("title", "hello"))
.build()
.expect("spec is valid");
let card = spec.elements.get("card").expect("card present");
let json = serde_json::to_value(card).expect("encode");
assert!(
json.get("$each").is_none(),
"expected $each to be omitted when None; got: {json}"
);
}
#[test]
fn if_directive_flat_condition_round_trips() {
use crate::visibility::Visibility;
let json = serde_json::json!({"path": "/can_advance", "operator": "eq", "value": true});
let parsed: Visibility = serde_json::from_value(json.clone()).expect("decode");
match &parsed {
Visibility::Condition(c) => {
assert_eq!(c.path, "/can_advance");
assert_eq!(c.value, Some(serde_json::json!(true)));
}
_ => panic!("expected flat Condition variant, got: {parsed:?}"),
}
let reserialized = serde_json::to_value(&parsed).expect("encode");
assert!(reserialized.get("path").is_some());
assert!(reserialized.get("operator").is_some());
}
#[test]
fn element_with_if_flat_round_trips() {
let json = serde_json::json!({
"type": "Button",
"$if": {"path": "/can_advance", "operator": "eq", "value": true},
"props": {"label": "x"}
});
let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
assert!(parsed.if_.is_some());
let reserialized = serde_json::to_value(&parsed).expect("encode");
assert!(reserialized.get("$if").is_some());
}
#[test]
fn element_with_if_compound_round_trips() {
use crate::visibility::Visibility;
let json = serde_json::json!({
"type": "Button",
"$if": {"and": [
{"path": "/a", "operator": "exists"},
{"path": "/b", "operator": "eq", "value": true}
]},
"props": {"label": "x"}
});
let parsed: Element = serde_json::from_value(json.clone()).expect("decode");
match parsed.if_.as_ref() {
Some(Visibility::And { and }) => assert_eq!(and.len(), 2),
other => panic!("expected And variant, got: {other:?}"),
}
let reserialized = serde_json::to_value(&parsed).expect("encode");
assert!(reserialized.get("$if").and_then(|v| v.get("and")).is_some());
}
#[test]
fn element_without_if_omits_field() {
let spec = Spec::builder()
.element("btn", Element::new("Button").prop("label", "ok"))
.build()
.expect("spec is valid");
let btn = spec.elements.get("btn").expect("btn present");
let json = serde_json::to_value(btn).expect("encode");
assert!(
json.get("$if").is_none(),
"expected $if to be omitted when None; got: {json}"
);
}
#[test]
fn validate_each_path_not_array_fires() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/orders", "as": "order"},
"props": {}
}
},
"data": {"orders": "not-an-array"}
}"#;
let err = Spec::from_json(json).expect_err("validator must reject non-array $each.path");
match err {
SpecError::EachPathNotArray { element_id, path } => {
assert_eq!(element_id, "list");
assert_eq!(path, "/orders");
}
other => panic!("expected EachPathNotArray, got: {other:?}"),
}
}
#[test]
fn validate_each_path_not_array_skipped_when_data_null() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/orders", "as": "order"},
"props": {}
}
}
}"#;
Spec::from_json(json).expect("no error when data is null");
}
#[test]
fn validate_each_as_reserved_data_rejected() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/items", "as": "data"},
"props": {}
}
}
}"#;
let err = Spec::from_json(json).expect_err("'data' is a reserved name");
match err {
SpecError::EachAsReservedName { element_id, name } => {
assert_eq!(element_id, "list");
assert_eq!(name, "data");
}
other => panic!("expected EachAsReservedName, got: {other:?}"),
}
}
#[test]
fn validate_each_as_reserved_root_rejected() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/items", "as": "root"},
"props": {}
}
}
}"#;
let err = Spec::from_json(json).expect_err("'root' is a reserved name");
match err {
SpecError::EachAsReservedName { element_id, name } => {
assert_eq!(element_id, "list");
assert_eq!(name, "root");
}
other => panic!("expected EachAsReservedName, got: {other:?}"),
}
}
#[test]
fn validate_each_as_non_reserved_accepted() {
let json_order = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/items", "as": "order"},
"props": {}
}
},
"data": {"items": []}
}"#;
Spec::from_json(json_order).expect("'order' is not reserved");
let json_row = r#"{
"$schema": "ferro-json-ui/v2",
"root": "list",
"elements": {
"list": {
"type": "Card",
"$each": {"path": "/items", "as": "row"},
"props": {}
}
},
"data": {"items": []}
}"#;
Spec::from_json(json_row).expect("'row' is not reserved");
}
#[test]
fn validate_if_path_missing_fires() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "btn",
"elements": {
"btn": {
"type": "Button",
"$if": {"path": "/missing_key", "operator": "eq", "value": true},
"props": {"label": "Go"}
}
},
"data": {"other": true}
}"#;
let err = Spec::from_json(json).expect_err("missing $if.path must error");
match err {
SpecError::IfPathMissing { element_id, path } => {
assert_eq!(element_id, "btn");
assert_eq!(path, "/missing_key");
}
other => panic!("expected IfPathMissing, got: {other:?}"),
}
}
#[test]
fn validate_if_path_missing_skipped_when_data_null() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "btn",
"elements": {
"btn": {
"type": "Button",
"$if": {"path": "/missing_key", "operator": "eq", "value": true},
"props": {"label": "Go"}
}
}
}"#;
Spec::from_json(json).expect("no error when data is null");
}
#[test]
fn validate_nested_each_rejected() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "A",
"elements": {
"A": {
"type": "Card",
"$each": {"path": "/items", "as": "item"},
"children": ["mid"]
},
"mid": {
"type": "Section",
"children": ["B"]
},
"B": {
"type": "Card",
"$each": {"path": "/other_items", "as": "other"},
"props": {}
}
}
}"#;
let err = Spec::from_json(json).expect_err("nested $each must be rejected");
match err {
SpecError::NestedEach { outer, inner } => {
assert_eq!(outer, "A");
assert_eq!(inner, "B");
}
other => panic!("expected NestedEach, got: {other:?}"),
}
}
#[test]
fn validate_mismatched_each_child_rejected() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "A",
"elements": {
"A": {
"type": "Card",
"$each": {"path": "/items", "as": "item"},
"children": ["B"]
},
"B": {
"type": "Text",
"$each": {"path": "/different_items", "as": "item"}
}
}
}"#;
let err = Spec::from_json(json).expect_err("mismatched $each child must be rejected");
match err {
SpecError::MismatchedEach {
parent,
parent_path,
child,
child_path,
} => {
assert_eq!(parent, "A");
assert_eq!(parent_path, "/items");
assert_eq!(child, "B");
assert_eq!(child_path, "/different_items");
}
other => panic!("expected MismatchedEach, got: {other:?}"),
}
}
#[test]
fn validate_correlated_each_child_accepted() {
let json = r#"{
"$schema": "ferro-json-ui/v2",
"root": "A",
"elements": {
"A": {
"type": "Card",
"$each": {"path": "/items", "as": "item"},
"children": ["B"]
},
"B": {
"type": "Text",
"$each": {"path": "/items", "as": "item"}
}
},
"data": {"items": []}
}"#;
Spec::from_json(json).expect("correlated $each children with same (path, as) are valid");
}
#[test]
fn nested_element_builder_basics() {
let el = NestedElement::new("Card")
.prop("title", "x")
.build_for_test();
assert_eq!(el.type_name, "Card");
assert_eq!(el.props.get("title").and_then(|v| v.as_str()), Some("x"));
assert!(el.children.is_empty());
assert!(el.action.is_none());
assert!(el.visible.is_none());
}
#[test]
fn nested_builder_flattens_one_level() {
let spec = Spec::builder()
.element_nested(
"root",
NestedElement::new("Card").child(NestedElement::new("Text").prop("content", "hi")),
)
.build()
.expect("spec is valid");
assert_eq!(spec.root, "root");
assert_eq!(spec.elements.len(), 2);
let root_el = spec.elements.get("root").expect("root present");
assert_eq!(root_el.children, vec!["root-0".to_string()]);
let child = spec.elements.get("root-0").expect("auto-id child present");
assert_eq!(child.type_name, "Text");
assert_eq!(
child.props.get("content").and_then(|v| v.as_str()),
Some("hi")
);
}
#[test]
fn nested_builder_accepts_depth_three() {
let spec = Spec::builder()
.element_nested(
"root",
NestedElement::new("Screen").child(
NestedElement::new("Section")
.child(NestedElement::new("Text").prop("content", "leaf")),
),
)
.build()
.expect("three levels at depth limit must be valid");
assert_eq!(spec.elements.len(), 3);
let root_el = spec.elements.get("root").expect("root");
assert_eq!(root_el.children, vec!["root-0".to_string()]);
let section = spec.elements.get("root-0").expect("section");
assert_eq!(section.type_name, "Section");
assert_eq!(section.children, vec!["root-0-0".to_string()]);
let leaf = spec.elements.get("root-0-0").expect("leaf");
assert_eq!(leaf.type_name, "Text");
assert!(leaf.children.is_empty());
}
#[test]
fn nested_builder_accepts_depth_sixteen() {
let spec = Spec::builder()
.element_nested(
"root",
NestedElement::new("Screen").child(
NestedElement::new("Grid").child(
NestedElement::new("Card").child(
NestedElement::new("Row").child(
NestedElement::new("Column").child(
NestedElement::new("Section").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Text")
.prop("content", "leaf"),
),
),
),
),
),
),
),
),
),
),
),
),
),
),
),
)
.build()
.expect("sixteen levels at depth limit must be valid");
assert!(spec.elements.contains_key("root"));
}
#[test]
fn nested_builder_rejects_depth_seventeen() {
let err = Spec::builder()
.element_nested(
"root",
NestedElement::new("Screen").child(
NestedElement::new("Grid").child(
NestedElement::new("Card").child(
NestedElement::new("Row").child(
NestedElement::new("Column").child(
NestedElement::new("Section").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Container").child(
NestedElement::new("Column").child(
NestedElement::new("Text")
.prop("content", "too deep"),
),
),
),
),
),
),
),
),
),
),
),
),
),
),
),
),
)
.build()
.expect_err("seventeen levels must exceed the depth limit");
assert!(
matches!(err, SpecError::DepthExceeded { .. }),
"expected DepthExceeded, got {err:?}"
);
}
#[test]
fn nested_builder_auto_ids_match_position() {
let spec = Spec::builder()
.element_nested(
"parent",
NestedElement::new("Row")
.child(NestedElement::new("ColA"))
.child(NestedElement::new("ColB"))
.child(NestedElement::new("ColC")),
)
.build()
.expect("spec with 3 siblings is valid");
assert_eq!(spec.elements.len(), 4);
let parent = spec.elements.get("parent").expect("parent");
assert_eq!(
parent.children,
vec![
"parent-0".to_string(),
"parent-1".to_string(),
"parent-2".to_string(),
]
);
assert_eq!(
spec.elements.get("parent-0").expect("child-0").type_name,
"ColA"
);
assert_eq!(
spec.elements.get("parent-1").expect("child-1").type_name,
"ColB"
);
assert_eq!(
spec.elements.get("parent-2").expect("child-2").type_name,
"ColC"
);
}
#[test]
fn nested_builder_root_set_from_first_call() {
let spec = Spec::builder()
.element_nested("first", NestedElement::new("Screen"))
.element_nested("second", NestedElement::new("Screen"))
.build()
.expect("multi-root-call spec");
assert_eq!(spec.root, "first");
}
#[test]
fn nested_builder_preserves_action_and_visible() {
use crate::action::Action;
use crate::visibility::{Visibility, VisibilityCondition, VisibilityOperator};
let action = Action::new("home.index");
let vis = Visibility::Condition(VisibilityCondition {
path: "/enabled".to_string(),
operator: VisibilityOperator::Exists,
value: None,
});
let spec = Spec::builder()
.element_nested(
"btn",
NestedElement::new("Button")
.action(action.clone())
.visible(vis.clone()),
)
.build()
.expect("spec with action+visible");
let el = spec.elements.get("btn").expect("btn present");
assert!(el.action.is_some(), "action must be preserved");
assert!(el.visible.is_some(), "visible must be preserved");
}
#[test]
fn nested_builder_and_flat_builder_produce_equivalent_specs() {
let nested = Spec::builder()
.element_nested(
"root",
NestedElement::new("Card")
.prop("title", "T")
.child(NestedElement::new("Text").prop("content", "hi")),
)
.build()
.expect("nested spec valid");
let flat = Spec::builder()
.element(
"root",
Element::new("Card").prop("title", "T").child("root-0"),
)
.element("root-0", Element::new("Text").prop("content", "hi"))
.build()
.expect("flat spec valid");
let nested_json = serde_json::to_value(&nested).unwrap();
let flat_json = serde_json::to_value(&flat).unwrap();
assert_eq!(nested_json, flat_json);
}
#[test]
fn validate_directives_called_between_no_dangling_and_cycle() {
let src = include_str!("spec.rs");
let validate_section = src
.split("fn validate_structure")
.nth(1)
.expect("validate_structure body present");
let body_end = validate_section
.find("\nfn ")
.unwrap_or(validate_section.len());
let body = &validate_section[..body_end];
let pos_no_dangling = body.find("validate_no_dangling").expect("no_dangling call");
let pos_directives = body.find("validate_directives").expect("directives call");
let pos_cycle = body.find("detect_cycle").expect("cycle call");
assert!(
pos_no_dangling < pos_directives,
"validate_directives must be called AFTER validate_no_dangling"
);
assert!(
pos_directives < pos_cycle,
"validate_directives must be called BEFORE detect_cycle"
);
}
#[test]
fn spec_title_literal_roundtrip() {
let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":"Hello"}"#;
let spec: Spec = serde_json::from_str(json).expect("parses");
match spec.title.as_ref().unwrap() {
TitleBinding::Literal(s) => assert_eq!(s, "Hello"),
other => panic!("expected Literal, got {other:?}"),
}
let back = serde_json::to_string(&spec).unwrap();
assert!(back.contains(r#""title":"Hello""#), "got: {back}");
}
#[test]
fn spec_title_binding_roundtrip() {
let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"$data":"/page_title"}}"#;
let spec: Spec = serde_json::from_str(json).expect("parses");
match spec.title.as_ref().unwrap() {
TitleBinding::Binding(DataRef { data }) => assert_eq!(data, "/page_title"),
other => panic!("expected Binding, got {other:?}"),
}
let back = serde_json::to_string(&spec).unwrap();
assert!(back.contains(r#""$data":"/page_title""#), "got: {back}");
}
#[test]
fn spec_title_absent() {
let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}}}"#;
let spec: Spec = serde_json::from_str(json).expect("parses");
assert!(spec.title.is_none());
}
#[test]
fn spec_title_invalid_shape_rejected() {
let json = r#"{"$schema":"ferro-json-ui/v2","root":"x","elements":{"x":{"type":"Text","props":{"content":"a"}}},"title":{"foo":"bar"}}"#;
let result: Result<Spec, _> = serde_json::from_str(json);
assert!(
result.is_err(),
"expected parse failure for {{foo:bar}} title shape"
);
}
}