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 let collection_name = if *is_preview {
224 "nimbus-preview".to_string()
225 } else {
226 "nimbus-mobile-experiments".to_string()
227 };
228 let config = RemoteSettingsConfig {
229 server: Some(RemoteSettingsServer::Custom {
230 url: endpoint.clone(),
231 }),
232 server_url: None,
233 bucket_name: None,
234 collection_name,
235 };
236 let client = RemoteSettings::new(config)?;
237
238 let response = client.get_records_raw()?;
239 response.json::<Value>()?
240 }
241 ExperimentListSource::FromFile { file } => {
242 let v: Value = value_utils::read_from_file(file)?;
243 if v.is_array() {
244 serde_json::json!({ "data": v })
245 } else if v.get_array("data").is_ok() {
246 v
247 } else if v.get_array("branches").is_ok() {
248 serde_json::json!({ "data": [v] })
249 } else {
250 bail!(
251 "An unrecognized recipes JSON file: {}",
252 file.as_path().to_str().unwrap_or_default()
253 );
254 }
255 }
256 ExperimentListSource::FromApiV6 { endpoint } => {
257 let url = format!("{endpoint}/api/v6/experiments/");
258
259 let req = viaduct::Request::get(viaduct::parse_url(&url)?)
260 .header("User-Agent", USER_AGENT)?;
261
262 let resp = req.send()?;
263 let data: Value = resp.json()?;
264
265 fn start_date(v: &Value) -> &str {
266 let later = "9999-99-99";
267 match v.get("startDate") {
268 Some(v) => v.as_str().unwrap_or(later),
269 _ => later,
270 }
271 }
272
273 let data = match data {
274 Value::Array(mut array) => {
275 array.sort_by(|p, q| {
276 let p_time = start_date(p);
277 let q_time = start_date(q);
278 p_time.cmp(q_time)
279 });
280 Value::Array(array)
281 }
282 _ => data,
283 };
284 serde_json::json!({ "data": data })
285 }
286 })
287 }
288}
289
290fn filter_list(filter: &ExperimentListFilter, inner: &ExperimentListSource) -> Result<Value> {
291 let v: Value = Value::try_from(inner)?;
292 let data = v.get_array("data")?;
293 let mut array: Vec<Value> = Default::default();
294 for exp in data {
295 if let Ok(true) = filter.matches(exp) {
296 array.push(exp.to_owned());
297 }
298 }
299
300 Ok(serde_json::json!({ "data": array }))
301}
302
303#[cfg(test)]
304mod unit_tests {
305 use super::*;
306
307 #[test]
308 fn test_experiment_list_from_rs() -> Result<()> {
309 let release = config::rs_production_server();
310 let stage = config::rs_stage_server();
311 assert_eq!(
312 ExperimentListSource::try_from_rs("")?,
313 ExperimentListSource::FromRemoteSettings {
314 endpoint: release.clone(),
315 is_preview: false
316 }
317 );
318 assert_eq!(
319 ExperimentListSource::try_from_rs("preview")?,
320 ExperimentListSource::FromRemoteSettings {
321 endpoint: release.clone(),
322 is_preview: true
323 }
324 );
325 assert_eq!(
326 ExperimentListSource::try_from_rs("release")?,
327 ExperimentListSource::FromRemoteSettings {
328 endpoint: release.clone(),
329 is_preview: false
330 }
331 );
332 assert_eq!(
333 ExperimentListSource::try_from_rs("release/preview")?,
334 ExperimentListSource::FromRemoteSettings {
335 endpoint: release.clone(),
336 is_preview: true
337 }
338 );
339 assert_eq!(
340 ExperimentListSource::try_from_rs("stage")?,
341 ExperimentListSource::FromRemoteSettings {
342 endpoint: stage.clone(),
343 is_preview: false
344 }
345 );
346 assert_eq!(
347 ExperimentListSource::try_from_rs("stage/preview")?,
348 ExperimentListSource::FromRemoteSettings {
349 endpoint: stage,
350 is_preview: true
351 }
352 );
353 assert_eq!(
354 ExperimentListSource::try_from_rs("release/preview")?,
355 ExperimentListSource::FromRemoteSettings {
356 endpoint: release,
357 is_preview: true
358 }
359 );
360
361 assert!(ExperimentListSource::try_from_rs("not-real/preview").is_err());
362 assert!(ExperimentListSource::try_from_rs("release/not-real").is_err());
363
364 Ok(())
365 }
366}