1use serde_derive::*;
7use serde_json::Value;
8
9use crate::enrollment::{
10 EnrolledReason, EnrollmentStatus, ExperimentEnrollment, NotEnrolledReason,
11};
12use crate::error::{NimbusError, Result, debug, info};
13use crate::sampling;
14#[cfg(feature = "stateful")]
15pub use crate::stateful::evaluator::*;
16#[cfg(not(feature = "stateful"))]
17pub use crate::stateless::evaluator::*;
18use crate::{AvailableRandomizationUnits, Branch, Experiment, NimbusTargetingHelper};
19
20#[derive(Serialize, Deserialize, Debug, Clone, Default)]
21pub struct Bucket {}
22
23impl Bucket {
24 #[allow(unused)]
25 pub fn new() -> Self {
26 unimplemented!();
27 }
28}
29
30fn prefer_none_to_empty(s: Option<&str>) -> Option<String> {
31 let s = s?;
32 if s.is_empty() {
33 None
34 } else {
35 Some(s.to_string())
36 }
37}
38
39pub fn split_locale(locale: String) -> (Option<String>, Option<String>) {
40 if locale.contains('-') {
41 let mut parts = locale.split('-');
42 (
43 prefer_none_to_empty(parts.next()),
44 prefer_none_to_empty(parts.next()),
45 )
46 } else {
47 (Some(locale), None)
48 }
49}
50
51pub fn evaluate_enrollment(
58 available_randomization_units: &AvailableRandomizationUnits,
59 experiment: &Experiment,
60 targeting_helper: &NimbusTargetingHelper,
61) -> Result<ExperimentEnrollment> {
62 let status = match can_enroll(available_randomization_units, targeting_helper, experiment) {
63 CanEnrollResult::Unavailable { reason } => EnrollmentStatus::NotEnrolled { reason },
64 CanEnrollResult::NotTargeted => EnrollmentStatus::NotEnrolled {
65 reason: NotEnrolledReason::NotTargeted,
66 },
67 CanEnrollResult::NotSelected => EnrollmentStatus::NotEnrolled {
68 reason: NotEnrolledReason::NotSelected,
69 },
70 CanEnrollResult::TargetingError { reason } => EnrollmentStatus::Error { reason },
71 CanEnrollResult::NoRandomizationUnit => {
72 info!(
73 "Could not find a suitable randomization unit for {}. Skipping experiment.",
74 experiment.slug,
75 );
76 EnrollmentStatus::Error {
77 reason: "No randomization unit".into(),
78 }
79 }
80
81 CanEnrollResult::Enrollable { randomization_id } => EnrollmentStatus::new_enrolled(
82 EnrolledReason::Qualified,
83 &choose_branch(&experiment.slug, &experiment.branches, randomization_id)?.slug,
84 ),
85 };
86
87 Ok(ExperimentEnrollment {
88 slug: experiment.slug.clone(),
89 status,
90 })
91}
92
93pub enum CanEnrollResult<'aru> {
95 Enrollable {
97 randomization_id: &'aru str,
99 },
100
101 Unavailable {
103 reason: NotEnrolledReason,
105 },
106
107 TargetingError {
109 reason: String,
111 },
112
113 NotTargeted,
116
117 NotSelected,
120
121 NoRandomizationUnit,
124}
125
126pub fn can_enroll<'aru>(
128 available_randomization_units: &'aru AvailableRandomizationUnits,
129 targeting_helper: &NimbusTargetingHelper,
130 experiment: &Experiment,
131) -> CanEnrollResult<'aru> {
132 if let ExperimentAvailable::Unavailable { reason } =
133 is_experiment_available(targeting_helper, experiment, true)
134 {
135 return CanEnrollResult::Unavailable { reason };
136 }
137
138 if let Some(targeting_expression) = &experiment.targeting {
139 match targeting_helper.eval_jexl(targeting_expression) {
140 Err(e) => {
141 return CanEnrollResult::TargetingError {
142 reason: e.to_string(),
143 };
144 }
145 Ok(false) => return CanEnrollResult::NotTargeted,
146 Ok(true) => {}
147 };
148 }
149
150 let Some(randomization_id) =
151 available_randomization_units.get_value(&experiment.bucket_config.randomization_unit)
152 else {
153 return CanEnrollResult::NoRandomizationUnit;
154 };
155
156 let Ok(is_sampled) = sampling::bucket_sample(
157 [randomization_id, &experiment.bucket_config.namespace],
158 experiment.bucket_config.start,
159 experiment.bucket_config.count,
160 experiment.bucket_config.total,
161 ) else {
162 return CanEnrollResult::NoRandomizationUnit;
163 };
164
165 if is_sampled {
166 CanEnrollResult::Enrollable { randomization_id }
167 } else {
168 CanEnrollResult::NotSelected
169 }
170}
171
172#[derive(Debug, Eq, PartialEq)]
174pub enum ExperimentAvailable {
175 Available,
177
178 Unavailable { reason: NotEnrolledReason },
181}
182
183pub fn is_experiment_available(
195 th: &NimbusTargetingHelper,
196 exp: &Experiment,
197 is_release: bool,
198) -> ExperimentAvailable {
199 match (&exp.app_name, th.context.get("app_name".to_string())) {
202 (Some(exp), Some(Value::String(mine))) => {
203 if !exp.eq(mine) {
204 return ExperimentAvailable::Unavailable {
205 reason: NotEnrolledReason::DifferentAppName,
206 };
207 }
208 }
209 (_, _) => debug!("Experiment missing app_name, skipping it as a targeting parameter"),
210 }
211
212 if !is_release {
213 return ExperimentAvailable::Available;
214 }
215
216 match (&exp.channel, th.context.get("channel".to_string())) {
220 (Some(exp), Some(Value::String(mine))) => {
221 if !exp.to_lowercase().eq(&mine.to_lowercase()) {
222 return ExperimentAvailable::Unavailable {
223 reason: NotEnrolledReason::DifferentChannel,
224 };
225 }
226 }
227 (_, _) => debug!("Experiment missing channel, skipping it as a targeting parameter"),
228 }
229
230 ExperimentAvailable::Available
231}
232
233pub(crate) fn choose_branch<'a>(
255 slug: &str,
256 branches: &'a [Branch],
257 id: &str,
258) -> Result<&'a Branch> {
259 let ratios = branches.iter().map(|b| b.ratio as u32).collect::<Vec<_>>();
261 let input = format!("{:}-{:}-{:}-branch", "experimentmanager", id, slug);
265 let index = sampling::ratio_sample(input, &ratios)?;
266 branches.get(index).ok_or(NimbusError::OutOfBoundsError)
267}
268
269#[cfg(test)]
270mod unit_tests {
271 use super::*;
272
273 #[test]
274 fn test_splitting_locale() -> Result<()> {
275 assert_eq!(
276 split_locale("en-US".to_string()),
277 (Some("en".to_string()), Some("US".to_string()))
278 );
279 assert_eq!(
280 split_locale("es".to_string()),
281 (Some("es".to_string()), None)
282 );
283
284 assert_eq!(
285 split_locale("-unknown".to_string()),
286 (None, Some("unknown".to_string()))
287 );
288 Ok(())
289 }
290}