feature-flag 0.1.0

Server-side feature flag evaluation for async Rust: targeting rules, sticky percentage rollouts, hot reload, zero RNG.
Documentation
//! The hot path. `FlagEvaluator` wraps a `FlagSet` (behind an `ArcSwap`-style
//! lock) and resolves `(flag_id, subject)` to a variant.
//!
//! Bucketing is deterministic: `SHA-256(flag_id ‖ "/" ‖ subject_id) mod 100`,
//! so the same subject always lands in the same slot — assuming weights don't
//! shift. That gives us **sticky percentage rollouts** without an RNG.

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;

/// What an evaluation returns.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Evaluation {
    /// The flag that was evaluated.
    pub flag_id: String,
    /// Resolved variant.
    pub variant: Variant,
    /// `Some(rule_id)` if a rule fired; `None` if the default variant was used.
    pub matched_rule_id: Option<String>,
    /// One of `"rule_match"`, `"default"`, `"disabled"`.
    pub reason: &'static str,
}

/// Cheap to clone; the inner `FlagSet` lives behind an `Arc<RwLock<_>>`.
#[derive(Clone, Debug)]
pub struct FlagEvaluator {
    inner: Arc<RwLock<FlagSet>>,
}

impl FlagEvaluator {
    /// Build an evaluator from an already-validated flagset.
    pub fn new(flagset: FlagSet) -> Self {
        Self {
            inner: Arc::new(RwLock::new(flagset)),
        }
    }

    /// Atomically replace the loaded flagset. Used by [`crate::HotReloader`].
    pub fn swap(&self, flagset: FlagSet) {
        *self.inner.write() = flagset;
    }

    /// Snapshot the current flagset. Mostly useful in tests.
    pub fn snapshot(&self) -> FlagSet {
        self.inner.read().clone()
    }

    /// Evaluate a flag against a subject.
    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",
        })
    }

    /// Convenience: return just the variant string, falling back to `default`
    /// on any evaluation error (including `UnknownFlag`).
    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);
            // Bucket is in 0..100. Walk the variants in declared order and
            // accumulate weights; the first whose cumulative weight passes
            // the bucket wins.
            let mut cumulative: u32 = 0;
            for entry in variants {
                cumulative = cumulative.saturating_add(entry.weight);
                if u32::from(bucket) < cumulative {
                    return entry.variant.clone();
                }
            }
            // validate() guarantees the weights total 100, but be defensive.
            variants
                .last()
                .map_or_else(String::new, |e| e.variant.clone())
        }
    }
}

/// SHA-256-based sticky bucket in `0..100`. Deterministic — given the same
/// `(flag_id, subject_id)` you always get the same bucket.
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();
    // Take the first 4 bytes as a big-endian u32, then mod 100.
    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() {
        // 10k subjects, 100 buckets — no bucket should be wildly overfull.
        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();
        // Uniform expectation is 100; allow up to 200 (2x) to keep flake risk low.
        assert!(max < 200, "bucket max = {max} — distribution too lumpy");
    }
}