1use anyhow::{bail, Result};
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::BTreeMap;
9use std::{
10 fmt::Display,
11 path::{Path, PathBuf},
12};
13
14use crate::value_utils::{read_from_file, try_find_mut_features_from_branch, CliUtils, Patch};
15use crate::{
16 cli::{Cli, CliCommand, ExperimentArgs},
17 config, feature_utils,
18 sources::ExperimentListSource,
19 value_utils, NimbusApp, USER_AGENT,
20};
21
22use super::experiment_list::decode_list_slug;
23
24#[derive(Clone, Debug, PartialEq)]
25pub(crate) enum ExperimentSource {
26 FromList {
27 slug: String,
28 list: ExperimentListSource,
29 },
30 FromFeatureFiles {
31 app: NimbusApp,
32 feature_id: String,
33 files: Vec<PathBuf>,
34 },
35 FromApiV6 {
36 slug: String,
37 endpoint: String,
38 },
39 WithPatchFile {
40 patch: PathBuf,
41 inner: Box<ExperimentSource>,
42 },
43 #[cfg(test)]
44 FromTestFixture {
45 file: PathBuf,
46 },
47}
48
49impl ExperimentSource {
52 fn try_from_slug<'a>(
53 value: &'a str,
54 production: &'a str,
55 stage: &'a str,
56 ) -> Result<(&'a str, &'a str, bool)> {
57 let tokens: Vec<&str> = value.splitn(3, '/').collect();
58
59 let (is_production, is_preview) = match tokens.as_slice() {
60 [_] => decode_list_slug("")?,
61 [first, _] => decode_list_slug(first)?,
62 [first, second, _] => decode_list_slug(&format!("{first}/{second}"))?,
63 _ => unreachable!(),
64 };
65
66 let endpoint = if is_production { production } else { stage };
67
68 Ok(match tokens.last() {
69 Some(slug) => (slug, endpoint, is_preview),
70 _ => bail!(format!(
71 "Can't unpack '{value}' into an experiment; try stage/SLUG, or SLUG"
72 )),
73 })
74 }
75
76 fn try_from_rs(value: &str) -> Result<Self> {
77 let p = config::rs_production_server();
78 let s = config::rs_stage_server();
79 let (slug, endpoint, is_preview) = Self::try_from_slug(value, &p, &s)?;
80 Ok(Self::FromList {
81 slug: slug.to_string(),
82 list: ExperimentListSource::FromRemoteSettings {
83 endpoint: endpoint.to_string(),
84 is_preview,
85 },
86 })
87 }
88
89 fn try_from_url(value: &str) -> Result<Self> {
90 if !value.contains("://") {
91 anyhow::bail!("A URL must start with https://, '{value}' does not");
92 }
93 let value = value.replacen("://", "/", 1);
94
95 let parts: Vec<&str> = value.split('/').collect();
96
97 Ok(match parts.as_slice() {
98 [scheme, endpoint, "nimbus", slug]
99 | [scheme, endpoint, "nimbus", slug, _]
100 | [scheme, endpoint, "nimbus", slug, _, ""]
101 | [scheme, endpoint, "api", "v6", "experiments", slug, ""] => Self::FromApiV6 {
102 slug: slug.to_string(),
103 endpoint: format!("{scheme}://{endpoint}"),
104 },
105 _ => anyhow::bail!("Unrecognized URL from which to to get an experiment"),
106 })
107 }
108
109 fn try_from_api(value: &str) -> Result<Self> {
110 let p = config::api_v6_production_server();
111 let s = config::api_v6_stage_server();
112 let (slug, endpoint, _) = Self::try_from_slug(value, &p, &s)?;
113 Ok(Self::FromApiV6 {
114 slug: slug.to_string(),
115 endpoint: endpoint.to_string(),
116 })
117 }
118
119 pub(crate) fn try_from_file(file: &Path, slug: &str) -> Result<Self> {
120 Ok(ExperimentSource::FromList {
121 slug: slug.to_string(),
122 list: file.try_into()?,
123 })
124 }
125
126 #[cfg(test)]
127 pub(crate) fn from_fixture(filename: &str) -> Self {
128 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
129 let file = dir.join("test/fixtures").join(filename);
130 Self::FromTestFixture { file }
131 }
132}
133
134impl TryFrom<&ExperimentArgs> for ExperimentSource {
135 type Error = anyhow::Error;
136
137 fn try_from(value: &ExperimentArgs) -> Result<Self> {
138 let experiment = &value.experiment;
139 let is_urlish = experiment.contains("://");
140 let experiment = match &value.file {
141 Some(_) if is_urlish => {
142 anyhow::bail!("Cannot load an experiment from a file and a URL at the same time")
143 }
144 None if is_urlish => Self::try_from_url(experiment.as_str())?,
145 Some(file) => Self::try_from_file(file, experiment)?,
146 _ if value.use_rs => Self::try_from_rs(experiment)?,
147 _ => Self::try_from_api(experiment.as_str())?,
148 };
149 Ok(match &value.patch {
150 Some(file) => Self::WithPatchFile {
151 patch: file.clone(),
152 inner: Box::new(experiment),
153 },
154 _ => experiment,
155 })
156 }
157}
158
159impl TryFrom<&Cli> for ExperimentSource {
160 type Error = anyhow::Error;
161
162 fn try_from(value: &Cli) -> Result<Self> {
163 Ok(match &value.command {
164 CliCommand::Validate { experiment, .. }
165 | CliCommand::Enroll { experiment, .. }
166 | CliCommand::Features { experiment, .. } => experiment.try_into()?,
167 CliCommand::TestFeature {
168 feature_id,
169 files,
170 patch,
171 ..
172 } => {
173 let experiment = Self::FromFeatureFiles {
174 app: value.into(),
175 feature_id: feature_id.clone(),
176 files: files.clone(),
177 };
178 match patch {
179 Some(f) => Self::WithPatchFile {
180 patch: f.clone(),
181 inner: Box::new(experiment),
182 },
183 _ => experiment,
184 }
185 }
186 _ => unreachable!("Cli Arg not supporting getting an experiment source"),
187 })
188 }
189}
190
191impl Display for ExperimentSource {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 Self::FromList { slug, .. } | Self::FromApiV6 { slug, .. } => f.write_str(slug),
197 Self::FromFeatureFiles { feature_id, .. } => {
198 f.write_str(&format!("{feature_id}-experiment"))
199 }
200 Self::WithPatchFile { inner, .. } => f.write_str(&format!("{inner} (patched)")),
201 #[cfg(test)]
202 Self::FromTestFixture { file } => f.write_str(&format!("{file:?}")),
203 }
204 }
205}
206
207impl TryFrom<&ExperimentSource> for Value {
208 type Error = anyhow::Error;
209
210 fn try_from(value: &ExperimentSource) -> Result<Value> {
211 Ok(match value {
212 ExperimentSource::FromList { slug, list } => {
213 let value = Value::try_from(list)?;
214 value_utils::try_find_experiment(&value, slug)?
215 }
216 ExperimentSource::FromApiV6 { slug, endpoint } => {
217 let url = format!("{endpoint}/api/v6/experiments/{slug}/");
218 let req = reqwest::blocking::Client::builder()
219 .user_agent(USER_AGENT)
220 .gzip(true)
221 .build()?
222 .get(url);
223
224 req.send()?.json()?
225 }
226 ExperimentSource::FromFeatureFiles {
227 app,
228 feature_id,
229 files,
230 } => feature_utils::create_experiment(app, feature_id, files)?,
231
232 ExperimentSource::WithPatchFile { patch, inner } => patch_experiment(inner, patch)?,
233
234 #[cfg(test)]
235 ExperimentSource::FromTestFixture { file } => value_utils::read_from_file(file)?,
236 })
237 }
238}
239
240fn patch_experiment(experiment: &ExperimentSource, patch: &PathBuf) -> Result<Value> {
241 let mut value: Value = experiment
242 .try_into()
243 .map_err(|e| anyhow::Error::msg(format!("Problem loading experiment: {e}")))?;
244
245 let patch: FeatureDefaults = read_from_file(patch)
246 .map_err(|e| anyhow::Error::msg(format!("Problem loading patch file: {e}")))?;
247
248 for b in value.get_mut_array("branches")? {
249 for (feature_id, value) in try_find_mut_features_from_branch(b)? {
250 match patch.features.get(&feature_id) {
251 Some(v) => value.patch(v),
252 _ => true,
253 };
254 }
255 }
256 Ok(value)
257}
258
259#[derive(Deserialize, Serialize)]
260struct FeatureDefaults {
261 #[serde(flatten)]
262 features: BTreeMap<String, Value>,
263}
264
265#[cfg(test)]
266mod unit_tests {
267 use super::*;
268 #[test]
269 fn test_experiment_source_from_rs() -> Result<()> {
270 let release = ExperimentListSource::try_from_rs("")?;
271 let stage = ExperimentListSource::try_from_rs("stage")?;
272 let release_preview = ExperimentListSource::try_from_rs("preview")?;
273 let stage_preview = ExperimentListSource::try_from_rs("stage/preview")?;
274 let slug = "my-slug".to_string();
275 assert_eq!(
276 ExperimentSource::try_from_rs("my-slug")?,
277 ExperimentSource::FromList {
278 list: release.clone(),
279 slug: slug.clone()
280 }
281 );
282 assert_eq!(
283 ExperimentSource::try_from_rs("release/my-slug")?,
284 ExperimentSource::FromList {
285 list: release,
286 slug: slug.clone()
287 }
288 );
289 assert_eq!(
290 ExperimentSource::try_from_rs("stage/my-slug")?,
291 ExperimentSource::FromList {
292 list: stage,
293 slug: slug.clone()
294 }
295 );
296 assert_eq!(
297 ExperimentSource::try_from_rs("preview/my-slug")?,
298 ExperimentSource::FromList {
299 list: release_preview.clone(),
300 slug: slug.clone()
301 }
302 );
303 assert_eq!(
304 ExperimentSource::try_from_rs("release/preview/my-slug")?,
305 ExperimentSource::FromList {
306 list: release_preview,
307 slug: slug.clone()
308 }
309 );
310 assert_eq!(
311 ExperimentSource::try_from_rs("stage/preview/my-slug")?,
312 ExperimentSource::FromList {
313 list: stage_preview,
314 slug
315 }
316 );
317
318 assert!(ExperimentSource::try_from_rs("not-real/preview/my-slug").is_err());
319 assert!(ExperimentSource::try_from_rs("release/not-real/my-slug").is_err());
320
321 Ok(())
322 }
323
324 #[test]
325 fn test_experiment_source_from_api() -> Result<()> {
326 let release = config::api_v6_production_server();
327 let stage = config::api_v6_stage_server();
328 let slug = "my-slug".to_string();
329 assert_eq!(
330 ExperimentSource::try_from_api("my-slug")?,
331 ExperimentSource::FromApiV6 {
332 slug: slug.to_string(),
333 endpoint: release.clone()
334 }
335 );
336 assert_eq!(
337 ExperimentSource::try_from_api("release/my-slug")?,
338 ExperimentSource::FromApiV6 {
339 slug: slug.to_string(),
340 endpoint: release.clone()
341 }
342 );
343 assert_eq!(
344 ExperimentSource::try_from_api("stage/my-slug")?,
345 ExperimentSource::FromApiV6 {
346 slug: slug.to_string(),
347 endpoint: stage.clone()
348 }
349 );
350 assert_eq!(
351 ExperimentSource::try_from_api("preview/my-slug")?,
352 ExperimentSource::FromApiV6 {
353 slug: slug.to_string(),
354 endpoint: release.clone()
355 }
356 );
357 assert_eq!(
358 ExperimentSource::try_from_api("release/preview/my-slug")?,
359 ExperimentSource::FromApiV6 {
360 slug: slug.to_string(),
361 endpoint: release
362 }
363 );
364 assert_eq!(
365 ExperimentSource::try_from_api("stage/preview/my-slug")?,
366 ExperimentSource::FromApiV6 {
367 slug,
368 endpoint: stage
369 }
370 );
371
372 Ok(())
373 }
374
375 #[test]
376 fn test_experiment_source_from_url() -> Result<()> {
377 let endpoint = "https://example.com";
378 let slug = "my-slug";
379 assert_eq!(
380 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary")?,
381 ExperimentSource::FromApiV6 {
382 slug: slug.to_string(),
383 endpoint: endpoint.to_string(),
384 }
385 );
386 assert_eq!(
387 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary/")?,
388 ExperimentSource::FromApiV6 {
389 slug: slug.to_string(),
390 endpoint: endpoint.to_string(),
391 }
392 );
393 assert_eq!(
394 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/results#overview")?,
395 ExperimentSource::FromApiV6 {
396 slug: slug.to_string(),
397 endpoint: endpoint.to_string(),
398 }
399 );
400 assert_eq!(
401 ExperimentSource::try_from_url("https://example.com/api/v6/experiments/my-slug/")?,
402 ExperimentSource::FromApiV6 {
403 slug: slug.to_string(),
404 endpoint: endpoint.to_string(),
405 }
406 );
407 let endpoint = "http://localhost:8080";
408 assert_eq!(
409 ExperimentSource::try_from_url("http://localhost:8080/nimbus/my-slug/summary")?,
410 ExperimentSource::FromApiV6 {
411 slug: slug.to_string(),
412 endpoint: endpoint.to_string(),
413 }
414 );
415 assert_eq!(
416 ExperimentSource::try_from_url("http://localhost:8080/api/v6/experiments/my-slug/")?,
417 ExperimentSource::FromApiV6 {
418 slug: slug.to_string(),
419 endpoint: endpoint.to_string(),
420 }
421 );
422
423 Ok(())
424 }
425}