1use serde_json::{Value, json};
2use sofie_core::{Error, Fnv1a64, Result, Uuid};
3
4#[derive(Clone, Debug, PartialEq, Eq)]
6pub struct Rule {
7 pub on: bool,
8 pub environments: Vec<String>,
9 pub areas: Vec<String>,
10 pub principals: Vec<Uuid>,
11 pub percentage: u8,
12 pub variants: Vec<(String, u32)>,
13}
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub struct Subject {
17 pub area: Option<&'static str>,
18 pub principal: Option<Uuid>,
19}
20
21impl Rule {
22 #[must_use]
23 pub fn off() -> Self {
24 Self {
25 on: false,
26 environments: Vec::new(),
27 areas: Vec::new(),
28 principals: Vec::new(),
29 percentage: 100,
30 variants: Vec::new(),
31 }
32 }
33
34 #[must_use]
35 pub fn everyone() -> Self {
36 Self {
37 on: true,
38 ..Self::off()
39 }
40 }
41
42 #[must_use]
43 pub fn is_settled(&self) -> bool {
44 let one_variant = self
45 .variants
46 .iter()
47 .filter(|(_, weight)| *weight > 0)
48 .count()
49 <= 1;
50 !self.on
51 || (self.environments.is_empty()
52 && self.areas.is_empty()
53 && self.percentage == 100
54 && one_variant)
55 }
56
57 pub(crate) fn is_on(&self, name: &str, environment: &str, subject: Subject) -> bool {
58 if !self.applies(environment, subject.area) {
59 return false;
60 }
61 if self.percentage >= 100 {
62 return true;
63 }
64 subject.principal.is_some_and(|principal| {
65 self.principals.contains(&principal)
66 || bucket(&[name], principal, 100) < u64::from(self.percentage)
67 })
68 }
69
70 pub(crate) fn is_on_for_someone(&self, environment: &str, area: Option<&str>) -> bool {
72 self.applies(environment, area) && (self.percentage > 0 || !self.principals.is_empty())
73 }
74
75 fn applies(&self, environment: &str, area: Option<&str>) -> bool {
76 let listed =
77 |list: &[String], value: &str| list.is_empty() || list.iter().any(|item| item == value);
78 self.on
79 && listed(&self.environments, environment)
80 && (self.areas.is_empty() || area.is_some_and(|area| listed(&self.areas, area)))
81 }
82
83 pub(crate) fn variant(&self, name: &str, subject: Subject) -> Option<&str> {
84 let weighted = self.variants.iter().filter(|(_, weight)| *weight > 0);
85 let total: u64 = weighted.clone().map(|(_, weight)| u64::from(*weight)).sum();
86 let Some(principal) = subject.principal else {
87 return weighted.map(|(variant, _)| variant.as_str()).next();
88 };
89 let mut point = bucket(&[name, "/variant"], principal, total.max(1));
90 for (variant, weight) in weighted {
91 match point.checked_sub(u64::from(*weight)) {
92 Some(rest) => point = rest,
93 None => return Some(variant),
94 }
95 }
96 None
97 }
98
99 pub(crate) fn to_json(&self) -> String {
100 let principals: Vec<String> = self.principals.iter().map(ToString::to_string).collect();
101 json!({
102 "on": self.on,
103 "environments": self.environments,
104 "areas": self.areas,
105 "principals": principals,
106 "percentage": self.percentage,
107 "variants": self.variants,
108 })
109 .to_string()
110 }
111
112 pub(crate) fn from_json(name: &str, text: &str) -> Result<Self> {
113 let malformed = |what: &str| {
114 Error::internal_message(format!("the stored rule of flag `{name}` has {what}"))
115 };
116 let value: Value = serde_json::from_str(text).map_err(|error| {
117 Error::internal(format!("reading the stored rule of flag `{name}`"), error)
118 })?;
119 let texts = |key: &str| -> Result<Vec<String>> {
120 value[key]
121 .as_array()
122 .ok_or_else(|| malformed(&format!("no list `{key}`")))?
123 .iter()
124 .map(|item| {
125 item.as_str()
126 .map(str::to_owned)
127 .ok_or_else(|| malformed(&format!("a non-text entry in `{key}`")))
128 })
129 .collect()
130 };
131 let principals = texts("principals")?
132 .iter()
133 .map(|text| {
134 text.parse()
135 .map_err(|_| malformed("a principal that is not an id"))
136 })
137 .collect::<Result<_>>()?;
138 let rule = Self {
139 on: value["on"]
140 .as_bool()
141 .ok_or_else(|| malformed("no switch `on`"))?,
142 environments: texts("environments")?,
143 areas: texts("areas")?,
144 principals,
145 percentage: value["percentage"]
146 .as_u64()
147 .and_then(|percentage| u8::try_from(percentage).ok())
148 .ok_or_else(|| malformed("no `percentage`"))?,
149 variants: variants_of(&value["variants"])
150 .ok_or_else(|| malformed("malformed `variants`"))?,
151 };
152 rule.validate(name)?;
153 Ok(rule)
154 }
155
156 pub fn validate(&self, name: &str) -> Result<()> {
157 if self.percentage > 100 {
158 return Err(Error::invalid(
159 "percentage",
160 format!("of flag `{name}` must be between 0 and 100"),
161 ));
162 }
163 refuse_malformed_entries("environments", &self.environments)?;
164 refuse_malformed_entries("areas", &self.areas)?;
165 let variants: Vec<String> = self
166 .variants
167 .iter()
168 .map(|(variant, _)| variant.clone())
169 .collect();
170 refuse_malformed_entries("variants", &variants)
171 }
172}
173
174fn refuse_malformed_entries(field: &str, entries: &[String]) -> Result<()> {
176 let named = |entry: &String| {
177 !entry.is_empty()
178 && entry
179 .chars()
180 .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character))
181 };
182 if let Some(malformed) = entries.iter().find(|entry| !named(entry)) {
183 return Err(Error::invalid(
184 field,
185 format!("`{malformed}` is not a name of letters, digits, `_` and `-`"),
186 ));
187 }
188 let repeated = entries
189 .iter()
190 .enumerate()
191 .find(|(index, entry)| entries[..*index].contains(entry));
192 if let Some((_, entry)) = repeated {
193 return Err(Error::invalid(field, format!("lists `{entry}` twice")));
194 }
195 Ok(())
196}
197
198fn variants_of(value: &Value) -> Option<Vec<(String, u32)>> {
199 value
200 .as_array()?
201 .iter()
202 .map(|pair| {
203 let name = pair.get(0)?.as_str()?.to_owned();
204 let weight = u32::try_from(pair.get(1)?.as_u64()?).ok()?;
205 Some((name, weight))
206 })
207 .collect()
208}
209
210fn bucket(name: &[&str], principal: Uuid, range: u64) -> u64 {
212 let hash = name
213 .iter()
214 .fold(Fnv1a64::new(), |hash, part| hash.write(part.as_bytes()))
215 .write(&[0])
216 .write(principal.as_bytes())
217 .finish();
218 mixed(hash) % range
219}
220
221fn mixed(mut hash: u64) -> u64 {
224 hash ^= hash >> 30;
225 hash = hash.wrapping_mul(0xbf58_476d_1ce4_e5b9);
226 hash ^= hash >> 27;
227 hash = hash.wrapping_mul(0x94d0_49bb_1331_11eb);
228 hash ^ (hash >> 31)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn principal(n: u128) -> Uuid {
236 Uuid::from_u128(n)
237 }
238
239 fn someone(n: u128) -> Subject {
240 Subject {
241 area: Some("member"),
242 principal: Some(principal(n)),
243 }
244 }
245
246 #[test]
247 fn off_is_off_and_everyone_is_on_for_anyone() {
248 assert!(!Rule::off().is_on("a.b", "production", someone(1)));
249 assert!(Rule::everyone().is_on("a.b", "production", Subject::default()));
250 }
251
252 #[test]
253 fn environments_and_areas_narrow_it() {
254 let rule = Rule {
255 environments: vec!["staging".to_owned()],
256 areas: vec!["console".to_owned()],
257 ..Rule::everyone()
258 };
259 let console = Subject {
260 area: Some("console"),
261 principal: None,
262 };
263 assert!(rule.is_on("a.b", "staging", console));
264 assert!(!rule.is_on("a.b", "production", console));
265 assert!(!rule.is_on("a.b", "staging", someone(1)));
266 assert!(!rule.is_on("a.b", "staging", Subject::default()));
267 }
268
269 #[test]
270 fn a_partial_rollout_is_on_for_someone_though_off_for_a_caller_without_a_principal() {
271 let listed = Rule {
272 principals: vec![principal(1)],
273 percentage: 0,
274 ..Rule::everyone()
275 };
276 let partial = Rule {
277 percentage: 10,
278 ..Rule::everyone()
279 };
280 let nobody = Rule {
281 percentage: 0,
282 ..Rule::everyone()
283 };
284 for rule in [&listed, &partial] {
285 assert!(!rule.is_on("a.b", "production", Subject::default()));
286 assert!(rule.is_on_for_someone("production", Some("member")));
287 }
288 assert!(!nobody.is_on_for_someone("production", Some("member")));
289 assert!(!Rule::off().is_on_for_someone("production", Some("member")));
290 }
291
292 fn split() -> Rule {
293 Rule {
294 variants: vec![("a".to_owned(), 1), ("b".to_owned(), 1)],
295 ..Rule::everyone()
296 }
297 }
298
299 #[test]
300 fn two_flags_split_principals_independently() {
301 let (first, second) = (split(), split());
302 let principals =
303 (1..=4_000_u128).map(|n| n.wrapping_mul(0x9e37_79b9_7f4a_7c15_f39c_c060_5ced_c835));
304 let same = principals
305 .filter(|n| {
306 let subject = someone(*n);
307 first.variant("a.first", subject) == second.variant("a.second", subject)
308 })
309 .count();
310 assert!((1_700..2_300).contains(&same), "{same}");
311 }
312
313 #[test]
314 fn a_rollout_does_not_choose_its_principals_variant() {
315 let rule = Rule {
316 percentage: 10,
317 ..split()
318 };
319 let rolled_in: Vec<Subject> = (1..=20_000_u128)
320 .map(someone)
321 .filter(|subject| rule.is_on("a.b", "x", *subject))
322 .collect();
323 let first_arm = rolled_in
324 .iter()
325 .filter(|subject| rule.variant("a.b", **subject) == Some("a"))
326 .count();
327 let share = first_arm * 100 / rolled_in.len();
328 assert!(
329 (40..=60).contains(&share),
330 "{share}% of {}",
331 rolled_in.len()
332 );
333 }
334
335 #[test]
336 fn a_percentage_is_stable_per_principal_and_near_its_share() {
337 let rule = Rule {
338 percentage: 30,
339 ..Rule::everyone()
340 };
341 let on = (0..10_000)
342 .filter(|n| rule.is_on("a.b", "x", someone(*n)))
343 .count();
344 assert!((2_700..3_300).contains(&on), "{on}");
345 let first: Vec<bool> = (0..50)
346 .map(|n| rule.is_on("a.b", "x", someone(n)))
347 .collect();
348 let again: Vec<bool> = (0..50)
349 .map(|n| rule.is_on("a.b", "x", someone(n)))
350 .collect();
351 assert_eq!(first, again);
352 assert!(!rule.is_on("a.b", "x", Subject::default()));
353 }
354
355 #[test]
356 fn listed_principals_are_in_whatever_the_percentage() {
357 let rule = Rule {
358 percentage: 0,
359 principals: vec![principal(7)],
360 ..Rule::everyone()
361 };
362 assert!(rule.is_on("a.b", "x", someone(7)));
363 assert!(!rule.is_on("a.b", "x", someone(8)));
364 }
365
366 #[test]
367 fn variants_split_by_weight_and_default_to_the_first() {
368 let rule = Rule {
369 variants: vec![("classic".to_owned(), 1), ("compact".to_owned(), 3)],
370 ..Rule::everyone()
371 };
372 let compact = (0..4_000)
373 .filter(|n| rule.variant("a.b", someone(*n)) == Some("compact"))
374 .count();
375 assert!((2_800..3_200).contains(&compact), "{compact}");
376 assert_eq!(rule.variant("a.b", Subject::default()), Some("classic"));
377 }
378
379 #[test]
380 fn a_rule_survives_its_stored_form() {
381 let rule = Rule {
382 environments: vec!["production".to_owned()],
383 areas: vec!["member".to_owned()],
384 principals: vec![principal(3)],
385 percentage: 25,
386 variants: vec![("a".to_owned(), 1)],
387 ..Rule::everyone()
388 };
389 assert_eq!(Rule::from_json("a.b", &rule.to_json()).unwrap(), rule);
390 assert!(Rule::from_json("a.b", "{\"on\":true}").is_err());
391 }
392
393 #[test]
394 fn a_target_that_could_never_match_is_refused() {
395 for refused in [
396 Rule {
397 areas: vec![String::new()],
398 ..Rule::everyone()
399 },
400 Rule {
401 environments: vec!["pro duction".to_owned()],
402 ..Rule::everyone()
403 },
404 Rule {
405 variants: vec![("a".to_owned(), 1), ("a".to_owned(), 2)],
406 ..Rule::everyone()
407 },
408 ] {
409 assert!(refused.validate("a.b").is_err(), "{refused:?}");
410 }
411 }
412
413 #[test]
414 fn settled_means_the_same_for_everyone() {
415 assert!(Rule::off().is_settled());
416 assert!(Rule::everyone().is_settled());
417 let partial = Rule {
418 percentage: 50,
419 ..Rule::everyone()
420 };
421 assert!(!partial.is_settled());
422 }
423}