Skip to main content

ferro_projections/render/
template.rs

1//! Template renderer producing structured JSON context from service definitions.
2//!
3//! Implements the `Renderer` trait to translate `ServiceDef` into a nested
4//! JSON object with semantic groups: `fields`, `actions`, and `state_machine`.
5//! Consumed by template engines (e.g., MiniJinja) in downstream projects.
6
7use 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
15/// Template context renderer producing structured JSON from service definitions.
16///
17/// Translates a `ServiceDef` into a flat, semantic JSON object that template
18/// engines can consume directly. Intent scores and render context are ignored —
19/// the output is intent-agnostic per design (D-01 through D-08).
20///
21/// # Output shape
22///
23/// ```json
24/// {
25///   "service": "Order",
26///   "fields": {
27///     "total": { "name": "total", "data_type": "float", "meaning": "money", "required": true }
28///   },
29///   "actions": [
30///     { "name": "submit", "display_name": "Submit", "inputs": [] }
31///   ],
32///   "state_machine": {
33///     "initial_state": "draft",
34///     "states": [{ "name": "draft", "display_name": "Draft", "is_final": false }],
35///     "transitions": [{ "from": "draft", "event": "submit", "to": "pending" }]
36///   }
37/// }
38/// ```
39///
40/// `state_machine` is `null` when the service has no state machine.
41///
42/// # Example
43///
44/// ```
45/// use ferro_projections::{
46///     ServiceDef, DataType, FieldMeaning, derive_intents, TemplateRenderer, Renderer, BaseContext,
47/// };
48///
49/// let svc = ServiceDef::new("order")
50///     .display_name("Order")
51///     .field("id", DataType::Integer, FieldMeaning::Identifier)
52///     .field("total", DataType::Float, FieldMeaning::Money);
53///
54/// let intents = derive_intents(&svc);
55/// let renderer = TemplateRenderer;
56/// let result = renderer.render(&svc, &intents, &BaseContext::default());
57/// assert!(result.is_ok());
58///
59/// let json = result.unwrap();
60/// assert_eq!(json["service"], "Order");
61/// assert!(json["fields"]["total"].is_object());
62/// assert!(!json["fields"].as_object().unwrap().contains_key("id"));
63/// ```
64pub 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        // Build fields map: keyed by name, excluding system fields.
77        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        // Build actions array: rich objects with display_name and inputs.
93        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        // Build state_machine: null if absent.
117        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        // System field excluded
184        assert!(
185            !fields.contains_key("id"),
186            "id (Identifier) must be excluded"
187        );
188        // Domain fields included
189        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}