Skip to main content

mockforge_foundation/state_machine/
rules.rs

1//! Consistency rules and state machines for intelligent behavior
2
3use super::sub_scenario::SubScenario;
4use super::visual_layout::VisualLayout;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Consistency rule that enforces logical behavior patterns
9#[derive(Debug, Clone, Serialize, Deserialize)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub struct ConsistencyRule {
12    /// Rule name
13    pub name: String,
14
15    /// Description of what this rule does
16    pub description: Option<String>,
17
18    /// Condition for applying the rule (e.g., "path starts_with '/api/cart'")
19    pub condition: String,
20
21    /// Action to take when condition matches
22    pub action: RuleAction,
23
24    /// Priority (higher priority rules are evaluated first)
25    #[serde(default)]
26    pub priority: i32,
27}
28
29/// Action to take when a consistency rule matches
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(tag = "type", rename_all = "lowercase")]
33pub enum RuleAction {
34    /// Return an error response
35    Error {
36        /// HTTP status code
37        status: u16,
38        /// Error message
39        message: String,
40    },
41
42    /// Transform the request before processing
43    Transform {
44        /// Description of the transformation
45        description: String,
46    },
47
48    /// Execute a chain of requests
49    ExecuteChain {
50        /// Chain ID to execute
51        chain_id: String,
52    },
53
54    /// Require authentication
55    RequireAuth {
56        /// Error message if not authenticated
57        message: String,
58    },
59
60    /// Apply a state transition
61    StateTransition {
62        /// Resource type
63        resource_type: String,
64        /// Transition name
65        transition: String,
66    },
67}
68
69impl ConsistencyRule {
70    /// Create a new consistency rule
71    pub fn new(name: impl Into<String>, condition: impl Into<String>, action: RuleAction) -> Self {
72        Self {
73            name: name.into(),
74            description: None,
75            condition: condition.into(),
76            action,
77            priority: 0,
78        }
79    }
80
81    /// Set description
82    pub fn with_description(mut self, description: impl Into<String>) -> Self {
83        self.description = Some(description.into());
84        self
85    }
86
87    /// Set priority
88    pub fn with_priority(mut self, priority: i32) -> Self {
89        self.priority = priority;
90        self
91    }
92
93    /// Check if this rule's condition matches the given request
94    ///
95    /// This is a simplified implementation. In production, you'd want a more
96    /// sophisticated condition evaluator (e.g., using a DSL or expression language).
97    pub fn matches(&self, method: &str, path: &str) -> bool {
98        // Simple condition parsing
99        if self.condition.contains("path starts_with") {
100            if let Some(prefix) = self.condition.split('\'').nth(1) {
101                return path.starts_with(prefix);
102            }
103        }
104
105        if self.condition.contains("method ==") {
106            if let Some(expected_method) = self.condition.split('\'').nth(1) {
107                return method.eq_ignore_ascii_case(expected_method);
108            }
109        }
110
111        false
112    }
113}
114
115/// State machine for resource lifecycle management
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct StateMachine {
119    /// Resource type this state machine applies to
120    pub resource_type: String,
121
122    /// All possible states
123    pub states: Vec<String>,
124
125    /// Initial state for new resources
126    pub initial_state: String,
127
128    /// Allowed transitions between states
129    pub transitions: Vec<StateTransition>,
130
131    /// Nested sub-scenarios that can be referenced from this state machine
132    #[serde(default)]
133    pub sub_scenarios: Vec<SubScenario>,
134
135    /// Visual layout information for the editor (node positions, edges, etc.)
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub visual_layout: Option<VisualLayout>,
138
139    /// Additional metadata for editor-specific data
140    #[serde(default)]
141    pub metadata: HashMap<String, serde_json::Value>,
142}
143
144impl StateMachine {
145    /// Create a new state machine
146    pub fn new(
147        resource_type: impl Into<String>,
148        states: Vec<String>,
149        initial_state: impl Into<String>,
150    ) -> Self {
151        Self {
152            resource_type: resource_type.into(),
153            states,
154            initial_state: initial_state.into(),
155            transitions: Vec::new(),
156            sub_scenarios: Vec::new(),
157            visual_layout: None,
158            metadata: HashMap::new(),
159        }
160    }
161
162    /// Add a transition
163    pub fn add_transition(mut self, transition: StateTransition) -> Self {
164        self.transitions.push(transition);
165        self
166    }
167
168    /// Add multiple transitions
169    pub fn add_transitions(mut self, transitions: Vec<StateTransition>) -> Self {
170        self.transitions.extend(transitions);
171        self
172    }
173
174    /// Check if a transition is allowed
175    pub fn can_transition(&self, from: &str, to: &str) -> bool {
176        self.transitions.iter().any(|t| t.from_state == from && t.to_state == to)
177    }
178
179    /// Get next possible states from current state
180    pub fn next_states(&self, current: &str) -> Vec<String> {
181        self.transitions
182            .iter()
183            .filter(|t| t.from_state == current)
184            .map(|t| t.to_state.clone())
185            .collect()
186    }
187
188    /// Select next state based on probabilities
189    pub fn select_next_state(&self, current: &str) -> Option<String> {
190        let candidates: Vec<&StateTransition> =
191            self.transitions.iter().filter(|t| t.from_state == current).collect();
192
193        if candidates.is_empty() {
194            return None;
195        }
196
197        // Calculate cumulative probabilities
198        let total_probability: f64 = candidates.iter().map(|t| t.probability).sum();
199        let mut cumulative = 0.0;
200        let random = rand::random::<f64>() * total_probability;
201
202        for transition in &candidates {
203            cumulative += transition.probability;
204            if random <= cumulative {
205                return Some(transition.to_state.clone());
206            }
207        }
208
209        // Fallback to first transition
210        Some(candidates[0].to_state.clone())
211    }
212
213    /// Add a sub-scenario
214    pub fn add_sub_scenario(mut self, sub_scenario: SubScenario) -> Self {
215        self.sub_scenarios.push(sub_scenario);
216        self
217    }
218
219    /// Set visual layout
220    pub fn with_visual_layout(mut self, layout: VisualLayout) -> Self {
221        self.visual_layout = Some(layout);
222        self
223    }
224
225    /// Set metadata value
226    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
227        self.metadata.insert(key.into(), value);
228        self
229    }
230
231    /// Get a sub-scenario by ID
232    pub fn get_sub_scenario(&self, id: &str) -> Option<&SubScenario> {
233        self.sub_scenarios.iter().find(|s| s.id == id)
234    }
235
236    /// Get a sub-scenario by ID mutably
237    pub fn get_sub_scenario_mut(&mut self, id: &str) -> Option<&mut SubScenario> {
238        self.sub_scenarios.iter_mut().find(|s| s.id == id)
239    }
240}
241
242/// State transition definition
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245pub struct StateTransition {
246    /// Source state
247    #[serde(rename = "from")]
248    pub from_state: String,
249
250    /// Destination state
251    #[serde(rename = "to")]
252    pub to_state: String,
253
254    /// Probability of this transition occurring (0.0 to 1.0)
255    #[serde(default = "default_probability")]
256    pub probability: f64,
257
258    /// Optional condition for this transition (legacy string format)
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub condition: Option<String>,
261
262    /// Optional side effects of this transition
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub side_effects: Option<Vec<String>>,
265
266    /// JavaScript/TypeScript expression for conditional transition
267    ///
268    /// This is the new preferred way to specify conditions. Supports full
269    /// JavaScript expressions with variable access, comparison, and logical operators.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub condition_expression: Option<String>,
272
273    /// Parsed condition AST for validation (not serialized, computed on demand)
274    #[serde(skip)]
275    pub condition_ast: Option<serde_json::Value>,
276
277    /// Reference to a sub-scenario to execute during this transition
278    ///
279    /// If set, the sub-scenario will be executed when this transition is taken.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub sub_scenario_ref: Option<String>,
282}
283
284impl StateTransition {
285    /// Create a new state transition
286    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
287        Self {
288            from_state: from.into(),
289            to_state: to.into(),
290            probability: default_probability(),
291            condition: None,
292            side_effects: None,
293            condition_expression: None,
294            condition_ast: None,
295            sub_scenario_ref: None,
296        }
297    }
298
299    /// Set probability
300    pub fn with_probability(mut self, probability: f64) -> Self {
301        self.probability = probability.clamp(0.0, 1.0);
302        self
303    }
304
305    /// Set condition
306    pub fn with_condition(mut self, condition: impl Into<String>) -> Self {
307        self.condition = Some(condition.into());
308        self
309    }
310
311    /// Add side effect
312    pub fn with_side_effect(mut self, effect: impl Into<String>) -> Self {
313        let mut effects = self.side_effects.unwrap_or_default();
314        effects.push(effect.into());
315        self.side_effects = Some(effects);
316        self
317    }
318
319    /// Set condition expression (JavaScript/TypeScript)
320    pub fn with_condition_expression(mut self, expression: impl Into<String>) -> Self {
321        self.condition_expression = Some(expression.into());
322        self
323    }
324
325    /// Set sub-scenario reference
326    pub fn with_sub_scenario_ref(mut self, sub_scenario_id: impl Into<String>) -> Self {
327        self.sub_scenario_ref = Some(sub_scenario_id.into());
328        self
329    }
330}
331
332fn default_probability() -> f64 {
333    1.0
334}
335
336/// Evaluation context for rules and conditions
337#[derive(Debug, Clone)]
338pub struct EvaluationContext {
339    /// HTTP method
340    pub method: String,
341
342    /// Request path
343    pub path: String,
344
345    /// Request headers
346    pub headers: HashMap<String, String>,
347
348    /// Session state
349    pub session_state: HashMap<String, serde_json::Value>,
350}
351
352impl EvaluationContext {
353    /// Create a new evaluation context
354    pub fn new(method: impl Into<String>, path: impl Into<String>) -> Self {
355        Self {
356            method: method.into(),
357            path: path.into(),
358            headers: HashMap::new(),
359            session_state: HashMap::new(),
360        }
361    }
362
363    /// Add headers
364    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
365        self.headers = headers;
366        self
367    }
368
369    /// Add session state
370    pub fn with_session_state(mut self, state: HashMap<String, serde_json::Value>) -> Self {
371        self.session_state = state;
372        self
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_consistency_rule_matches() {
382        let rule = ConsistencyRule::new(
383            "require_auth",
384            "path starts_with '/api/cart'",
385            RuleAction::RequireAuth {
386                message: "Authentication required".to_string(),
387            },
388        );
389
390        assert!(rule.matches("GET", "/api/cart"));
391        assert!(rule.matches("POST", "/api/cart/items"));
392        assert!(!rule.matches("GET", "/api/products"));
393    }
394
395    #[test]
396    fn test_state_machine_transitions() {
397        let machine = StateMachine::new(
398            "order",
399            vec![
400                "pending".to_string(),
401                "processing".to_string(),
402                "shipped".to_string(),
403                "delivered".to_string(),
404            ],
405            "pending",
406        )
407        .add_transition(StateTransition::new("pending", "processing").with_probability(0.8))
408        .add_transition(StateTransition::new("processing", "shipped").with_probability(0.9))
409        .add_transition(StateTransition::new("shipped", "delivered").with_probability(1.0));
410
411        assert!(machine.can_transition("pending", "processing"));
412        assert!(machine.can_transition("processing", "shipped"));
413        assert!(!machine.can_transition("pending", "shipped")); // No direct transition
414    }
415
416    #[test]
417    fn test_state_machine_next_states() {
418        let machine = StateMachine::new(
419            "order",
420            vec![
421                "pending".to_string(),
422                "processing".to_string(),
423                "cancelled".to_string(),
424            ],
425            "pending",
426        )
427        .add_transition(StateTransition::new("pending", "processing"))
428        .add_transition(StateTransition::new("pending", "cancelled"));
429
430        let next = machine.next_states("pending");
431        assert_eq!(next.len(), 2);
432        assert!(next.contains(&"processing".to_string()));
433        assert!(next.contains(&"cancelled".to_string()));
434    }
435
436    #[test]
437    fn test_rule_action_serialization() {
438        let action = RuleAction::Error {
439            status: 401,
440            message: "Unauthorized".to_string(),
441        };
442
443        let json = serde_json::to_string(&action).unwrap();
444        assert!(json.contains("\"type\":\"error\""));
445        assert!(json.contains("401"));
446
447        let deserialized: RuleAction = serde_json::from_str(&json).unwrap();
448        match deserialized {
449            RuleAction::Error { status, message } => {
450                assert_eq!(status, 401);
451                assert_eq!(message, "Unauthorized");
452            }
453            _ => panic!("Unexpected action type"),
454        }
455    }
456
457    #[test]
458    fn test_state_transition_probability() {
459        let transition = StateTransition::new("pending", "processing").with_probability(0.75);
460
461        assert_eq!(transition.probability, 0.75);
462
463        // Test clamping
464        let transition_clamped = StateTransition::new("a", "b").with_probability(1.5);
465        assert_eq!(transition_clamped.probability, 1.0);
466    }
467}