#![cfg(feature = "projections")]
use ferro_projections::render::{field_display_name, is_system_field};
use ferro_projections::{
FieldDef, Intent, IntentScore, NavigationHint, RelationshipDef, ServiceDef,
};
use ferro_theme::IntentSlotTemplate;
use crate::action::Action;
use crate::catalog::{global_catalog, Catalog};
use crate::component::{
CardProps, CardVariant, Column, DataTableProps, DescriptionItem, DescriptionListProps,
FormProps, KanbanBoardProps, KanbanColumnProps, StatCardProps, Tab, TableProps, TabsProps,
};
use crate::spec::{Element, ElementBuilder, Spec};
use super::component_map::{
build_badge_props, build_column_for_field, build_description_item, build_input_props,
build_progress_props, build_relationship_button_props, build_relationship_text_props,
build_select_props, build_switch_props, build_text_props, lookup_meaning, lookup_relationship,
};
use super::error::ProjectionError;
use super::intent_layout::{default_template, pick_intent_template};
use super::{RenderMode, VisualContext};
impl Spec {
pub fn from_service_def(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
if ctx.intent_index >= intents.len() {
return Err(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.intent_index,
available: intents.len(),
});
}
Self::from_service_def_with_catalog(service, intents, ctx, global_catalog())
}
pub(crate) fn from_service_def_with_catalog(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
catalog: &Catalog,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
let intent_score =
intents
.get(ctx.intent_index)
.ok_or(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.intent_index,
available: intents.len(),
})?;
let spec = if ctx.mode == RenderMode::Input {
build_input_spec(service)?
} else {
let template = ctx
.templates
.as_ref()
.and_then(|t| pick_intent_template(t, &intent_score.intent))
.cloned()
.unwrap_or_else(|| default_template(&intent_score.intent));
build_display_spec(service, &intent_score.intent, &template.display)?
};
match catalog.validate(&spec) {
Ok(()) => Ok(spec),
Err(errors) => {
#[cfg(debug_assertions)]
panic!("Projector emitted invalid spec: {errors:?}");
#[cfg(not(debug_assertions))]
{
Err(ProjectionError::CatalogValidation(errors))
}
}
}
}
}
fn resolve_title(service: &ServiceDef) -> String {
service
.display_name
.as_deref()
.unwrap_or(&service.name)
.to_string()
}
fn element_with_props(type_name: &str, props: serde_json::Value) -> ElementBuilder {
let obj = props
.as_object()
.expect("typed Props must serialize to a JSON object");
let mut el = Element::new(type_name);
for (k, v) in obj {
el = el.prop(k.clone(), v.clone());
}
el
}
fn element_with_props_and_children(
type_name: &str,
props: serde_json::Value,
children: Vec<String>,
) -> ElementBuilder {
let mut el = element_with_props(type_name, props);
for child in children {
el = el.child(child);
}
el
}
fn build_input_spec(service: &ServiceDef) -> Result<Spec, ProjectionError> {
let action = Action::new(format!("/{}", service.name));
let form_props = serde_json::to_value(FormProps {
action,
method: None,
guard: None,
max_width: None,
id: None,
enctype: None,
})
.expect("FormProps serialization cannot fail");
let mut children_ids: Vec<String> = Vec::new();
let mut field_elements: Vec<(String, ElementBuilder)> = Vec::new();
for field in service
.fields
.iter()
.filter(|f| f.writable && !is_system_field(&f.meaning))
{
let choice = lookup_meaning(&field.meaning);
let Some(type_name) = choice.input else {
continue;
};
let props = input_props_for(type_name, field)?;
let id = format!("field_{}", field.name);
field_elements.push((id.clone(), element_with_props(type_name, props)));
children_ids.push(id);
}
let root = element_with_props_and_children("Form", form_props, children_ids);
let mut builder = Spec::builder()
.title(resolve_title(service))
.element("root", root);
for (id, el) in field_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn input_props_for(
type_name: &str,
field: &FieldDef,
) -> Result<serde_json::Value, ProjectionError> {
match type_name {
"Input" => Ok(build_input_props(field)),
"Select" => Ok(build_select_props(field)),
"Switch" => Ok(build_switch_props(field)),
other => Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
}),
}
}
fn build_display_spec(
service: &ServiceDef,
intent: &Intent,
template: &IntentSlotTemplate,
) -> Result<Spec, ProjectionError> {
let layout = template.layout.as_deref().unwrap_or("Card");
let mut aux_elements: Vec<(String, ElementBuilder)> = Vec::new();
let root = match layout {
"DataTable" => emit_datatable_root(service),
"Card" => emit_card_root(service, &template.slots, &mut aux_elements),
"Form" => {
return build_input_spec(service);
}
"KanbanBoard" => emit_kanban_root(service),
"StatCard" => emit_statcard_root(service, &template.slots, &mut aux_elements),
other => {
return Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
});
}
};
let _ = intent;
let mut builder = Spec::builder()
.title(resolve_title(service))
.element("root", root);
for (id, el) in aux_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn emit_datatable_root(service: &ServiceDef) -> ElementBuilder {
let columns: Vec<Column> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).column.is_some())
.map(build_column_for_field)
.collect();
let props = serde_json::to_value(DataTableProps {
columns,
data_path: format!("/data/{}", service.name),
row_actions: None,
empty_message: None,
row_key: None,
row_href: None,
})
.expect("DataTableProps serialization cannot fail");
element_with_props("DataTable", props)
}
fn emit_card_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut children: Vec<String> = Vec::new();
for slot in slots {
match slot.as_str() {
"title" => { }
"fields" => emit_fields_as_description_list(service, aux, &mut children),
"relationships" => emit_relationships(service, aux, &mut children),
"actions" => emit_actions_placeholder(service, aux, &mut children),
"metadata" => emit_metadata(service, aux, &mut children),
"body" => emit_body_placeholder(aux, &mut children),
_ => {}
}
}
let props = serde_json::to_value(CardProps {
title: resolve_title(service),
description: None,
subtitle: None,
badge: None,
max_width: None,
footer: Vec::new(),
variant: CardVariant::Bordered,
})
.expect("CardProps serialization cannot fail");
let mut el = element_with_props("Card", props);
for id in children {
el = el.child(id);
}
el
}
fn emit_kanban_root(service: &ServiceDef) -> ElementBuilder {
let placeholder = KanbanColumnProps {
id: "default".to_string(),
title: resolve_title(service),
count: 0,
children: Vec::new(),
};
let props = serde_json::to_value(KanbanBoardProps {
columns: vec![placeholder],
data_path: None,
mobile_default_column: None,
empty_label: None,
})
.expect("KanbanBoardProps serialization cannot fail");
element_with_props("KanbanBoard", props)
}
fn emit_statcard_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut dropped: Vec<String> = Vec::new();
for slot in slots {
if slot == "metadata" {
emit_metadata(service, aux, &mut dropped);
}
}
let props = serde_json::to_value(StatCardProps {
label: resolve_title(service),
value: String::new(),
icon: None,
subtitle: None,
sse_target: None,
})
.expect("StatCardProps serialization cannot fail");
element_with_props("StatCard", props)
}
fn emit_fields_as_description_list(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).display.is_some())
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "fields_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
fn emit_relationships(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let tab_rels: Vec<&RelationshipDef> = service
.relationships
.iter()
.filter(|r| matches!(r.navigation, NavigationHint::Tab))
.collect();
if !tab_rels.is_empty() {
let tabs: Vec<Tab> = tab_rels
.iter()
.map(|r| Tab {
value: r.name.clone(),
label: field_display_name(&r.target),
children: Vec::new(),
})
.collect();
let default_tab = tabs.first().map(|t| t.value.clone()).unwrap_or_default();
let props = serde_json::to_value(TabsProps { default_tab, tabs })
.expect("TabsProps serialization cannot fail");
let id = "relationships_tabs".to_string();
aux.push((id.clone(), element_with_props("Tabs", props)));
children_out.push(id);
}
for rel in service.relationships.iter() {
if matches!(rel.navigation, NavigationHint::Tab) {
continue;
}
let Some(component) = lookup_relationship(rel.navigation) else {
continue; };
let props = match rel.navigation {
NavigationHint::Inline => build_relationship_text_props(rel),
NavigationHint::Link => build_relationship_button_props(rel),
NavigationHint::Nested => {
let col = Column {
key: "name".to_string(),
label: field_display_name(&rel.target),
format: None,
};
serde_json::to_value(TableProps {
columns: vec![col],
data_path: format!("/data/{}", rel.name),
row_actions: None,
empty_message: None,
sortable: None,
sort_column: None,
sort_direction: None,
})
.expect("TableProps serialization cannot fail")
}
_ => continue,
};
let id = format!("rel_{}", rel.name);
aux.push((id.clone(), element_with_props(component, props)));
children_out.push(id);
}
}
#[allow(clippy::ptr_arg)] fn emit_actions_placeholder(
_service: &ServiceDef,
_aux: &mut Vec<(String, ElementBuilder)>,
_children_out: &mut Vec<String>,
) {
}
fn emit_metadata(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| is_system_field(&f.meaning))
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "metadata_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
#[allow(clippy::ptr_arg)] fn emit_body_placeholder(
_aux: &mut Vec<(String, ElementBuilder)>,
_children_out: &mut Vec<String>,
) {
}
#[allow(dead_code)]
fn _reserved_props_noop() {
let _ = build_badge_props;
let _ = build_progress_props;
let _ = build_text_props;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
use ferro_projections::{derive_intents, DataType, FieldMeaning, ServiceDef};
use ferro_theme::{IntentModeTemplates, IntentSlotTemplate, ThemeTemplates};
fn sample_service() -> ServiceDef {
ServiceDef::new("product")
.display_name("Product")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::Float, FieldMeaning::Money)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
}
fn clean_catalog() -> Catalog {
Catalog::build_builtins_only().expect("builtins-only catalog builds clean")
}
#[test]
fn from_service_def_validates() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext::default();
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("valid projection should pass validation");
assert!(cat.validate(&spec).is_ok());
}
#[test]
fn from_service_def_browse_display() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("should project");
assert_eq!(spec.schema, "ferro-json-ui/v2");
let root = spec.elements.get(&spec.root).expect("root element exists");
assert_eq!(root.type_name, "DataTable");
}
#[test]
fn input_mode_always_form() {
let service = sample_service();
let intents = derive_intents(&service);
assert!(!intents.is_empty(), "sample service must derive intents");
let cat = clean_catalog();
for idx in 0..intents.len() {
let ctx = VisualContext {
intent_index: idx,
mode: RenderMode::Input,
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("every intent should project in Input mode");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "Form",
"intent at index {idx} did not collapse to Form in Input mode"
);
}
}
#[test]
fn system_fields_excluded() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat).unwrap();
let root = spec.elements.get(&spec.root).unwrap();
let columns = root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(keys.contains(&"name"), "name column expected: {keys:?}");
assert!(keys.contains(&"price"), "price column expected: {keys:?}");
assert!(
!keys.contains(&"id"),
"id column must be excluded: {keys:?}"
);
assert!(
!keys.contains(&"created_at"),
"created_at column must be excluded: {keys:?}"
);
}
#[test]
fn template_override() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
templates: Some(templates),
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("override projects");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "StatCard",
"theme override must win over default_template"
);
}
#[test]
fn sensitive_field_never_appears_in_display_or_column() {
let service = ServiceDef::new("user")
.display_name("User")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("password_hash", DataType::String, FieldMeaning::Sensitive);
let intents = derive_intents(&service);
let cat = clean_catalog();
let browse_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0);
let browse_ctx = VisualContext {
intent_index: browse_idx,
mode: RenderMode::Display,
..Default::default()
};
let browse_spec =
Spec::from_service_def_with_catalog(&service, &intents, &browse_ctx, &cat).unwrap();
let browse_root = browse_spec.elements.get(&browse_spec.root).unwrap();
let columns = browse_root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let column_keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(
!column_keys.contains(&"password_hash"),
"Sensitive field leaked into DataTable columns: {column_keys:?}"
);
let focus_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Focus))
.unwrap_or(0);
let focus_ctx = VisualContext {
intent_index: focus_idx,
mode: RenderMode::Display,
..Default::default()
};
let focus_spec =
Spec::from_service_def_with_catalog(&service, &intents, &focus_ctx, &cat).unwrap();
let leaked = focus_spec.elements.values().any(|el| {
el.type_name == "DescriptionList"
&& el
.props
.get("items")
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter().any(|item| {
item.get("label")
.and_then(|l| l.as_str())
.map(|s| s.to_lowercase().contains("password"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
assert!(
!leaked,
"Sensitive field leaked into a DescriptionList item"
);
}
#[test]
fn input_props_for_unknown_type_returns_unknown_component_error() {
let field = ferro_projections::FieldDef {
name: "x".into(),
data_type: DataType::String,
meaning: FieldMeaning::Email,
required: false,
is_list: false,
readable: true,
writable: true,
};
let result = super::input_props_for("DatePicker", &field);
match result {
Err(ProjectionError::UnknownComponent { type_name }) => {
assert_eq!(type_name, "DatePicker");
}
other => panic!("expected UnknownComponent, got {other:?}"),
}
}
#[test]
fn statcard_metadata_is_orphan_element() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
mode: RenderMode::Display,
templates: Some(templates),
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("StatCard+metadata projects");
assert!(
spec.elements.contains_key("metadata_list"),
"metadata DescriptionList must be emitted as a sibling element"
);
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(root.type_name, "StatCard");
assert!(
!root.children.contains(&"metadata_list".to_string()),
"StatCard root must not claim metadata_list as a child: {:?}",
root.children
);
}
#[test]
fn empty_intents_returns_error() {
let service = sample_service();
let cat = clean_catalog();
let result =
Spec::from_service_def_with_catalog(&service, &[], &VisualContext::default(), &cat);
assert!(matches!(result, Err(ProjectionError::EmptyIntents)));
}
#[test]
fn out_of_bounds_intent_index_returns_error() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents.len() + 5,
..Default::default()
};
let cat = clean_catalog();
let result = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat);
match result {
Err(ProjectionError::IntentIndexOutOfBounds {
requested,
available,
}) => {
assert_eq!(requested, intents.len() + 5);
assert_eq!(available, intents.len());
}
other => panic!("expected IntentIndexOutOfBounds, got {other:?}"),
}
}
}