1use std::collections::{BTreeSet, HashMap};
6
7use serde_derive::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9use uuid::Uuid;
10
11use crate::defaults::Defaults;
12use crate::enrollment::ExperimentMetadata;
13use crate::error::{trace, warn};
14#[cfg(feature = "stateful")]
15use crate::stateful::firefox_labs::{FIREFOX_LABS_FEEDBACK_URL_KEY, FirefoxLabsMetadata};
16use crate::{NimbusError, Result};
17
18const DEFAULT_TOTAL_BUCKETS: u32 = 10000;
19
20#[derive(Debug, Clone)]
21#[cfg_attr(test, derive(Eq, PartialEq))]
22pub struct EnrolledExperiment {
23 pub feature_ids: Vec<String>,
24 pub slug: String,
25 pub user_facing_name: String,
26 pub user_facing_description: String,
27 pub branch_slug: String,
28 pub is_rollout: bool,
29}
30
31#[cfg_attr(test, derive(Debug, Eq, PartialEq))]
32pub struct EnrollmentSlugs {
33 pub slug: String,
34 pub branch_slug: String,
35}
36
37#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
40#[serde(rename_all = "camelCase")]
41pub struct Experiment {
42 pub schema_version: String,
43 pub slug: String,
44 pub app_name: Option<String>,
45 pub app_id: Option<String>,
46 pub channel: Option<String>,
47 pub user_facing_name: String,
48 pub user_facing_description: String,
49 pub is_enrollment_paused: bool,
50 pub bucket_config: BucketConfig,
51 pub branches: Vec<Branch>,
52 #[serde(default)]
55 pub feature_ids: Vec<String>,
56 pub targeting: Option<String>,
57 pub start_date: Option<String>, pub end_date: Option<String>, pub proposed_duration: Option<u32>,
60 pub proposed_enrollment: u32,
61 pub reference_branch: Option<String>,
62 #[serde(default)]
63 pub is_rollout: bool,
64 pub published_date: Option<chrono::DateTime<chrono::Utc>>,
65 #[serde(default)]
68 pub is_firefox_labs_opt_in: bool,
69
70 #[serde(default)]
71 pub firefox_labs_title: Option<String>,
72
73 #[serde(default)]
74 pub firefox_labs_description: Option<String>,
75
76 #[serde(default)]
77 pub firefox_labs_description_links: Option<HashMap<String, String>>,
78
79 #[serde(default)]
80 pub requires_restart: bool,
81}
82
83#[cfg_attr(not(feature = "stateful"), allow(unused))]
84impl Experiment {
85 pub(crate) fn has_branch(&self, branch_slug: &str) -> bool {
86 self.branches
87 .iter()
88 .any(|branch| branch.slug == branch_slug)
89 }
90
91 pub(crate) fn get_branch(&self, branch_slug: &str) -> Option<&Branch> {
92 self.branches.iter().find(|b| b.slug == branch_slug)
93 }
94
95 pub(crate) fn get_feature_ids(&self) -> Vec<String> {
96 let branches = &self.branches;
97 let feature_ids = branches
98 .iter()
99 .flat_map(|b| {
100 b.get_feature_configs()
101 .iter()
102 .map(|f| f.feature_id.clone())
103 .collect::<Vec<_>>()
104 })
105 .collect::<BTreeSet<_>>();
106
107 feature_ids.into_iter().collect()
110 }
111
112 #[cfg(test)]
113 pub(crate) fn patch(&self, patch: Value) -> Self {
114 let mut experiment = serde_json::to_value(self).unwrap();
115 if let (Some(e), Some(w)) = (experiment.as_object(), patch.as_object()) {
116 let mut e = e.clone();
117 for (key, value) in w {
118 e.insert(key.clone(), value.clone());
119 }
120 experiment = serde_json::to_value(e).unwrap();
121 }
122 serde_json::from_value(experiment).unwrap()
123 }
124
125 #[cfg(feature = "stateful")]
126 pub(crate) fn get_firefox_labs_metadata(&self, enrolled: bool) -> Option<FirefoxLabsMetadata> {
127 if self.is_firefox_labs_opt_in
131 && self.is_rollout
132 && self.branches.len() == 1
133 && let Some(title) = self.firefox_labs_title.as_deref()
134 && let Some(description) = self.firefox_labs_description.as_deref()
135 {
136 let feedback_url = self
137 .firefox_labs_description_links
138 .as_ref()
139 .and_then(|links| links.get(FIREFOX_LABS_FEEDBACK_URL_KEY).cloned());
140
141 Some(FirefoxLabsMetadata {
142 slug: self.slug.clone(),
143 title_string_id: title.into(),
144 description_string_id: description.into(),
145 feedback_url,
146 enrolled,
147 requires_restart: self.requires_restart,
148 })
149 } else {
150 None
151 }
152 }
153
154 #[cfg(feature = "stateful")]
155 pub(crate) fn is_valid_firefox_lab(&self) -> bool {
156 self.is_firefox_labs_opt_in
157 && self.is_rollout
158 && self.branches.len() == 1
159 && self.firefox_labs_title.is_some()
160 && self.firefox_labs_description.is_some()
161 }
162}
163
164impl ExperimentMetadata for Experiment {
165 fn get_slug(&self) -> String {
166 self.slug.clone()
167 }
168
169 fn is_rollout(&self) -> bool {
170 self.is_rollout
171 }
172}
173
174pub fn parse_experiments(payload: &str) -> Result<Vec<Experiment>> {
175 let value: Value = match serde_json::from_str(payload) {
179 Ok(v) => v,
180 Err(e) => {
181 return Err(NimbusError::JSONError(
182 "value = nimbus::schema::parse_experiments::serde_json::from_str".into(),
183 e.to_string(),
184 ));
185 }
186 };
187 let data = value
188 .get("data")
189 .ok_or(NimbusError::InvalidExperimentFormat)?;
190 let mut res = Vec::new();
191 for exp in data
192 .as_array()
193 .ok_or(NimbusError::InvalidExperimentFormat)?
194 {
195 match serde_json::from_value::<Experiment>(exp.clone()) {
199 Ok(exp) => res.push(exp),
200 Err(e) => {
201 trace!("Malformed experiment data: {:#?}", exp);
202 warn!(
203 "Malformed experiment found! Experiment {}, Error: {}",
204 exp.get("id").unwrap_or(&serde_json::json!("ID_NOT_FOUND")),
205 e
206 );
207 }
208 }
209 }
210 Ok(res)
211}
212
213#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
214#[serde(rename_all = "camelCase")]
215pub struct FeatureConfig {
216 pub feature_id: String,
217 #[serde(default)]
221 pub value: Map<String, Value>,
222}
223
224impl Defaults for FeatureConfig {
225 fn defaults(&self, fallback: &Self) -> Result<Self> {
226 if self.feature_id != fallback.feature_id {
227 Err(NimbusError::InternalError(
229 "Cannot merge feature configs from different features",
230 ))
231 } else {
232 Ok(FeatureConfig {
233 feature_id: self.feature_id.clone(),
234 value: self.value.defaults(&fallback.value)?,
235 })
236 }
237 }
238}
239
240#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
243pub struct Branch {
244 pub slug: String,
245 pub ratio: i32,
246 #[serde(skip_serializing_if = "Option::is_none")]
251 pub feature: Option<FeatureConfig>,
252 #[serde(skip_serializing_if = "Option::is_none")]
253 pub features: Option<Vec<FeatureConfig>>,
254}
255
256impl Branch {
257 pub(crate) fn get_feature_configs(&self) -> Vec<FeatureConfig> {
258 match (&self.features, &self.feature) {
261 (Some(features), _) => features.clone(),
262 (None, Some(feature)) => vec![feature.clone()],
263 _ => Default::default(),
264 }
265 }
266
267 #[cfg(feature = "stateful")]
268 pub(crate) fn get_feature_props_and_values(&self) -> Vec<(String, String, Value)> {
269 self.get_feature_configs()
270 .iter()
271 .flat_map(|fc| {
272 fc.value
273 .iter()
274 .map(|(k, v)| (fc.feature_id.clone(), k.clone(), v.clone()))
275 })
276 .collect()
277 }
278}
279
280fn default_buckets() -> u32 {
281 DEFAULT_TOTAL_BUCKETS
282}
283
284#[derive(Deserialize, Serialize, Debug, Default, Clone, PartialEq, Eq)]
287#[serde(rename_all = "camelCase")]
288pub struct BucketConfig {
289 pub randomization_unit: RandomizationUnit,
290 pub namespace: String,
291 pub start: u32,
292 pub count: u32,
293 #[serde(default = "default_buckets")]
294 pub total: u32,
295}
296
297#[allow(unused)]
298#[cfg(test)]
299impl BucketConfig {
300 pub(crate) fn always() -> Self {
301 Self {
302 start: 0,
303 count: default_buckets(),
304 total: default_buckets(),
305 ..Default::default()
306 }
307 }
308}
309
310pub struct AvailableExperiment {
312 pub slug: String,
313 pub user_facing_name: String,
314 pub user_facing_description: String,
315 pub branches: Vec<ExperimentBranch>,
316 pub reference_branch: Option<String>,
317}
318
319pub struct ExperimentBranch {
320 pub slug: String,
321 pub ratio: i32,
322}
323
324impl From<Experiment> for AvailableExperiment {
325 fn from(exp: Experiment) -> Self {
326 Self {
327 slug: exp.slug,
328 user_facing_name: exp.user_facing_name,
329 user_facing_description: exp.user_facing_description,
330 branches: exp.branches.into_iter().map(|b| b.into()).collect(),
331 reference_branch: exp.reference_branch,
332 }
333 }
334}
335
336impl From<Branch> for ExperimentBranch {
337 fn from(branch: Branch) -> Self {
338 Self {
339 slug: branch.slug,
340 ratio: branch.ratio,
341 }
342 }
343}
344
345#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
348#[serde(rename_all = "snake_case")]
349#[derive(Default)]
350pub enum RandomizationUnit {
351 #[default]
352 NimbusId,
353 UserId,
354}
355
356#[derive(Clone, Default)]
357pub struct AvailableRandomizationUnits {
358 pub user_id: Option<String>,
359 pub nimbus_id: Option<String>,
360}
361
362impl AvailableRandomizationUnits {
363 pub fn with_user_id(user_id: &str) -> Self {
366 Self {
367 user_id: Some(user_id.to_string()),
368 nimbus_id: None,
369 }
370 }
371
372 pub fn with_nimbus_id(nimbus_id: &Uuid) -> Self {
373 Self {
374 user_id: None,
375 nimbus_id: Some(nimbus_id.to_string()),
376 }
377 }
378
379 pub fn apply_nimbus_id(&self, nimbus_id: &Uuid) -> Self {
380 Self {
381 user_id: self.user_id.clone(),
382 nimbus_id: Some(nimbus_id.to_string()),
383 }
384 }
385
386 pub fn get_value(&self, wanted: &RandomizationUnit) -> Option<&str> {
387 match wanted {
388 RandomizationUnit::NimbusId => self.nimbus_id.as_deref(),
389 RandomizationUnit::UserId => self.user_id.as_deref(),
390 }
391 }
392}