nimbus_cli/output/
info.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use 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}