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