1use crate::{
6 cli::{Cli, CliCommand, ExperimentArgs, ExperimentListArgs, ExperimentListSourceArgs},
7 config,
8 value_utils::{self, CliUtils},
9 USER_AGENT,
10};
11use anyhow::{bail, Result};
12use serde_json::Value;
13use std::path::{Path, PathBuf};
14
15use super::{ExperimentListFilter, ExperimentSource};
16
17#[derive(Clone, Debug, PartialEq)]
18pub(crate) enum ExperimentListSource {
19 Empty,
20 Filtered {
21 filter: ExperimentListFilter,
22 inner: Box<ExperimentListSource>,
23 },
24 FromApiV6 {
25 endpoint: String,
26 },
27 FromFile {
28 file: PathBuf,
29 },
30 FromRemoteSettings {
31 endpoint: String,
32 is_preview: bool,
33 },
34 FromRecipes {
35 recipes: Vec<ExperimentSource>,
36 },
37}
38
39impl ExperimentListSource {
40 fn try_from_slug<'a>(
41 slug: &'a str,
42 production: &'a str,
43 stage: &'a str,
44 ) -> Result<(&'a str, bool)> {
45 let (is_production, is_preview) = decode_list_slug(slug)?;
46
47 let endpoint = if is_production { production } else { stage };
48
49 Ok((endpoint, is_preview))
50 }
51
52 pub(crate) fn try_from_rs(value: &str) -> Result<Self> {
53 let p = config::rs_production_server();
54 let s = config::rs_stage_server();
55 let (endpoint, is_preview) = Self::try_from_slug(value, &p, &s)?;
56 Ok(Self::FromRemoteSettings {
57 endpoint: endpoint.to_string(),
58 is_preview,
59 })
60 }
61
62 pub(crate) fn try_from_api(value: &str) -> Result<Self> {
63 let p = config::api_v6_production_server();
64 let s = config::api_v6_stage_server();
65 let (endpoint, _) = Self::try_from_slug(value, &p, &s)?;
66 Ok(Self::FromApiV6 {
67 endpoint: endpoint.to_string(),
68 })
69 }
70}
71
72pub(crate) fn decode_list_slug(slug: &str) -> Result<(bool, bool)> {
74 let tokens: Vec<&str> = slug.splitn(3, '/').collect();
75
76 Ok(match tokens.as_slice() {
77 [""] => (true, false),
78 ["preview"] => (true, true),
79 [server] => (is_production_server(server)?, false),
80 [server, preview] => (
81 is_production_server(server)?,
82 is_preview_collection(preview)?,
83 ),
84 _ => bail!(format!(
85 "Can't unpack '{slug}' into an experiment; try stage/SLUG, or SLUG"
86 )),
87 })
88}
89
90fn is_production_server(slug: &str) -> Result<bool> {
91 Ok(match slug {
92 "production" | "release" | "prod" | "" => true,
93 "stage" | "staging" => false,
94 _ => bail!(format!(
95 "Cannot translate '{slug}' into production or stage"
96 )),
97 })
98}
99
100fn is_preview_collection(slug: &str) -> Result<bool> {
101 Ok(match slug {
102 "preview" => true,
103 "" => false,
104 _ => bail!(format!(
105 "Cannot translate '{slug}' into preview or release collection"
106 )),
107 })
108}
109
110impl TryFrom<&Cli> for ExperimentListSource {
111 type Error = anyhow::Error;
112
113 fn try_from(value: &Cli) -> Result<Self> {
114 let list = match &value.command {
115 CliCommand::FetchList { list, .. } | CliCommand::List { list } => {
116 ExperimentListSource::try_from(list)?
117 }
118 CliCommand::Fetch {
119 experiment,
120 recipes: slugs,
121 ..
122 } => {
123 let mut recipes = vec![ExperimentSource::try_from(experiment)?];
124
125 for r in slugs {
126 let recipe = ExperimentArgs {
127 experiment: r.clone(),
128 ..experiment.clone()
129 };
130 recipes.push(ExperimentSource::try_from(&recipe)?);
131 }
132 ExperimentListSource::FromRecipes { recipes }
133 }
134 _ => unreachable!(),
135 };
136
137 let app = value.app.clone();
138 Ok(if let Some(app) = app {
139 ExperimentListSource::Filtered {
140 filter: ExperimentListFilter::for_app(app.as_str()),
141 inner: Box::new(list),
142 }
143 } else {
144 list
145 })
146 }
147}
148
149impl TryFrom<&ExperimentListArgs> for ExperimentListSource {
150 type Error = anyhow::Error;
151
152 fn try_from(value: &ExperimentListArgs) -> Result<Self> {
153 let source = match &value.source {
154 ExperimentListSourceArgs {
155 server,
156 file: Some(file),
157 ..
158 } => {
159 if !server.is_empty() {
160 bail!("Cannot load a list from a file AND a server")
161 } else {
162 Self::FromFile { file: file.clone() }
163 }
164 }
165 ExperimentListSourceArgs {
166 server: s,
167 file: None,
168 use_api,
169 } => {
170 if *use_api {
171 Self::try_from_api(s)?
172 } else {
173 Self::try_from_rs(s)?
174 }
175 }
176 };
177 let filter: ExperimentListFilter = From::from(&value.filter);
178 Ok(if !filter.is_empty() {
179 ExperimentListSource::Filtered {
180 filter,
181 inner: Box::new(source),
182 }
183 } else {
184 source
185 })
186 }
187}
188
189impl TryFrom<&Path> for ExperimentListSource {
190 type Error = anyhow::Error;
191
192 fn try_from(value: &Path) -> Result<Self> {
193 Ok(Self::FromFile {
194 file: value.to_path_buf(),
195 })
196 }
197}
198
199impl TryFrom<&ExperimentListSource> for Value {
202 type Error = anyhow::Error;
203
204 fn try_from(value: &ExperimentListSource) -> Result<Value> {
205 Ok(match value {
206 ExperimentListSource::Empty => serde_json::json!({ "data": [] }),
207 ExperimentListSource::Filtered { filter, inner } => filter_list(filter, inner)?,
208 ExperimentListSource::FromRecipes { recipes } => {
209 let mut data: Vec<Value> = Default::default();
210
211 for r in recipes {
212 if let Ok(v) = r.try_into() {
213 data.push(v);
214 }
215 }
216 serde_json::json!({ "data": data })
217 }
218 ExperimentListSource::FromRemoteSettings {
219 endpoint,
220 is_preview,
221 } => {
222 use remote_settings::{RemoteSettings, RemoteSettingsConfig, RemoteSettingsServer};
223 viaduct_reqwest::use_reqwest_backend();
224 let collection_name = if *is_preview {
225 "nimbus-preview".to_string()
226 } else {
227 "nimbus-mobile-experiments".to_string()
228 };
229 let config = RemoteSettingsConfig {
230 server: Some(RemoteSettingsServer::Custom {
231 url: endpoint.clone(),
232 }),
233 server_url: None,
234 bucket_name: None,
235 collection_name,
236 };
237 let client = RemoteSettings::new(config)?;
238
239 let response = client.get_records_raw()?;
240 response.json::<Value>()?
241 }
242 ExperimentListSource::FromFile { file } => {
243 let v: Value = value_utils::read_from_file(file)?;
244 if v.is_array() {
245 serde_json::json!({ "data": v })
246 } else if v.get_array("data").is_ok() {
247 v
248 } else if v.get_array("branches").is_ok() {
249 serde_json::json!({ "data": [v] })
250 } else {
251 bail!(
252 "An unrecognized recipes JSON file: {}",
253 file.as_path().to_str().unwrap_or_default()
254 );
255 }
256 }
257 ExperimentListSource::FromApiV6 { endpoint } => {
258 let url = format!("{endpoint}/api/v6/experiments/");
259
260 let req = reqwest::blocking::Client::builder()
261 .user_agent(USER_AGENT)
262 .gzip(true)
263 .build()?
264 .get(url);
265
266 let resp = req.send()?;
267 let data: Value = resp.json()?;
268
269 fn start_date(v: &Value) -> &str {
270 let later = "9999-99-99";
271 match v.get("startDate") {
272 Some(v) => v.as_str().unwrap_or(later),
273 _ => later,
274 }
275 }
276
277 let data = match data {
278 Value::Array(mut array) => {
279 array.sort_by(|p, q| {
280 let p_time = start_date(p);
281 let q_time = start_date(q);
282 p_time.cmp(q_time)
283 });
284 Value::Array(array)
285 }
286 _ => data,
287 };
288 serde_json::json!({ "data": data })
289 }
290 })
291 }
292}
293
294fn filter_list(filter: &ExperimentListFilter, inner: &ExperimentListSource) -> Result<Value> {
295 let v: Value = Value::try_from(inner)?;
296 let data = v.get_array("data")?;
297 let mut array: Vec<Value> = Default::default();
298 for exp in data {
299 if let Ok(true) = filter.matches(exp) {
300 array.push(exp.to_owned());
301 }
302 }
303
304 Ok(serde_json::json!({ "data": array }))
305}
306
307#[cfg(test)]
308mod unit_tests {
309 use super::*;
310
311 #[test]
312 fn test_experiment_list_from_rs() -> Result<()> {
313 let release = config::rs_production_server();
314 let stage = config::rs_stage_server();
315 assert_eq!(
316 ExperimentListSource::try_from_rs("")?,
317 ExperimentListSource::FromRemoteSettings {
318 endpoint: release.clone(),
319 is_preview: false
320 }
321 );
322 assert_eq!(
323 ExperimentListSource::try_from_rs("preview")?,
324 ExperimentListSource::FromRemoteSettings {
325 endpoint: release.clone(),
326 is_preview: true
327 }
328 );
329 assert_eq!(
330 ExperimentListSource::try_from_rs("release")?,
331 ExperimentListSource::FromRemoteSettings {
332 endpoint: release.clone(),
333 is_preview: false
334 }
335 );
336 assert_eq!(
337 ExperimentListSource::try_from_rs("release/preview")?,
338 ExperimentListSource::FromRemoteSettings {
339 endpoint: release.clone(),
340 is_preview: true
341 }
342 );
343 assert_eq!(
344 ExperimentListSource::try_from_rs("stage")?,
345 ExperimentListSource::FromRemoteSettings {
346 endpoint: stage.clone(),
347 is_preview: false
348 }
349 );
350 assert_eq!(
351 ExperimentListSource::try_from_rs("stage/preview")?,
352 ExperimentListSource::FromRemoteSettings {
353 endpoint: stage,
354 is_preview: true
355 }
356 );
357 assert_eq!(
358 ExperimentListSource::try_from_rs("release/preview")?,
359 ExperimentListSource::FromRemoteSettings {
360 endpoint: release,
361 is_preview: true
362 }
363 );
364
365 assert!(ExperimentListSource::try_from_rs("not-real/preview").is_err());
366 assert!(ExperimentListSource::try_from_rs("release/not-real").is_err());
367
368 Ok(())
369 }
370}