feature-flag 0.1.0

Server-side feature flag evaluation for async Rust: targeting rules, sticky percentage rollouts, hot reload, zero RNG.
Documentation
//! Targeting predicate DSL — typed comparisons + boolean combinators.
//!
//! The DSL is deliberately small. If you need a real expression language you
//! probably want a different crate.

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::Subject;

/// Comparison operator for [`Predicate::Compare`].
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Comparison {
    /// `attr == value`
    Eq,
    /// `attr != value`
    Ne,
    /// `attr > value` (numeric)
    Gt,
    /// `attr >= value` (numeric)
    Gte,
    /// `attr < value` (numeric)
    Lt,
    /// `attr <= value` (numeric)
    Lte,
    /// `value` must be a JSON array; true when `attr` is one of its elements.
    In,
    /// Inverse of `In`.
    NotIn,
    /// String prefix match.
    StartsWith,
    /// String suffix match.
    EndsWith,
}

/// One node of the predicate tree. Tagged via `kind` for serde so the JSON
/// stays readable.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Predicate {
    /// Always true. Useful as a default-match.
    Always,
    /// Look at `subject.attrs[attr]` and compare it to `value` with `op`.
    Compare {
        /// Attribute key (e.g. `"country"`, `"plan"`, `"email"`).
        attr: String,
        /// Comparison operator.
        op: Comparison,
        /// Right-hand side.
        value: Value,
    },
    /// All children must match.
    AllOf {
        /// Children.
        matchers: Vec<Predicate>,
    },
    /// At least one child must match.
    AnyOf {
        /// Children.
        matchers: Vec<Predicate>,
    },
    /// Inverts the child.
    Not {
        /// Child.
        matcher: Box<Predicate>,
    },
}

impl Predicate {
    /// Evaluate against a subject. Missing attrs make every comparison `false`
    /// (except `NotIn` against a list — that's still true if the value isn't
    /// there).
    pub fn matches(&self, subject: &Subject) -> bool {
        match self {
            Self::Always => true,
            Self::AllOf { matchers } => matchers.iter().all(|p| p.matches(subject)),
            Self::AnyOf { matchers } => matchers.iter().any(|p| p.matches(subject)),
            Self::Not { matcher } => !matcher.matches(subject),
            Self::Compare { attr, op, value } => compare(subject.attr(attr), *op, value),
        }
    }
}

fn compare(actual: Option<&Value>, op: Comparison, expected: &Value) -> bool {
    match op {
        Comparison::Eq => actual.is_some_and(|a| a == expected),
        Comparison::Ne => actual.is_none_or(|a| a != expected),
        Comparison::Gt => num_cmp(actual, expected, |a, b| a > b),
        Comparison::Gte => num_cmp(actual, expected, |a, b| a >= b),
        Comparison::Lt => num_cmp(actual, expected, |a, b| a < b),
        Comparison::Lte => num_cmp(actual, expected, |a, b| a <= b),
        Comparison::In => match (actual, expected.as_array()) {
            (Some(a), Some(arr)) => arr.iter().any(|v| v == a),
            _ => false,
        },
        Comparison::NotIn => match (actual, expected.as_array()) {
            (Some(a), Some(arr)) => !arr.iter().any(|v| v == a),
            (None, Some(_)) => true,
            _ => false,
        },
        Comparison::StartsWith => str_pair(actual, expected, |a, b| a.starts_with(b)),
        Comparison::EndsWith => str_pair(actual, expected, |a, b| a.ends_with(b)),
    }
}

fn num_cmp(actual: Option<&Value>, expected: &Value, f: impl Fn(f64, f64) -> bool) -> bool {
    match (actual.and_then(Value::as_f64), expected.as_f64()) {
        (Some(a), Some(b)) => f(a, b),
        _ => false,
    }
}

fn str_pair(actual: Option<&Value>, expected: &Value, f: impl Fn(&str, &str) -> bool) -> bool {
    match (actual.and_then(Value::as_str), expected.as_str()) {
        (Some(a), Some(b)) => f(a, b),
        _ => false,
    }
}