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 = viaduct::Request::get(viaduct::parse_url(&url)?)
219 .header("User-Agent", USER_AGENT)?;
220 req.send()?.json()?
221 }
222 ExperimentSource::FromFeatureFiles {
223 app,
224 feature_id,
225 files,
226 } => feature_utils::create_experiment(app, feature_id, files)?,
227
228 ExperimentSource::WithPatchFile { patch, inner } => patch_experiment(inner, patch)?,
229
230 #[cfg(test)]
231 ExperimentSource::FromTestFixture { file } => value_utils::read_from_file(file)?,
232 })
233 }
234}
235
236fn patch_experiment(experiment: &ExperimentSource, patch: &PathBuf) -> Result<Value> {
237 let mut value: Value = experiment
238 .try_into()
239 .map_err(|e| anyhow::Error::msg(format!("Problem loading experiment: {e}")))?;
240
241 let patch: FeatureDefaults = read_from_file(patch)
242 .map_err(|e| anyhow::Error::msg(format!("Problem loading patch file: {e}")))?;
243
244 for b in value.get_mut_array("branches")? {
245 for (feature_id, value) in try_find_mut_features_from_branch(b)? {
246 match patch.features.get(&feature_id) {
247 Some(v) => value.patch(v),
248 _ => true,
249 };
250 }
251 }
252 Ok(value)
253}
254
255#[derive(Deserialize, Serialize)]
256struct FeatureDefaults {
257 #[serde(flatten)]
258 features: BTreeMap<String, Value>,
259}
260
261#[cfg(test)]
262mod unit_tests {
263 use super::*;
264 #[test]
265 fn test_experiment_source_from_rs() -> Result<()> {
266 let release = ExperimentListSource::try_from_rs("")?;
267 let stage = ExperimentListSource::try_from_rs("stage")?;
268 let release_preview = ExperimentListSource::try_from_rs("preview")?;
269 let stage_preview = ExperimentListSource::try_from_rs("stage/preview")?;
270 let slug = "my-slug".to_string();
271 assert_eq!(
272 ExperimentSource::try_from_rs("my-slug")?,
273 ExperimentSource::FromList {
274 list: release.clone(),
275 slug: slug.clone()
276 }
277 );
278 assert_eq!(
279 ExperimentSource::try_from_rs("release/my-slug")?,
280 ExperimentSource::FromList {
281 list: release,
282 slug: slug.clone()
283 }
284 );
285 assert_eq!(
286 ExperimentSource::try_from_rs("stage/my-slug")?,
287 ExperimentSource::FromList {
288 list: stage,
289 slug: slug.clone()
290 }
291 );
292 assert_eq!(
293 ExperimentSource::try_from_rs("preview/my-slug")?,
294 ExperimentSource::FromList {
295 list: release_preview.clone(),
296 slug: slug.clone()
297 }
298 );
299 assert_eq!(
300 ExperimentSource::try_from_rs("release/preview/my-slug")?,
301 ExperimentSource::FromList {
302 list: release_preview,
303 slug: slug.clone()
304 }
305 );
306 assert_eq!(
307 ExperimentSource::try_from_rs("stage/preview/my-slug")?,
308 ExperimentSource::FromList {
309 list: stage_preview,
310 slug
311 }
312 );
313
314 assert!(ExperimentSource::try_from_rs("not-real/preview/my-slug").is_err());
315 assert!(ExperimentSource::try_from_rs("release/not-real/my-slug").is_err());
316
317 Ok(())
318 }
319
320 #[test]
321 fn test_experiment_source_from_api() -> Result<()> {
322 let release = config::api_v6_production_server();
323 let stage = config::api_v6_stage_server();
324 let slug = "my-slug".to_string();
325 assert_eq!(
326 ExperimentSource::try_from_api("my-slug")?,
327 ExperimentSource::FromApiV6 {
328 slug: slug.to_string(),
329 endpoint: release.clone()
330 }
331 );
332 assert_eq!(
333 ExperimentSource::try_from_api("release/my-slug")?,
334 ExperimentSource::FromApiV6 {
335 slug: slug.to_string(),
336 endpoint: release.clone()
337 }
338 );
339 assert_eq!(
340 ExperimentSource::try_from_api("stage/my-slug")?,
341 ExperimentSource::FromApiV6 {
342 slug: slug.to_string(),
343 endpoint: stage.clone()
344 }
345 );
346 assert_eq!(
347 ExperimentSource::try_from_api("preview/my-slug")?,
348 ExperimentSource::FromApiV6 {
349 slug: slug.to_string(),
350 endpoint: release.clone()
351 }
352 );
353 assert_eq!(
354 ExperimentSource::try_from_api("release/preview/my-slug")?,
355 ExperimentSource::FromApiV6 {
356 slug: slug.to_string(),
357 endpoint: release
358 }
359 );
360 assert_eq!(
361 ExperimentSource::try_from_api("stage/preview/my-slug")?,
362 ExperimentSource::FromApiV6 {
363 slug,
364 endpoint: stage
365 }
366 );
367
368 Ok(())
369 }
370
371 #[test]
372 fn test_experiment_source_from_url() -> Result<()> {
373 let endpoint = "https://example.com";
374 let slug = "my-slug";
375 assert_eq!(
376 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary")?,
377 ExperimentSource::FromApiV6 {
378 slug: slug.to_string(),
379 endpoint: endpoint.to_string(),
380 }
381 );
382 assert_eq!(
383 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/summary/")?,
384 ExperimentSource::FromApiV6 {
385 slug: slug.to_string(),
386 endpoint: endpoint.to_string(),
387 }
388 );
389 assert_eq!(
390 ExperimentSource::try_from_url("https://example.com/nimbus/my-slug/results#overview")?,
391 ExperimentSource::FromApiV6 {
392 slug: slug.to_string(),
393 endpoint: endpoint.to_string(),
394 }
395 );
396 assert_eq!(
397 ExperimentSource::try_from_url("https://example.com/api/v6/experiments/my-slug/")?,
398 ExperimentSource::FromApiV6 {
399 slug: slug.to_string(),
400 endpoint: endpoint.to_string(),
401 }
402 );
403 let endpoint = "http://localhost:8080";
404 assert_eq!(
405 ExperimentSource::try_from_url("http://localhost:8080/nimbus/my-slug/summary")?,
406 ExperimentSource::FromApiV6 {
407 slug: slug.to_string(),
408 endpoint: endpoint.to_string(),
409 }
410 );
411 assert_eq!(
412 ExperimentSource::try_from_url("http://localhost:8080/api/v6/experiments/my-slug/")?,
413 ExperimentSource::FromApiV6 {
414 slug: slug.to_string(),
415 endpoint: endpoint.to_string(),
416 }
417 );
418
419 Ok(())
420 }
421}