nimbus_cli/sources/
filter.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 crate::{cli::ExperimentListFilterArgs, output::info::ExperimentInfo};
6use anyhow::Result;
7use serde_json::Value;
8
9#[derive(Clone, Debug, Default, PartialEq)]
10pub(crate) struct ExperimentListFilter {
11    slug_pattern: Option<String>,
12    app: Option<String>,
13    feature_pattern: Option<String>,
14    active_on: Option<String>,
15    enrolling_on: Option<String>,
16    channel: Option<String>,
17    is_rollout: Option<bool>,
18}
19
20impl From<&ExperimentListFilterArgs> for ExperimentListFilter {
21    fn from(value: &ExperimentListFilterArgs) -> Self {
22        ExperimentListFilter {
23            slug_pattern: value.slug.clone(),
24            feature_pattern: value.feature.clone(),
25            active_on: value.active_on.clone(),
26            enrolling_on: value.enrolling_on.clone(),
27            channel: value.channel.clone(),
28            is_rollout: value.is_rollout,
29            ..Default::default()
30        }
31    }
32}
33
34impl ExperimentListFilter {
35    pub(crate) fn for_app(app: &str) -> Self {
36        ExperimentListFilter {
37            app: Some(app.to_string()),
38            ..Default::default()
39        }
40    }
41}
42
43#[cfg(test)]
44impl ExperimentListFilter {
45    pub(crate) fn for_feature(feature_pattern: &str) -> Self {
46        ExperimentListFilter {
47            feature_pattern: Some(feature_pattern.to_string()),
48            ..Default::default()
49        }
50    }
51
52    pub(crate) fn for_active_on(date: &str) -> Self {
53        ExperimentListFilter {
54            active_on: Some(date.to_string()),
55            ..Default::default()
56        }
57    }
58
59    pub(crate) fn for_enrolling_on(date: &str) -> Self {
60        ExperimentListFilter {
61            enrolling_on: Some(date.to_string()),
62            ..Default::default()
63        }
64    }
65}
66
67impl ExperimentListFilter {
68    pub(crate) fn is_empty(&self) -> bool {
69        self == &Default::default()
70    }
71
72    pub(crate) fn matches(&self, value: &Value) -> Result<bool> {
73        let info: ExperimentInfo = match value.try_into() {
74            Ok(e) => e,
75            _ => return Ok(false),
76        };
77        Ok(self.matches_info(info))
78    }
79
80    fn matches_info(&self, info: ExperimentInfo) -> bool {
81        match self.slug_pattern.as_deref() {
82            Some(s) if !info.slug.contains(s) => return false,
83            _ => (),
84        };
85
86        match self.app.as_deref() {
87            Some(s) if s != info.app_name => return false,
88            _ => (),
89        };
90
91        match self.channel.as_deref() {
92            Some(s) if s != info.channel => return false,
93            _ => (),
94        };
95
96        match self.is_rollout {
97            Some(s) if s != info.is_rollout => return false,
98            _ => (),
99        };
100
101        match self.feature_pattern.as_deref() {
102            Some(f) if !info.features.iter().any(|s| s.contains(f)) => return false,
103            _ => (),
104        };
105
106        match self.active_on.as_deref() {
107            Some(date) if !info.active().contains(date) => return false,
108            _ => (),
109        };
110
111        match self.enrolling_on.as_deref() {
112            Some(date) if !info.enrollment().contains(date) => return false,
113            _ => (),
114        };
115
116        true
117    }
118}
119
120#[cfg(test)]
121mod unit_tests {
122    use crate::output::info::DateRange;
123
124    use super::*;
125
126    #[test]
127    fn test_matches_app() -> Result<()> {
128        let filter = ExperimentListFilter {
129            app: Some("my-app".to_string()),
130            ..Default::default()
131        };
132
133        let positive = ExperimentInfo {
134            app_name: "my-app",
135            ..Default::default()
136        };
137        assert!(filter.matches_info(positive));
138
139        let negative = ExperimentInfo {
140            app_name: "not-my-app",
141            ..Default::default()
142        };
143        assert!(!filter.matches_info(negative));
144
145        Ok(())
146    }
147
148    #[test]
149    fn test_matches_slug() -> Result<()> {
150        let filter = ExperimentListFilter {
151            slug_pattern: Some("my-app".to_string()),
152            ..Default::default()
153        };
154
155        let positive = ExperimentInfo {
156            slug: "my-app",
157            ..Default::default()
158        };
159        assert!(filter.matches_info(positive));
160
161        let negative = ExperimentInfo {
162            slug: "my-other-app",
163            ..Default::default()
164        };
165        assert!(!filter.matches_info(negative));
166
167        Ok(())
168    }
169
170    #[test]
171    fn test_matches_channel() -> Result<()> {
172        let filter = ExperimentListFilter {
173            channel: Some("release".to_string()),
174            ..Default::default()
175        };
176
177        let positive = ExperimentInfo {
178            channel: "release",
179            ..Default::default()
180        };
181        assert!(filter.matches_info(positive));
182
183        let negative = ExperimentInfo {
184            channel: "beta",
185            ..Default::default()
186        };
187        assert!(!filter.matches_info(negative));
188
189        Ok(())
190    }
191
192    #[test]
193    fn test_matches_is_rollout() -> Result<()> {
194        let filter = ExperimentListFilter {
195            is_rollout: Some(false),
196            ..Default::default()
197        };
198
199        let positive = ExperimentInfo {
200            is_rollout: false,
201            ..Default::default()
202        };
203        assert!(filter.matches_info(positive));
204
205        let negative = ExperimentInfo {
206            is_rollout: true,
207            ..Default::default()
208        };
209        assert!(!filter.matches_info(negative));
210
211        Ok(())
212    }
213
214    #[test]
215    fn test_matches_conjunction() -> Result<()> {
216        let filter = ExperimentListFilter {
217            app: Some("my-app".to_string()),
218            channel: Some("release".to_string()),
219            ..Default::default()
220        };
221
222        let positive = ExperimentInfo {
223            app_name: "my-app",
224            channel: "release",
225            ..Default::default()
226        };
227        assert!(filter.matches_info(positive));
228
229        let negative = ExperimentInfo {
230            app_name: "not-my-app",
231            channel: "release",
232            ..Default::default()
233        };
234        assert!(!filter.matches_info(negative));
235
236        let negative = ExperimentInfo {
237            app_name: "my-app",
238            channel: "not-release",
239            ..Default::default()
240        };
241        assert!(!filter.matches_info(negative));
242
243        Ok(())
244    }
245
246    #[test]
247    fn test_matches_features() -> Result<()> {
248        let filter = ExperimentListFilter {
249            feature_pattern: Some("another".to_string()),
250            ..Default::default()
251        };
252
253        let positive = ExperimentInfo {
254            features: vec!["my-feature", "another-feature"],
255            ..Default::default()
256        };
257        assert!(filter.matches_info(positive));
258
259        let negative = ExperimentInfo {
260            features: vec!["my-feature", "not-this-feature"],
261            ..Default::default()
262        };
263        assert!(!filter.matches_info(negative));
264
265        Ok(())
266    }
267
268    #[test]
269    fn test_matches_enrolling_on() -> Result<()> {
270        let filter = ExperimentListFilter {
271            enrolling_on: Some("2023-07-18".to_string()),
272            ..Default::default()
273        };
274
275        let positive = ExperimentInfo {
276            enrollment: DateRange::from_str("2023-07-01", "2023-07-31", 0),
277            ..Default::default()
278        };
279        assert!(filter.matches_info(positive));
280
281        let negative = ExperimentInfo {
282            enrollment: DateRange::from_str("2023-06-01", "2023-06-30", 0),
283            ..Default::default()
284        };
285        assert!(!filter.matches_info(negative));
286
287        Ok(())
288    }
289
290    #[test]
291    fn test_matches_active_on() -> Result<()> {
292        let filter = ExperimentListFilter {
293            active_on: Some("2023-07-18".to_string()),
294            ..Default::default()
295        };
296
297        let positive = ExperimentInfo {
298            duration: DateRange::from_str("2023-07-01", "2023-07-31", 0),
299            ..Default::default()
300        };
301        assert!(filter.matches_info(positive));
302
303        let negative = ExperimentInfo {
304            duration: DateRange::from_str("2023-06-01", "2023-06-30", 0),
305            ..Default::default()
306        };
307        assert!(!filter.matches_info(negative));
308
309        Ok(())
310    }
311}