feature_flag/
evaluator.rs1use std::sync::Arc;
9
10use parking_lot::RwLock;
11use sha2::{Digest, Sha256};
12
13use crate::error::FeatureFlagError;
14use crate::model::{FlagSet, Outcome, Rule, Variant};
15use crate::subject::Subject;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct Evaluation {
20 pub flag_id: String,
22 pub variant: Variant,
24 pub matched_rule_id: Option<String>,
26 pub reason: &'static str,
28}
29
30#[derive(Clone, Debug)]
32pub struct FlagEvaluator {
33 inner: Arc<RwLock<FlagSet>>,
34}
35
36impl FlagEvaluator {
37 pub fn new(flagset: FlagSet) -> Self {
39 Self {
40 inner: Arc::new(RwLock::new(flagset)),
41 }
42 }
43
44 pub fn swap(&self, flagset: FlagSet) {
46 *self.inner.write() = flagset;
47 }
48
49 pub fn snapshot(&self) -> FlagSet {
51 self.inner.read().clone()
52 }
53
54 pub fn evaluate(
56 &self,
57 flag_id: &str,
58 subject: &Subject,
59 ) -> Result<Evaluation, FeatureFlagError> {
60 let guard = self.inner.read();
61 let flag = guard
62 .flags
63 .iter()
64 .find(|f| f.id == flag_id)
65 .ok_or_else(|| FeatureFlagError::UnknownFlag(flag_id.to_string()))?;
66
67 if !flag.enabled {
68 return Ok(Evaluation {
69 flag_id: flag.id.clone(),
70 variant: flag.default_variant.clone(),
71 matched_rule_id: None,
72 reason: "disabled",
73 });
74 }
75
76 for rule in &flag.rules {
77 if rule.when.matches(subject) {
78 let variant = resolve_outcome(flag_id, subject, rule);
79 return Ok(Evaluation {
80 flag_id: flag.id.clone(),
81 variant,
82 matched_rule_id: Some(rule.id.clone()),
83 reason: "rule_match",
84 });
85 }
86 }
87
88 Ok(Evaluation {
89 flag_id: flag.id.clone(),
90 variant: flag.default_variant.clone(),
91 matched_rule_id: None,
92 reason: "default",
93 })
94 }
95
96 pub fn variant_or(&self, flag_id: &str, subject: &Subject, default: &str) -> String {
99 match self.evaluate(flag_id, subject) {
100 Ok(e) => e.variant,
101 Err(_) => default.to_string(),
102 }
103 }
104}
105
106fn resolve_outcome(flag_id: &str, subject: &Subject, rule: &Rule) -> Variant {
107 match &rule.outcome {
108 Outcome::Variant { variant } => variant.clone(),
109 Outcome::Rollout { variants } => {
110 let bucket = bucket_of(flag_id, &subject.id);
111 let mut cumulative: u32 = 0;
115 for entry in variants {
116 cumulative = cumulative.saturating_add(entry.weight);
117 if u32::from(bucket) < cumulative {
118 return entry.variant.clone();
119 }
120 }
121 variants
123 .last()
124 .map_or_else(String::new, |e| e.variant.clone())
125 }
126 }
127}
128
129fn bucket_of(flag_id: &str, subject_id: &str) -> u8 {
132 let mut hasher = Sha256::new();
133 hasher.update(flag_id.as_bytes());
134 hasher.update(b"/");
135 hasher.update(subject_id.as_bytes());
136 let digest = hasher.finalize();
137 let n = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
139 (n % 100) as u8
140}
141
142#[cfg(test)]
143mod bucket_tests {
144 use super::bucket_of;
145
146 #[test]
147 fn bucket_is_deterministic() {
148 let a = bucket_of("flag-a", "user-1");
149 let b = bucket_of("flag-a", "user-1");
150 assert_eq!(a, b);
151 }
152
153 #[test]
154 fn buckets_distribute_reasonably() {
155 let mut counts = [0u32; 100];
157 for i in 0..10_000 {
158 let b = bucket_of("flag-x", &format!("user-{i}"));
159 counts[b as usize] += 1;
160 }
161 let max = counts.iter().copied().max().unwrap();
162 assert!(max < 200, "bucket max = {max} — distribution too lumpy");
164 }
165}