mockforge_core/ab_testing/
manager.rs

1//! Variant manager for A/B testing
2//!
3//! This module provides functionality for managing mock variants and A/B test configurations.
4
5use crate::ab_testing::types::{ABTestConfig, MockVariant, VariantAnalytics};
6use crate::error::{Error, Result};
7use std::collections::HashMap;
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use tracing::{debug, info, warn};
11
12/// Manages A/B test configurations and variants
13#[derive(Debug, Clone)]
14pub struct VariantManager {
15    /// A/B test configurations indexed by endpoint key: "{method} {path}"
16    tests: Arc<RwLock<HashMap<String, ABTestConfig>>>,
17    /// Analytics data for variants indexed by endpoint key and variant ID
18    analytics: Arc<RwLock<HashMap<String, HashMap<String, VariantAnalytics>>>>,
19    /// Round-robin counters for round-robin strategy
20    round_robin_counters: Arc<RwLock<HashMap<String, usize>>>,
21}
22
23impl VariantManager {
24    /// Create a new variant manager
25    pub fn new() -> Self {
26        Self {
27            tests: Arc::new(RwLock::new(HashMap::new())),
28            analytics: Arc::new(RwLock::new(HashMap::new())),
29            round_robin_counters: Arc::new(RwLock::new(HashMap::new())),
30        }
31    }
32
33    /// Register an A/B test configuration
34    pub async fn register_test(&self, config: ABTestConfig) -> Result<()> {
35        // Validate allocations
36        config.validate_allocations().map_err(|e| Error::validation(e))?;
37
38        let key = Self::endpoint_key(&config.method, &config.endpoint_path);
39        let mut tests = self.tests.write().await;
40        tests.insert(key.clone(), config.clone());
41
42        // Initialize analytics for all variants
43        let mut analytics = self.analytics.write().await;
44        let variant_analytics = analytics.entry(key).or_insert_with(HashMap::new);
45        for variant in &config.variants {
46            variant_analytics.insert(
47                variant.variant_id.clone(),
48                VariantAnalytics::new(variant.variant_id.clone()),
49            );
50        }
51
52        info!(
53            "Registered A/B test '{}' for {} {} with {} variants",
54            config.test_name,
55            config.method,
56            config.endpoint_path,
57            config.variants.len()
58        );
59
60        Ok(())
61    }
62
63    /// Get A/B test configuration for an endpoint
64    pub async fn get_test(&self, method: &str, path: &str) -> Option<ABTestConfig> {
65        let key = Self::endpoint_key(method, path);
66        let tests = self.tests.read().await;
67        tests.get(&key).cloned()
68    }
69
70    /// List all registered A/B tests
71    pub async fn list_tests(&self) -> Vec<ABTestConfig> {
72        let tests = self.tests.read().await;
73        tests.values().cloned().collect()
74    }
75
76    /// Remove an A/B test configuration
77    pub async fn remove_test(&self, method: &str, path: &str) -> Result<()> {
78        let key = Self::endpoint_key(method, path);
79        let mut tests = self.tests.write().await;
80        tests.remove(&key);
81
82        // Optionally clear analytics (or keep for historical data)
83        // For now, we'll keep analytics even after test removal
84
85        info!("Removed A/B test for {} {}", method, path);
86        Ok(())
87    }
88
89    /// Get a variant by ID for an endpoint
90    pub async fn get_variant(
91        &self,
92        method: &str,
93        path: &str,
94        variant_id: &str,
95    ) -> Option<MockVariant> {
96        if let Some(config) = self.get_test(method, path).await {
97            config.variants.iter().find(|v| v.variant_id == variant_id).cloned()
98        } else {
99            None
100        }
101    }
102
103    /// Record analytics for a variant request
104    pub async fn record_request(
105        &self,
106        method: &str,
107        path: &str,
108        variant_id: &str,
109        status_code: u16,
110        response_time_ms: f64,
111    ) {
112        let key = Self::endpoint_key(method, path);
113        let mut analytics = self.analytics.write().await;
114        if let Some(variant_analytics) = analytics.get_mut(&key) {
115            if let Some(analytics_data) = variant_analytics.get_mut(variant_id) {
116                analytics_data.record_request(status_code, response_time_ms);
117            } else {
118                // Initialize analytics if not present
119                let mut new_analytics = VariantAnalytics::new(variant_id.to_string());
120                new_analytics.record_request(status_code, response_time_ms);
121                variant_analytics.insert(variant_id.to_string(), new_analytics);
122            }
123        }
124    }
125
126    /// Get analytics for a variant
127    pub async fn get_variant_analytics(
128        &self,
129        method: &str,
130        path: &str,
131        variant_id: &str,
132    ) -> Option<VariantAnalytics> {
133        let key = Self::endpoint_key(method, path);
134        let analytics = self.analytics.read().await;
135        analytics.get(&key)?.get(variant_id).cloned()
136    }
137
138    /// Get all analytics for an endpoint
139    pub async fn get_endpoint_analytics(
140        &self,
141        method: &str,
142        path: &str,
143    ) -> HashMap<String, VariantAnalytics> {
144        let key = Self::endpoint_key(method, path);
145        let analytics = self.analytics.read().await;
146        analytics.get(&key).cloned().unwrap_or_default()
147    }
148
149    /// Get round-robin counter for an endpoint
150    pub async fn get_round_robin_index(&self, method: &str, path: &str) -> usize {
151        let key = Self::endpoint_key(method, path);
152        let mut counters = self.round_robin_counters.write().await;
153        let counter = counters.entry(key).or_insert(0);
154        *counter
155    }
156
157    /// Increment round-robin counter for an endpoint
158    pub async fn increment_round_robin(&self, method: &str, path: &str, max: usize) -> usize {
159        let key = Self::endpoint_key(method, path);
160        let mut counters = self.round_robin_counters.write().await;
161        let counter = counters.entry(key).or_insert(0);
162        let current = *counter;
163        *counter = (*counter + 1) % max;
164        current
165    }
166
167    /// Generate a consistent hash for a request attribute
168    ///
169    /// This is used for consistent hashing strategy to ensure the same
170    /// request attribute (e.g., user ID) always gets the same variant.
171    pub fn consistent_hash(attribute: &str, num_variants: usize) -> usize {
172        use std::hash::{Hash, Hasher};
173        let mut hasher = std::collections::hash_map::DefaultHasher::new();
174        attribute.hash(&mut hasher);
175        (hasher.finish() as usize) % num_variants
176    }
177
178    /// Generate endpoint key from method and path
179    fn endpoint_key(method: &str, path: &str) -> String {
180        format!("{} {}", method.to_uppercase(), path)
181    }
182}
183
184impl Default for VariantManager {
185    fn default() -> Self {
186        Self::new()
187    }
188}