mockforge_foundation/state_machine/
rules.rs1use super::sub_scenario::SubScenario;
4use super::visual_layout::VisualLayout;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
11pub struct ConsistencyRule {
12 pub name: String,
14
15 pub description: Option<String>,
17
18 pub condition: String,
20
21 pub action: RuleAction,
23
24 #[serde(default)]
26 pub priority: i32,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(tag = "type", rename_all = "lowercase")]
33pub enum RuleAction {
34 Error {
36 status: u16,
38 message: String,
40 },
41
42 Transform {
44 description: String,
46 },
47
48 ExecuteChain {
50 chain_id: String,
52 },
53
54 RequireAuth {
56 message: String,
58 },
59
60 StateTransition {
62 resource_type: String,
64 transition: String,
66 },
67}
68
69impl ConsistencyRule {
70 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 pub fn with_description(mut self, description: impl Into<String>) -> Self {
83 self.description = Some(description.into());
84 self
85 }
86
87 pub fn with_priority(mut self, priority: i32) -> Self {
89 self.priority = priority;
90 self
91 }
92
93 pub fn matches(&self, method: &str, path: &str) -> bool {
98 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#[derive(Debug, Clone, Serialize, Deserialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct StateMachine {
119 pub resource_type: String,
121
122 pub states: Vec<String>,
124
125 pub initial_state: String,
127
128 pub transitions: Vec<StateTransition>,
130
131 #[serde(default)]
133 pub sub_scenarios: Vec<SubScenario>,
134
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub visual_layout: Option<VisualLayout>,
138
139 #[serde(default)]
141 pub metadata: HashMap<String, serde_json::Value>,
142}
143
144impl StateMachine {
145 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 pub fn add_transition(mut self, transition: StateTransition) -> Self {
164 self.transitions.push(transition);
165 self
166 }
167
168 pub fn add_transitions(mut self, transitions: Vec<StateTransition>) -> Self {
170 self.transitions.extend(transitions);
171 self
172 }
173
174 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 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 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 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 Some(candidates[0].to_state.clone())
211 }
212
213 pub fn add_sub_scenario(mut self, sub_scenario: SubScenario) -> Self {
215 self.sub_scenarios.push(sub_scenario);
216 self
217 }
218
219 pub fn with_visual_layout(mut self, layout: VisualLayout) -> Self {
221 self.visual_layout = Some(layout);
222 self
223 }
224
225 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 pub fn get_sub_scenario(&self, id: &str) -> Option<&SubScenario> {
233 self.sub_scenarios.iter().find(|s| s.id == id)
234 }
235
236 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#[derive(Debug, Clone, Serialize, Deserialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245pub struct StateTransition {
246 #[serde(rename = "from")]
248 pub from_state: String,
249
250 #[serde(rename = "to")]
252 pub to_state: String,
253
254 #[serde(default = "default_probability")]
256 pub probability: f64,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub condition: Option<String>,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub side_effects: Option<Vec<String>>,
265
266 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub condition_expression: Option<String>,
272
273 #[serde(skip)]
275 pub condition_ast: Option<serde_json::Value>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub sub_scenario_ref: Option<String>,
282}
283
284impl StateTransition {
285 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 pub fn with_probability(mut self, probability: f64) -> Self {
301 self.probability = probability.clamp(0.0, 1.0);
302 self
303 }
304
305 pub fn with_condition(mut self, condition: impl Into<String>) -> Self {
307 self.condition = Some(condition.into());
308 self
309 }
310
311 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 pub fn with_condition_expression(mut self, expression: impl Into<String>) -> Self {
321 self.condition_expression = Some(expression.into());
322 self
323 }
324
325 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#[derive(Debug, Clone)]
338pub struct EvaluationContext {
339 pub method: String,
341
342 pub path: String,
344
345 pub headers: HashMap<String, String>,
347
348 pub session_state: HashMap<String, serde_json::Value>,
350}
351
352impl EvaluationContext {
353 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 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
365 self.headers = headers;
366 self
367 }
368
369 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")); }
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 let transition_clamped = StateTransition::new("a", "b").with_probability(1.5);
465 assert_eq!(transition_clamped.probability, 1.0);
466 }
467}