use std::sync::Arc;
use parking_lot::RwLock;
use sha2::{Digest, Sha256};
use crate::error::FeatureFlagError;
use crate::model::{FlagSet, Outcome, Rule, Variant};
use crate::subject::Subject;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Evaluation {
pub flag_id: String,
pub variant: Variant,
pub matched_rule_id: Option<String>,
pub reason: &'static str,
}
#[derive(Clone, Debug)]
pub struct FlagEvaluator {
inner: Arc<RwLock<FlagSet>>,
}
impl FlagEvaluator {
pub fn new(flagset: FlagSet) -> Self {
Self {
inner: Arc::new(RwLock::new(flagset)),
}
}
pub fn swap(&self, flagset: FlagSet) {
*self.inner.write() = flagset;
}
pub fn snapshot(&self) -> FlagSet {
self.inner.read().clone()
}
pub fn evaluate(
&self,
flag_id: &str,
subject: &Subject,
) -> Result<Evaluation, FeatureFlagError> {
let guard = self.inner.read();
let flag = guard
.flags
.iter()
.find(|f| f.id == flag_id)
.ok_or_else(|| FeatureFlagError::UnknownFlag(flag_id.to_string()))?;
if !flag.enabled {
return Ok(Evaluation {
flag_id: flag.id.clone(),
variant: flag.default_variant.clone(),
matched_rule_id: None,
reason: "disabled",
});
}
for rule in &flag.rules {
if rule.when.matches(subject) {
let variant = resolve_outcome(flag_id, subject, rule);
return Ok(Evaluation {
flag_id: flag.id.clone(),
variant,
matched_rule_id: Some(rule.id.clone()),
reason: "rule_match",
});
}
}
Ok(Evaluation {
flag_id: flag.id.clone(),
variant: flag.default_variant.clone(),
matched_rule_id: None,
reason: "default",
})
}
pub fn variant_or(&self, flag_id: &str, subject: &Subject, default: &str) -> String {
match self.evaluate(flag_id, subject) {
Ok(e) => e.variant,
Err(_) => default.to_string(),
}
}
}
fn resolve_outcome(flag_id: &str, subject: &Subject, rule: &Rule) -> Variant {
match &rule.outcome {
Outcome::Variant { variant } => variant.clone(),
Outcome::Rollout { variants } => {
let bucket = bucket_of(flag_id, &subject.id);
let mut cumulative: u32 = 0;
for entry in variants {
cumulative = cumulative.saturating_add(entry.weight);
if u32::from(bucket) < cumulative {
return entry.variant.clone();
}
}
variants
.last()
.map_or_else(String::new, |e| e.variant.clone())
}
}
}
fn bucket_of(flag_id: &str, subject_id: &str) -> u8 {
let mut hasher = Sha256::new();
hasher.update(flag_id.as_bytes());
hasher.update(b"/");
hasher.update(subject_id.as_bytes());
let digest = hasher.finalize();
let n = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
(n % 100) as u8
}
#[cfg(test)]
mod bucket_tests {
use super::bucket_of;
#[test]
fn bucket_is_deterministic() {
let a = bucket_of("flag-a", "user-1");
let b = bucket_of("flag-a", "user-1");
assert_eq!(a, b);
}
#[test]
fn buckets_distribute_reasonably() {
let mut counts = [0u32; 100];
for i in 0..10_000 {
let b = bucket_of("flag-x", &format!("user-{i}"));
counts[b as usize] += 1;
}
let max = counts.iter().copied().max().unwrap();
assert!(max < 200, "bucket max = {max} — distribution too lumpy");
}
}