ferro_projections/render/
template.rs1use serde_json::{json, Map, Value};
8
9use crate::error::Error;
10use crate::intent::IntentScore;
11use crate::service::ServiceDef;
12
13use super::{is_system_field, BaseContext, Renderer};
14
15pub struct TemplateRenderer;
65
66impl Renderer for TemplateRenderer {
67 type Output = serde_json::Value;
68 type Context = BaseContext;
69
70 fn render(
71 &self,
72 service: &ServiceDef,
73 _intents: &[IntentScore],
74 _ctx: &BaseContext,
75 ) -> Result<Value, Error> {
76 let mut fields = Map::new();
78 for f in &service.fields {
79 if !is_system_field(&f.meaning) {
80 fields.insert(
81 f.name.clone(),
82 json!({
83 "name": f.name,
84 "data_type": f.data_type,
85 "meaning": f.meaning,
86 "required": f.required,
87 }),
88 );
89 }
90 }
91
92 let actions: Vec<Value> = service
94 .actions
95 .iter()
96 .map(|a| {
97 let inputs: Vec<Value> = a
98 .inputs
99 .iter()
100 .map(|i| {
101 json!({
102 "name": i.name,
103 "data_type": i.data_type,
104 "required": i.required,
105 })
106 })
107 .collect();
108 json!({
109 "name": a.name,
110 "display_name": a.display_name.as_deref().unwrap_or(&a.name),
111 "inputs": inputs,
112 })
113 })
114 .collect();
115
116 let state_machine: Option<Value> = service.state_machine.as_ref().map(|sm| {
118 let states: Vec<Value> = sm
119 .states
120 .iter()
121 .map(|s| {
122 json!({
123 "name": s.name,
124 "display_name": s.display_name.as_deref().unwrap_or(&s.name),
125 "is_final": s.is_final,
126 })
127 })
128 .collect();
129 let transitions: Vec<Value> = sm
130 .transitions
131 .iter()
132 .map(|t| {
133 json!({
134 "from": t.from,
135 "event": t.event,
136 "to": t.to,
137 })
138 })
139 .collect();
140 json!({
141 "initial_state": sm.initial_state,
142 "states": states,
143 "transitions": transitions,
144 })
145 });
146
147 Ok(json!({
148 "service": service.display_name.as_deref().unwrap_or(&service.name),
149 "fields": Value::Object(fields),
150 "actions": actions,
151 "state_machine": state_machine,
152 }))
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::action::{ActionDef, InputDef};
160 use crate::derive::derive_intents;
161 use crate::field::{DataType, FieldMeaning};
162 use crate::service::ServiceDef;
163 use crate::state::{StateDef, StateMachine, Transition};
164
165 fn render(svc: &ServiceDef) -> Value {
166 let intents = derive_intents(svc);
167 let renderer = TemplateRenderer;
168 renderer
169 .render(svc, &intents, &BaseContext::default())
170 .expect("render must succeed")
171 }
172
173 #[test]
174 fn fields_use_original_names() {
175 let svc = ServiceDef::new("order")
176 .field("id", DataType::Integer, FieldMeaning::Identifier)
177 .field("total", DataType::Float, FieldMeaning::Money)
178 .field("status", DataType::String, FieldMeaning::Status);
179
180 let result = render(&svc);
181 let fields = result["fields"].as_object().unwrap();
182
183 assert!(
185 !fields.contains_key("id"),
186 "id (Identifier) must be excluded"
187 );
188 assert!(fields.contains_key("total"), "total must be present");
190 assert!(fields.contains_key("status"), "status must be present");
191 }
192
193 #[test]
194 fn field_values_include_metadata() {
195 let svc = ServiceDef::new("order").field("total", DataType::Float, FieldMeaning::Money);
196
197 let result = render(&svc);
198 let total = &result["fields"]["total"];
199
200 assert_eq!(total["name"], "total");
201 assert_eq!(total["meaning"], "money");
202 assert_eq!(total["required"], true);
203 }
204
205 #[test]
206 fn actions_include_display_name_and_inputs() {
207 let svc = ServiceDef::new("cart")
208 .field("item", DataType::String, FieldMeaning::EntityName)
209 .action(
210 ActionDef::new("add_to_cart")
211 .display_name("Add to Cart")
212 .input(InputDef::new(
213 "quantity",
214 DataType::Integer,
215 FieldMeaning::Quantity,
216 )),
217 );
218
219 let result = render(&svc);
220 let actions = result["actions"].as_array().unwrap();
221
222 assert_eq!(actions.len(), 1);
223 let action = &actions[0];
224 assert_eq!(action["name"], "add_to_cart");
225 assert_eq!(action["display_name"], "Add to Cart");
226
227 let inputs = action["inputs"].as_array().unwrap();
228 assert_eq!(inputs.len(), 1);
229 assert_eq!(inputs[0]["name"], "quantity");
230 }
231
232 #[test]
233 fn state_machine_states_and_transitions() {
234 let sm = StateMachine::new("lifecycle")
235 .initial("pending")
236 .state(StateDef::new("pending").display_name("Pending"))
237 .state(StateDef::new("done").display_name("Done").final_state())
238 .transition(Transition::new("pending", "complete", "done"));
239
240 let svc = ServiceDef::new("task")
241 .field("name", DataType::String, FieldMeaning::EntityName)
242 .state_machine(sm);
243
244 let result = render(&svc);
245 let sm_val = &result["state_machine"];
246
247 assert!(!sm_val.is_null(), "state_machine must not be null");
248 let states = sm_val["states"].as_array().unwrap();
249 assert_eq!(states.len(), 2, "should have 2 states");
250
251 let transitions = sm_val["transitions"].as_array().unwrap();
252 assert_eq!(transitions.len(), 1, "should have 1 transition");
253 assert_eq!(transitions[0]["from"], "pending");
254 assert_eq!(transitions[0]["event"], "complete");
255 assert_eq!(transitions[0]["to"], "done");
256 }
257
258 #[test]
259 fn no_state_machine_produces_null() {
260 let svc =
261 ServiceDef::new("product").field("name", DataType::String, FieldMeaning::EntityName);
262
263 let result = render(&svc);
264 assert!(result["state_machine"].is_null());
265 }
266
267 #[test]
268 fn service_display_name_present() {
269 let svc = ServiceDef::new("order").display_name("Order Management");
270
271 let result = render(&svc);
272 assert_eq!(result["service"], "Order Management");
273 }
274
275 #[test]
276 fn service_name_fallback_when_no_display_name() {
277 let svc = ServiceDef::new("order");
278 let result = render(&svc);
279 assert_eq!(result["service"], "order");
280 }
281
282 #[test]
283 fn empty_actions_produces_empty_array() {
284 let svc =
285 ServiceDef::new("product").field("name", DataType::String, FieldMeaning::EntityName);
286
287 let result = render(&svc);
288 let actions = result["actions"].as_array().unwrap();
289 assert!(actions.is_empty());
290 }
291}