pub mod builder;
pub mod component_map;
pub mod error;
pub mod intent_layout;
pub use error::ProjectionError;
use serde::{Deserialize, Serialize};
use ferro_projections::render::Renderer;
use ferro_projections::Error;
use ferro_projections::IntentScore;
use ferro_projections::ServiceDef;
use ferro_theme::ThemeTemplates;
use crate::spec::Spec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RenderMode {
Display,
Input,
}
#[derive(Debug, Clone)]
pub struct VisualContext {
pub intent_index: usize,
pub current_state: Option<String>,
pub mode: RenderMode,
pub templates: Option<ThemeTemplates>,
}
impl Default for VisualContext {
fn default() -> Self {
Self {
intent_index: 0,
current_state: None,
mode: RenderMode::Display,
templates: None,
}
}
}
pub struct JsonUiRenderer;
impl Renderer for JsonUiRenderer {
type Output = Spec;
type Context = VisualContext;
fn render(
&self,
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
) -> Result<Spec, Error> {
Spec::from_service_def(service, intents, ctx).map_err(|e| Error::Render(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_projections::{derive_intents, DataType, FieldMeaning, ServiceDef};
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)
}
#[test]
fn render_mode_serde_round_trip() {
let mode = RenderMode::Display;
let json = serde_json::to_string(&mode).unwrap();
assert_eq!(json, "\"display\"");
let parsed: RenderMode = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, mode);
}
#[test]
fn render_mode_display_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&RenderMode::Display).unwrap(),
"\"display\""
);
assert_eq!(
serde_json::to_string(&RenderMode::Input).unwrap(),
"\"input\""
);
}
#[test]
fn visual_context_default_has_sensible_values() {
let ctx = VisualContext::default();
assert_eq!(ctx.intent_index, 0);
assert!(ctx.current_state.is_none());
assert_eq!(ctx.mode, RenderMode::Display);
assert!(ctx.templates.is_none());
}
#[test]
fn render_out_of_bounds_intent_returns_render_error() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
intent_index: intents.len() + 5,
..Default::default()
};
let result = JsonUiRenderer.render(&service, &intents, &ctx);
match result {
Err(Error::Render(msg)) => assert!(msg.contains("out of bounds")),
_ => panic!("expected Error::Render, got {result:?}"),
}
}
#[test]
fn render_empty_intents_returns_render_error() {
let service = sample_service();
let result = JsonUiRenderer.render(&service, &[], &VisualContext::default());
assert!(matches!(result, Err(Error::Render(_))));
}
}