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, 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}