1use std::fmt::Display;
6use std::io::Write;
7use std::path::Path;
8
9use anyhow::Result;
10use serde_json::Value;
11use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
12
13use crate::{
14 sources::{ExperimentListSource, ExperimentSource},
15 value_utils::{self, CliUtils},
16};
17
18#[derive(serde::Serialize, Debug, Default)]
19pub(crate) struct ExperimentInfo<'a> {
20 pub(crate) slug: &'a str,
21 pub(crate) app_name: &'a str,
22 pub(crate) channel: &'a str,
23 pub(crate) branches: Vec<&'a str>,
24 pub(crate) features: Vec<&'a str>,
25 pub(crate) targeting: &'a str,
26 pub(crate) bucketing: u64,
27 pub(crate) is_rollout: bool,
28 pub(crate) user_facing_name: &'a str,
29 pub(crate) user_facing_description: &'a str,
30 pub(crate) enrollment: DateRange<'a>,
31 pub(crate) is_enrollment_paused: bool,
32 pub(crate) duration: DateRange<'a>,
33}
34
35impl<'a> ExperimentInfo<'a> {
36 pub(crate) fn enrollment(&self) -> &DateRange<'a> {
37 &self.enrollment
38 }
39
40 pub(crate) fn active(&self) -> &DateRange<'a> {
41 &self.duration
42 }
43
44 fn bucketing_percent(&self) -> String {
45 format!("{: >3.0} %", self.bucketing / 100)
46 }
47}
48
49#[derive(serde::Serialize, Debug, Default)]
50pub(crate) struct DateRange<'a> {
51 start: Option<&'a str>,
52 end: Option<&'a str>,
53 proposed: Option<i64>,
54}
55
56impl<'a> DateRange<'a> {
57 fn new(start: Option<&'a Value>, end: Option<&'a Value>, duration: Option<&'a Value>) -> Self {
58 let start = start.map(Value::as_str).unwrap_or_default();
59 let end = end.map(Value::as_str).unwrap_or_default();
60 let proposed = duration.map(Value::as_i64).unwrap_or_default();
61 Self {
62 start,
63 end,
64 proposed,
65 }
66 }
67
68 pub(crate) fn contains(&self, date: &str) -> bool {
69 let start = self.start.unwrap_or("9999-99-99");
70 let end = self.end.unwrap_or("9999-99-99");
71
72 start <= date && date <= end
73 }
74}
75
76impl Display for DateRange<'_> {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match (self.start, self.end, self.proposed) {
79 (Some(s), Some(e), _) => f.write_str(&format!("{s} ➞ {e}")),
80 (Some(s), _, Some(d)) => f.write_str(&format!("{s}, proposed ending after {d} days")),
81 (Some(s), _, _) => f.write_str(&format!("{s} ➞ ?")),
82 (None, Some(e), Some(d)) => {
83 f.write_str(&format!("ending {e}, started {d} days before"))
84 }
85 (None, Some(e), _) => f.write_str(&format!("ending {e}")),
86 _ => f.write_str("unknown"),
87 }
88 }
89}
90
91impl<'a> TryFrom<&'a Value> for ExperimentInfo<'a> {
92 type Error = anyhow::Error;
93
94 fn try_from(exp: &'a Value) -> Result<Self> {
95 let features: Vec<_> = exp
96 .get_array("featureIds")?
97 .iter()
98 .flat_map(|f| f.as_str())
99 .collect();
100 let branches: Vec<_> = exp
101 .get_array("branches")?
102 .iter()
103 .flat_map(|b| {
104 b.get("slug")
105 .expect("Expecting a branch with a slug")
106 .as_str()
107 })
108 .collect();
109
110 let config = exp.get_object("bucketConfig")?;
111
112 Ok(Self {
113 slug: exp.get_str("slug")?,
114 app_name: exp.get_str("appName")?,
115 channel: exp.get_str("channel")?,
116 branches,
117 features,
118 targeting: exp.get_str("targeting")?,
119 bucketing: config.get_u64("count")?,
120 is_rollout: exp.get_bool("isRollout")?,
121 user_facing_name: exp.get_str("userFacingName")?,
122 user_facing_description: exp.get_str("userFacingDescription")?,
123 enrollment: DateRange::new(
124 exp.get("startDate"),
125 exp.get("enrollmentEndDate"),
126 exp.get("proposedEnrollment"),
127 ),
128 is_enrollment_paused: exp.get_bool("isEnrollmentPaused")?,
129 duration: DateRange::new(
130 exp.get("startDate"),
131 exp.get("endDate"),
132 exp.get("proposedDuration"),
133 ),
134 })
135 }
136}
137
138impl ExperimentListSource {
139 pub(crate) fn print_list(&self) -> Result<bool> {
140 let value: Value = self.try_into()?;
141 let array = value_utils::try_extract_data_list(&value)?;
142
143 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
144 let style = {
145 let mut style = ColorSpec::new();
146 style.set_italic(true).set_underline(true);
147 style
148 };
149
150 stdout.set_color(&style)?;
151 write!(&mut stdout, "{: <66}", "Experiment slug")?;
152 stdout.reset()?;
153 write!(&mut stdout, "|")?;
154
155 stdout.set_color(&style)?;
156 write!(&mut stdout, "{: <9}", " Channel")?;
157 stdout.reset()?;
158 write!(&mut stdout, "|")?;
159
160 stdout.set_color(&style)?;
161 write!(&mut stdout, "{: >7}", " % ")?;
162 stdout.reset()?;
163 write!(&mut stdout, "|")?;
164
165 stdout.set_color(&style)?;
166 write!(&mut stdout, "{: <31}", "Features")?;
167 stdout.reset()?;
168 write!(&mut stdout, "|")?;
169
170 stdout.set_color(&style)?;
171 write!(&mut stdout, " ")?;
172 stdout.reset()?;
173 write!(&mut stdout, "|")?;
174
175 stdout.set_color(&style)?;
176 writeln!(&mut stdout, "{: <20}", " Branches")?;
177 stdout.reset()?;
178
179 for exp in array {
180 let info = match ExperimentInfo::try_from(&exp) {
181 Ok(e) => e,
182 _ => continue,
183 };
184
185 let is_rollout = if info.is_rollout { "R" } else { "" };
186
187 writeln!(
188 &mut stdout,
189 " {slug: <65}| {channel: <8}| {bucketing: >5} | {features: <30}| {is_rollout: <1} | {branches}",
190 slug = info.slug,
191 channel = info.channel,
192 bucketing = info.bucketing_percent(),
193 features = info.features.join(", "),
194 branches = info.branches.join(", ")
195 )?;
196 }
197
198 Ok(true)
199 }
200}
201
202impl ExperimentSource {
203 pub(crate) fn print_info<P>(&self, output: Option<P>) -> Result<bool>
204 where
205 P: AsRef<Path>,
206 {
207 fn format_line(
208 stream: &mut StandardStream,
209 title: &str,
210 detail: &str,
211 ) -> std::io::Result<()> {
212 stream.set_color(ColorSpec::new().set_italic(true))?;
213 write!(stream, "{: <11}", title)?;
214 stream.reset()?;
215 write!(stream, " ")?;
216 stream.set_color(ColorSpec::new().set_bold(true).set_fg(Some(Color::Cyan)))?;
217 writeln!(stream, "{}", detail)?;
218 stream.reset()?;
219
220 Ok(())
221 }
222
223 let value = self.try_into()?;
224 let info: ExperimentInfo = ExperimentInfo::try_from(&value)?;
225 if output.is_some() {
226 value_utils::write_to_file_or_print(output, &info)?;
227 return Ok(true);
228 }
229 let url = match self {
230 Self::FromApiV6 { slug, endpoint } => Some(format!("{endpoint}/nimbus/{slug}/summary")),
231 _ => None,
232 };
233
234 let enrollment = format!(
235 "{} ({})",
236 info.enrollment,
237 if info.is_enrollment_paused {
238 "paused"
239 } else {
240 "enrolling"
241 }
242 );
243
244 let is_rollout = if info.is_rollout {
245 "Rollout".to_string()
246 } else {
247 let n = info.branches.len();
248 let b = if n == 1 {
249 "1 branch".to_string()
250 } else {
251 format!("{n} branches")
252 };
253 format!("Experiment with {b}")
254 };
255
256 let mut stdout = StandardStream::stdout(ColorChoice::Auto);
257 format_line(&mut stdout, "Slug", info.slug)?;
258 format_line(&mut stdout, "Name", info.user_facing_name)?;
259 format_line(&mut stdout, "Description", info.user_facing_description)?;
260 if let Some(url) = url {
261 format_line(&mut stdout, "URL", &url)?;
262 }
263 format_line(&mut stdout, "App", info.app_name)?;
264 format_line(&mut stdout, "Channel", info.channel)?;
265 format_line(&mut stdout, "E/R", &is_rollout)?;
266
267 format_line(&mut stdout, "Enrollment", &enrollment)?;
268 format_line(&mut stdout, "Observing", &info.duration.to_string())?;
269 format_line(
270 &mut stdout,
271 "Targeting",
272 &format!(r#""{}""#, info.targeting),
273 )?;
274 format_line(&mut stdout, "Bucketing", &info.bucketing_percent())?;
275 format_line(&mut stdout, "Branches", &info.branches.join(", "))?;
276 format_line(&mut stdout, "Features", &info.features.join(", "))?;
277
278 Ok(true)
279 }
280}
281
282#[cfg(test)]
283mod unit_tests {
284 use serde_json::json;
285
286 use super::*;
287
288 impl<'a> DateRange<'a> {
289 pub(crate) fn from_str(start: &'a str, end: &'a str, duration: i64) -> Self {
290 Self {
291 start: Some(start),
292 end: Some(end),
293 proposed: Some(duration),
294 }
295 }
296 }
297
298 #[test]
299 fn test_date_range_to_string() -> Result<()> {
300 let from = json!("2023-06-01");
301 let to = json!("2023-06-19");
302 let null = json!(null);
303 let days28 = json!(28);
304
305 let dr = DateRange::new(Some(&null), Some(&null), Some(&null));
306 let expected = "unknown".to_string();
307 let observed = dr.to_string();
308 assert_eq!(expected, observed);
309
310 let dr = DateRange::new(Some(&null), Some(&null), Some(&days28));
311 let expected = "unknown".to_string();
312 let observed = dr.to_string();
313 assert_eq!(expected, observed);
314
315 let dr = DateRange::new(Some(&null), Some(&to), Some(&null));
316 let expected = "ending 2023-06-19".to_string();
317 let observed = dr.to_string();
318 assert_eq!(expected, observed);
319
320 let dr = DateRange::new(Some(&null), Some(&to), Some(&days28));
321 let expected = "ending 2023-06-19, started 28 days before".to_string();
322 let observed = dr.to_string();
323 assert_eq!(expected, observed);
324
325 let dr = DateRange::new(Some(&from), Some(&null), Some(&null));
326 let expected = "2023-06-01 ➞ ?".to_string();
327 let observed = dr.to_string();
328 assert_eq!(expected, observed);
329
330 let dr = DateRange::new(Some(&from), Some(&null), Some(&days28));
331 let expected = "2023-06-01, proposed ending after 28 days".to_string();
332 let observed = dr.to_string();
333 assert_eq!(expected, observed);
334
335 let dr = DateRange::new(Some(&from), Some(&to), Some(&null));
336 let expected = "2023-06-01 ➞ 2023-06-19".to_string();
337 let observed = dr.to_string();
338 assert_eq!(expected, observed);
339
340 let dr = DateRange::new(Some(&from), Some(&to), Some(&days28));
341 let expected = "2023-06-01 ➞ 2023-06-19".to_string();
342 let observed = dr.to_string();
343 assert_eq!(expected, observed);
344 Ok(())
345 }
346
347 #[test]
348 fn test_date_range_contains() -> Result<()> {
349 let from = json!("2023-06-01");
350 let to = json!("2023-06-19");
351 let null = json!(null);
352
353 let before = "2023-05-01";
354 let during = "2023-06-03";
355 let after = "2023-06-20";
356
357 let dr = DateRange::new(Some(&null), Some(&null), Some(&null));
358 assert!(!dr.contains(before));
359 assert!(!dr.contains(during));
360 assert!(!dr.contains(after));
361
362 let dr = DateRange::new(Some(&null), Some(&to), Some(&null));
363 assert!(!dr.contains(before));
364 assert!(!dr.contains(during));
365 assert!(!dr.contains(after));
366
367 let dr = DateRange::new(Some(&from), Some(&null), Some(&null));
368 assert!(!dr.contains(before));
369 assert!(dr.contains(during));
370 assert!(dr.contains(after));
371
372 let dr = DateRange::new(Some(&from), Some(&to), Some(&null));
373 assert!(!dr.contains(before));
374 assert!(dr.contains(during));
375 assert!(!dr.contains(after));
376
377 Ok(())
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn test_experiment_info() -> Result<()> {
387 let exp = ExperimentSource::from_fixture("fenix-nimbus-validation-v3.json");
388 let value: Value = Value::try_from(&exp)?;
389
390 let info = ExperimentInfo::try_from(&value)?;
391
392 assert_eq!("fenix-nimbus-validation-v3", info.slug);
393 assert_eq!("Fenix Nimbus Validation v3", info.user_facing_name);
394 assert_eq!(
395 "Verify we can run A/A experiments and bucket.",
396 info.user_facing_description
397 );
398 assert_eq!("fenix", info.app_name);
399 assert_eq!("nightly", info.channel);
400 assert!(!info.is_rollout);
401 assert!(!info.is_enrollment_paused);
402 assert_eq!("true", info.targeting);
403 assert_eq!(8000, info.bucketing);
404 assert_eq!(" 80 %", info.bucketing_percent());
405 assert_eq!(vec!["a1", "a2"], info.branches);
406 assert_eq!(vec!["no-feature-fenix"], info.features);
407
408 Ok(())
409 }
410}