Skip to main content

ferro_projections/
action.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::field::{DataType, FieldMeaning};
5
6/// A business operation schema describing what an action does and what it needs.
7///
8/// Actions are the verbs of a service: "submit order", "approve review", "send invoice".
9/// Each action defines its input contract, preconditions that must hold, effects that
10/// will occur, and an optional link to a state machine transition.
11///
12/// ```
13/// use ferro_projections::{ActionDef, InputDef, DataType, FieldMeaning};
14///
15/// let action = ActionDef::new("submit_order")
16///     .display_name("Submit Order")
17///     .description("Validates and submits a customer order for processing")
18///     .input(InputDef::new("order_id", DataType::Integer, FieldMeaning::Identifier))
19///     .precondition("has_items")
20///     .precondition("payment_valid")
21///     .effect("notify_customer")
22///     .transition_trigger("submit");
23/// ```
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
25pub struct ActionDef {
26    pub name: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub display_name: Option<String>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub description: Option<String>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub inputs: Vec<InputDef>,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub preconditions: Vec<String>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub effects: Vec<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub transition_trigger: Option<String>,
39}
40
41impl ActionDef {
42    /// Creates a new action definition with the given name.
43    pub fn new(name: impl Into<String>) -> Self {
44        Self {
45            name: name.into(),
46            display_name: None,
47            description: None,
48            inputs: Vec::new(),
49            preconditions: Vec::new(),
50            effects: Vec::new(),
51            transition_trigger: None,
52        }
53    }
54
55    /// Sets the human-readable display name.
56    pub fn display_name(mut self, name: impl Into<String>) -> Self {
57        self.display_name = Some(name.into());
58        self
59    }
60
61    /// Sets the action description.
62    pub fn description(mut self, desc: impl Into<String>) -> Self {
63        self.description = Some(desc.into());
64        self
65    }
66
67    /// Adds an input parameter to this action.
68    pub fn input(mut self, input: InputDef) -> Self {
69        self.inputs.push(input);
70        self
71    }
72
73    /// Adds a precondition guard name that must hold before this action can execute.
74    pub fn precondition(mut self, guard: impl Into<String>) -> Self {
75        self.preconditions.push(guard.into());
76        self
77    }
78
79    /// Adds an effect name that occurs when this action executes.
80    pub fn effect(mut self, effect: impl Into<String>) -> Self {
81        self.effects.push(effect.into());
82        self
83    }
84
85    /// Sets the state machine transition that this action triggers.
86    pub fn transition_trigger(mut self, trigger: impl Into<String>) -> Self {
87        self.transition_trigger = Some(trigger.into());
88        self
89    }
90}
91
92/// An input parameter definition for an action.
93///
94/// Reuses [`DataType`] and [`FieldMeaning`] from the field module to maintain
95/// a single type vocabulary across the projection schema.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
97pub struct InputDef {
98    pub name: String,
99    pub data_type: DataType,
100    pub meaning: FieldMeaning,
101    #[serde(default = "default_true")]
102    pub required: bool,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub description: Option<String>,
105}
106
107fn default_true() -> bool {
108    true
109}
110
111impl InputDef {
112    /// Creates a new required input parameter.
113    pub fn new(name: impl Into<String>, data_type: DataType, meaning: FieldMeaning) -> Self {
114        Self {
115            name: name.into(),
116            data_type,
117            meaning,
118            required: true,
119            description: None,
120        }
121    }
122
123    /// Sets whether this input is required.
124    pub fn required(mut self, required: bool) -> Self {
125        self.required = required;
126        self
127    }
128
129    /// Sets the input description.
130    pub fn description(mut self, desc: impl Into<String>) -> Self {
131        self.description = Some(desc.into());
132        self
133    }
134}
135
136/// A named boolean condition that guards action execution or state transitions.
137///
138/// Guards are declarative checks referenced by name. The actual evaluation logic
139/// lives outside the projection schema.
140///
141/// ```
142/// use ferro_projections::GuardDef;
143///
144/// let guard = GuardDef::new("has_items")
145///     .display_name("Has Items")
146///     .description("Order must contain at least one line item");
147/// ```
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
149pub struct GuardDef {
150    pub name: String,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub display_name: Option<String>,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub description: Option<String>,
155}
156
157impl GuardDef {
158    /// Creates a new guard definition with the given name.
159    pub fn new(name: impl Into<String>) -> Self {
160        Self {
161            name: name.into(),
162            display_name: None,
163            description: None,
164        }
165    }
166
167    /// Sets the human-readable display name.
168    pub fn display_name(mut self, name: impl Into<String>) -> Self {
169        self.display_name = Some(name.into());
170        self
171    }
172
173    /// Sets the guard description.
174    pub fn description(mut self, desc: impl Into<String>) -> Self {
175        self.description = Some(desc.into());
176        self
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::field::{DataType, FieldMeaning};
184
185    // -- ActionDef tests --
186
187    #[test]
188    fn action_def_minimal() {
189        let action = ActionDef::new("submit");
190        assert_eq!(action.name, "submit");
191        assert!(action.display_name.is_none());
192        assert!(action.description.is_none());
193        assert!(action.inputs.is_empty());
194        assert!(action.preconditions.is_empty());
195        assert!(action.effects.is_empty());
196        assert!(action.transition_trigger.is_none());
197    }
198
199    #[test]
200    fn action_def_builder_chain() {
201        let action = ActionDef::new("submit_order")
202            .display_name("Submit Order")
203            .description("Submits a customer order")
204            .input(InputDef::new(
205                "order_id",
206                DataType::Integer,
207                FieldMeaning::Identifier,
208            ))
209            .input(InputDef::new("notes", DataType::String, FieldMeaning::FreeText).required(false))
210            .precondition("has_items")
211            .precondition("payment_valid")
212            .effect("notify_customer")
213            .effect("send_confirmation")
214            .transition_trigger("submit");
215
216        assert_eq!(action.name, "submit_order");
217        assert_eq!(action.display_name.as_deref(), Some("Submit Order"));
218        assert_eq!(
219            action.description.as_deref(),
220            Some("Submits a customer order")
221        );
222        assert_eq!(action.inputs.len(), 2);
223        assert!(action.inputs[0].required);
224        assert!(!action.inputs[1].required);
225        assert_eq!(action.preconditions, vec!["has_items", "payment_valid"]);
226        assert_eq!(action.effects, vec!["notify_customer", "send_confirmation"]);
227        assert_eq!(action.transition_trigger.as_deref(), Some("submit"));
228    }
229
230    #[test]
231    fn action_def_serde_round_trip() {
232        let action = ActionDef::new("submit_order")
233            .display_name("Submit Order")
234            .input(InputDef::new(
235                "order_id",
236                DataType::Integer,
237                FieldMeaning::Identifier,
238            ))
239            .precondition("has_items")
240            .effect("notify")
241            .transition_trigger("submit");
242
243        let json = serde_json::to_string(&action).unwrap();
244        let parsed: ActionDef = serde_json::from_str(&json).unwrap();
245        assert_eq!(action, parsed);
246    }
247
248    #[test]
249    fn action_def_json_omits_empty_vecs_and_none() {
250        let action = ActionDef::new("simple");
251        let json = serde_json::to_string(&action).unwrap();
252        assert!(!json.contains("display_name"));
253        assert!(!json.contains("description"));
254        assert!(!json.contains("inputs"));
255        assert!(!json.contains("preconditions"));
256        assert!(!json.contains("effects"));
257        assert!(!json.contains("transition_trigger"));
258    }
259
260    #[test]
261    fn action_def_json_schema() {
262        let schema = schemars::schema_for!(ActionDef);
263        let value = schema.to_value();
264        let props = value
265            .get("properties")
266            .expect("ActionDef schema must have properties");
267        let obj = props.as_object().unwrap();
268        assert!(obj.contains_key("name"), "missing 'name' property");
269        assert!(obj.contains_key("inputs"), "missing 'inputs' property");
270        assert!(
271            obj.contains_key("preconditions"),
272            "missing 'preconditions' property"
273        );
274        assert!(obj.contains_key("effects"), "missing 'effects' property");
275    }
276
277    // -- InputDef tests --
278
279    #[test]
280    fn input_def_minimal() {
281        let input = InputDef::new("order_id", DataType::Integer, FieldMeaning::Identifier);
282        assert_eq!(input.name, "order_id");
283        assert_eq!(input.data_type, DataType::Integer);
284        assert_eq!(input.meaning, FieldMeaning::Identifier);
285        assert!(input.required);
286        assert!(input.description.is_none());
287    }
288
289    #[test]
290    fn input_def_builder_chain() {
291        let input = InputDef::new("notes", DataType::String, FieldMeaning::FreeText)
292            .required(false)
293            .description("Optional order notes");
294
295        assert_eq!(input.name, "notes");
296        assert!(!input.required);
297        assert_eq!(input.description.as_deref(), Some("Optional order notes"));
298    }
299
300    #[test]
301    fn input_def_serde_round_trip() {
302        let input = InputDef::new("email", DataType::String, FieldMeaning::Email)
303            .description("Customer email");
304
305        let json = serde_json::to_string(&input).unwrap();
306        let parsed: InputDef = serde_json::from_str(&json).unwrap();
307        assert_eq!(input, parsed);
308    }
309
310    #[test]
311    fn input_def_defaults() {
312        let json = r#"{"name":"total","data_type":"float","meaning":"money"}"#;
313        let parsed: InputDef = serde_json::from_str(json).unwrap();
314        assert!(parsed.required);
315        assert!(parsed.description.is_none());
316    }
317
318    #[test]
319    fn input_def_json_schema() {
320        let schema = schemars::schema_for!(InputDef);
321        let value = schema.to_value();
322        let props = value
323            .get("properties")
324            .expect("InputDef schema must have properties");
325        let obj = props.as_object().unwrap();
326        assert!(obj.contains_key("name"), "missing 'name' property");
327        assert!(
328            obj.contains_key("data_type"),
329            "missing 'data_type' property"
330        );
331        assert!(obj.contains_key("meaning"), "missing 'meaning' property");
332    }
333
334    // -- GuardDef tests --
335
336    #[test]
337    fn guard_def_minimal() {
338        let guard = GuardDef::new("has_items");
339        assert_eq!(guard.name, "has_items");
340        assert!(guard.display_name.is_none());
341        assert!(guard.description.is_none());
342    }
343
344    #[test]
345    fn guard_def_builder_chain() {
346        let guard = GuardDef::new("payment_valid")
347            .display_name("Payment Valid")
348            .description("Customer payment method has been verified");
349
350        assert_eq!(guard.name, "payment_valid");
351        assert_eq!(guard.display_name.as_deref(), Some("Payment Valid"));
352        assert_eq!(
353            guard.description.as_deref(),
354            Some("Customer payment method has been verified")
355        );
356    }
357
358    #[test]
359    fn guard_def_serde_round_trip() {
360        let guard = GuardDef::new("has_items")
361            .display_name("Has Items")
362            .description("Order must contain at least one item");
363
364        let json = serde_json::to_string(&guard).unwrap();
365        let parsed: GuardDef = serde_json::from_str(&json).unwrap();
366        assert_eq!(guard, parsed);
367    }
368
369    #[test]
370    fn guard_def_json_omits_none() {
371        let guard = GuardDef::new("simple");
372        let json = serde_json::to_string(&guard).unwrap();
373        assert!(!json.contains("display_name"));
374        assert!(!json.contains("description"));
375    }
376
377    #[test]
378    fn guard_def_json_schema() {
379        let schema = schemars::schema_for!(GuardDef);
380        let value = schema.to_value();
381        let props = value
382            .get("properties")
383            .expect("GuardDef schema must have properties");
384        let obj = props.as_object().unwrap();
385        assert!(obj.contains_key("name"), "missing 'name' property");
386    }
387
388    // -- Additional Phase 86-02 tests --
389
390    #[test]
391    fn action_def_without_transition_trigger() {
392        let action = ActionDef::new("update_notes")
393            .display_name("Update Notes")
394            .input(InputDef::new(
395                "notes",
396                DataType::String,
397                FieldMeaning::FreeText,
398            ))
399            .effect("log_change");
400
401        assert!(action.transition_trigger.is_none());
402        assert_eq!(action.effects, vec!["log_change"]);
403        assert_eq!(action.inputs.len(), 1);
404    }
405
406    #[test]
407    fn action_def_serde_minimal_round_trip() {
408        let action = ActionDef::new("simple");
409        let json = serde_json::to_string(&action).unwrap();
410        let parsed: ActionDef = serde_json::from_str(&json).unwrap();
411        assert_eq!(action, parsed);
412    }
413}