Skip to main content

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