mockforge_core/openapi/
response_selection.rs

1//! Response selection modes for multiple responses/examples
2//!
3//! This module provides functionality for selecting responses when multiple
4//! options are available (scenarios, examples, or status codes).
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10
11/// Mode for selecting responses when multiple options are available
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "lowercase")]
14pub enum ResponseSelectionMode {
15    /// Use first available (default behavior)
16    First,
17    /// Select by scenario name (requires X-Mockforge-Scenario header)
18    Scenario,
19    /// Round-robin sequential selection
20    Sequential,
21    /// Random selection
22    Random,
23    /// Weighted random selection (weights defined per option)
24    WeightedRandom,
25}
26
27impl Default for ResponseSelectionMode {
28    fn default() -> Self {
29        Self::First
30    }
31}
32
33impl ResponseSelectionMode {
34    /// Parse from string
35    pub fn from_str(s: &str) -> Option<Self> {
36        match s.to_lowercase().as_str() {
37            "first" => Some(Self::First),
38            "scenario" => Some(Self::Scenario),
39            "sequential" | "round-robin" | "round_robin" => Some(Self::Sequential),
40            "random" => Some(Self::Random),
41            "weighted_random" | "weighted-random" | "weighted" => Some(Self::WeightedRandom),
42            _ => None,
43        }
44    }
45}
46
47/// Response selector with state for sequential mode
48#[derive(Debug)]
49pub struct ResponseSelector {
50    /// Selection mode
51    mode: ResponseSelectionMode,
52    /// Counter for sequential mode (per route)
53    sequential_counter: Arc<AtomicUsize>,
54    /// Weights for weighted random mode (optional)
55    weights: Option<HashMap<String, f64>>,
56}
57
58impl ResponseSelector {
59    /// Create a new response selector
60    pub fn new(mode: ResponseSelectionMode) -> Self {
61        Self {
62            mode,
63            sequential_counter: Arc::new(AtomicUsize::new(0)),
64            weights: None,
65        }
66    }
67
68    /// Create a new response selector with weights for weighted random
69    pub fn with_weights(mut self, weights: HashMap<String, f64>) -> Self {
70        self.weights = Some(weights);
71        self
72    }
73
74    /// Select an option from a list of available options
75    ///
76    /// # Arguments
77    /// * `options` - List of option identifiers (e.g., scenario names, example names)
78    ///
79    /// # Returns
80    /// Index into the options list for the selected option
81    pub fn select(&self, options: &[String]) -> usize {
82        if options.is_empty() {
83            return 0;
84        }
85
86        match self.mode {
87            ResponseSelectionMode::First => 0,
88            ResponseSelectionMode::Scenario => {
89                // Scenario mode requires explicit scenario selection
90                // Default to first if no scenario specified
91                0
92            }
93            ResponseSelectionMode::Sequential => {
94                // Round-robin: increment counter and wrap around
95                let current = self.sequential_counter.fetch_add(1, Ordering::Relaxed);
96                current % options.len()
97            }
98            ResponseSelectionMode::Random => {
99                use rand::Rng;
100                let mut rng = rand::thread_rng();
101                rng.gen_range(0..options.len())
102            }
103            ResponseSelectionMode::WeightedRandom => self.select_weighted_random(options),
104        }
105    }
106
107    /// Select using weighted random distribution
108    fn select_weighted_random(&self, options: &[String]) -> usize {
109        use rand::Rng;
110        let mut rng = rand::thread_rng();
111
112        // If weights are provided, use them
113        if let Some(ref weights) = self.weights {
114            let total_weight: f64 =
115                options.iter().map(|opt| weights.get(opt).copied().unwrap_or(1.0)).sum();
116
117            if total_weight > 0.0 {
118                let random = rng.gen::<f64>() * total_weight;
119                let mut cumulative = 0.0;
120
121                for (idx, opt) in options.iter().enumerate() {
122                    cumulative += weights.get(opt).copied().unwrap_or(1.0);
123                    if random <= cumulative {
124                        return idx;
125                    }
126                }
127            }
128        }
129
130        // Fall back to uniform random if no weights or invalid weights
131        rng.gen_range(0..options.len())
132    }
133
134    /// Reset the sequential counter (useful for testing)
135    pub fn reset_sequential(&self) {
136        self.sequential_counter.store(0, Ordering::Relaxed);
137    }
138
139    /// Get the current sequential counter value
140    pub fn get_sequential_index(&self) -> usize {
141        self.sequential_counter.load(Ordering::Relaxed)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_first_mode() {
151        let selector = ResponseSelector::new(ResponseSelectionMode::First);
152        let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
153
154        assert_eq!(selector.select(&options), 0);
155        assert_eq!(selector.select(&options), 0); // Always returns first
156    }
157
158    #[test]
159    fn test_sequential_mode() {
160        let selector = ResponseSelector::new(ResponseSelectionMode::Sequential);
161        let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
162
163        assert_eq!(selector.select(&options), 0);
164        assert_eq!(selector.select(&options), 1);
165        assert_eq!(selector.select(&options), 2);
166        assert_eq!(selector.select(&options), 0); // Wraps around
167        assert_eq!(selector.select(&options), 1);
168    }
169
170    #[test]
171    fn test_random_mode() {
172        let selector = ResponseSelector::new(ResponseSelectionMode::Random);
173        let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
174
175        // Random selection should return valid indices
176        for _ in 0..100 {
177            let idx = selector.select(&options);
178            assert!(idx < options.len());
179        }
180    }
181
182    #[test]
183    fn test_weighted_random_mode() {
184        let mut weights = HashMap::new();
185        weights.insert("a".to_string(), 0.5);
186        weights.insert("b".to_string(), 0.3);
187        weights.insert("c".to_string(), 0.2);
188
189        let selector =
190            ResponseSelector::new(ResponseSelectionMode::WeightedRandom).with_weights(weights);
191        let options = vec!["a".to_string(), "b".to_string(), "c".to_string()];
192
193        // Weighted random should return valid indices
194        for _ in 0..100 {
195            let idx = selector.select(&options);
196            assert!(idx < options.len());
197        }
198    }
199
200    #[test]
201    fn test_mode_from_str() {
202        assert_eq!(ResponseSelectionMode::from_str("first"), Some(ResponseSelectionMode::First));
203        assert_eq!(
204            ResponseSelectionMode::from_str("sequential"),
205            Some(ResponseSelectionMode::Sequential)
206        );
207        assert_eq!(
208            ResponseSelectionMode::from_str("round-robin"),
209            Some(ResponseSelectionMode::Sequential)
210        );
211        assert_eq!(ResponseSelectionMode::from_str("random"), Some(ResponseSelectionMode::Random));
212        assert_eq!(ResponseSelectionMode::from_str("invalid"), None);
213    }
214
215    #[test]
216    fn test_reset_sequential() {
217        let selector = ResponseSelector::new(ResponseSelectionMode::Sequential);
218        let options = vec!["a".to_string(), "b".to_string()];
219
220        assert_eq!(selector.select(&options), 0);
221        assert_eq!(selector.select(&options), 1);
222
223        selector.reset_sequential();
224        assert_eq!(selector.select(&options), 0);
225    }
226}