liteguard 0.7.20260603

Feature guards, observability, and security response in a single import. Evaluated locally, zero network overhead per check
Documentation
use crate::decision::DetailedEvalResult;
use crate::types::{Guard, Operator, Properties, PropertyValue, Rule};
use regex::Regex;
use std::cmp::Ordering;

/// Evaluates a guard against a property bag.
///
/// Rules are checked in declaration order and the first enabled matching rule
/// wins. If no rule matches, the guard's `default_value` is returned.
///
/// This function is deterministic and side-effect free, which makes it useful
/// for tests and local reasoning about guard behavior.
pub fn evaluate_guard(guard: &Guard, properties: &Properties) -> bool {
    for rule in &guard.rules {
        if !rule.enabled {
            continue;
        }
        if matches_rule(rule, properties) {
            return rule.result;
        }
    }
    guard.default_value
}

/// Evaluates a guard and returns both the boolean result and the index of the
/// first matching enabled rule (`-1` when the default value is used).
pub(crate) fn evaluate_guard_detailed(
    guard: &Guard,
    properties: &Properties,
) -> DetailedEvalResult {
    for (i, rule) in guard.rules.iter().enumerate() {
        if !rule.enabled {
            continue;
        }
        if matches_rule(rule, properties) {
            return DetailedEvalResult {
                result: rule.result,
                matched_rule_index: i as i64,
            };
        }
    }
    DetailedEvalResult {
        result: guard.default_value,
        matched_rule_index: -1,
    }
}

fn matches_rule(rule: &Rule, props: &Properties) -> bool {
    let raw = match props.get(&rule.property_name) {
        Some(v) => v,
        None => return false,
    };
    if rule.values.is_empty() {
        return false;
    }

    let first = rule.values.first();

    match rule.operator {
        Operator::Equals => values_equal(raw, first),
        Operator::NotEquals => !values_equal(raw, first),
        Operator::In => rule.values.iter().any(|v| values_equal(raw, Some(v))),
        Operator::NotIn => rule.values.iter().all(|v| !values_equal(raw, Some(v))),
        Operator::Regex => {
            let pattern = match first {
                Some(PropertyValue::Text(s)) => s.as_str(),
                _ => return false,
            };
            Regex::new(pattern)
                .map(|re| re.is_match(&value_to_string(raw)))
                .unwrap_or(false)
        }
        Operator::Gt => compare_ordered_values(raw, first) == Some(Ordering::Greater),
        Operator::Gte => matches!(
            compare_ordered_values(raw, first),
            Some(Ordering::Greater) | Some(Ordering::Equal)
        ),
        Operator::Lt => compare_ordered_values(raw, first) == Some(Ordering::Less),
        Operator::Lte => matches!(
            compare_ordered_values(raw, first),
            Some(Ordering::Less) | Some(Ordering::Equal)
        ),
    }
}

fn values_equal(a: &PropertyValue, b: Option<&PropertyValue>) -> bool {
    let Some(b) = b else {
        return false;
    };

    match (a, b) {
        (PropertyValue::Bool(left), PropertyValue::Bool(right)) => left == right,
        (PropertyValue::Text(left), PropertyValue::Text(right)) => left == right,
        (PropertyValue::Number(left), PropertyValue::Number(right)) => left == right,
        _ => false,
    }
}

fn value_to_string(v: &PropertyValue) -> String {
    match v {
        PropertyValue::Text(s) => s.clone(),
        PropertyValue::Number(n) => n.to_string(),
        PropertyValue::Bool(b) => b.to_string(),
    }
}

fn compare_ordered_values(a: &PropertyValue, b: Option<&PropertyValue>) -> Option<Ordering> {
    let b = b?;
    match (a, b) {
        (PropertyValue::Text(left), PropertyValue::Text(right)) => left.partial_cmp(right),
        (PropertyValue::Number(left), PropertyValue::Number(right)) => left.partial_cmp(right),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{Guard, Operator, Properties, Rule};

    fn make_guard(def: bool, rules: Vec<Rule>) -> Guard {
        Guard {
            name: "test".into(),
            rules,
            default_value: def,
            adopted: true,
            rate_limit: None,
            dry_run_rate_limit: None,
            disable_measurement: None,
        }
    }

    fn rule(prop: &str, op: Operator, values: Vec<PropertyValue>, result: bool) -> Rule {
        Rule {
            property_name: prop.into(),
            operator: op,
            values,
            result,
            enabled: true,
        }
    }

    #[test]
    fn no_rules_returns_default() {
        assert!(!evaluate_guard(
            &make_guard(false, vec![]),
            &Properties::new()
        ));
        assert!(evaluate_guard(
            &make_guard(true, vec![]),
            &Properties::new()
        ));
    }

    #[test]
    fn equals_match() {
        let g = make_guard(
            false,
            vec![rule("plan", Operator::Equals, vec!["pro".into()], true)],
        );
        assert!(evaluate_guard(&g, &Properties::new().set("plan", "pro")));
        assert!(!evaluate_guard(&g, &Properties::new().set("plan", "free")));
    }

    #[test]
    fn disabled_rule_skipped() {
        let mut r = rule("plan", Operator::Equals, vec!["pro".into()], true);
        r.enabled = false;
        let g = make_guard(false, vec![r]);
        assert!(!evaluate_guard(&g, &Properties::new().set("plan", "pro")));
    }

    #[test]
    fn in_operator() {
        let g = make_guard(
            false,
            vec![rule(
                "userId",
                Operator::In,
                vec!["alice".into(), "bob".into()],
                true,
            )],
        );
        assert!(evaluate_guard(
            &g,
            &Properties::new().set("userId", "alice")
        ));
        assert!(!evaluate_guard(
            &g,
            &Properties::new().set("userId", "charlie")
        ));
    }

    #[test]
    fn regex_operator() {
        let g = make_guard(
            false,
            vec![rule(
                "email",
                Operator::Regex,
                vec![r"^.*@acme\.com$".into()],
                true,
            )],
        );
        assert!(evaluate_guard(
            &g,
            &Properties::new().set("email", "alice@acme.com")
        ));
        assert!(!evaluate_guard(
            &g,
            &Properties::new().set("email", "alice@other.com")
        ));
    }

    #[test]
    fn empty_values_fail_closed() {
        let equals = make_guard(false, vec![rule("plan", Operator::Equals, vec![], true)]);
        assert!(!evaluate_guard(
            &equals,
            &Properties::new().set("plan", "pro")
        ));

        let not_in = make_guard(false, vec![rule("userId", Operator::NotIn, vec![], true)]);
        assert!(!evaluate_guard(
            &not_in,
            &Properties::new().set("userId", "alice")
        ));

        let gt = make_guard(false, vec![rule("score", Operator::Gt, vec![], true)]);
        assert!(!evaluate_guard(
            &gt,
            &Properties::new().set("score", 99.0_f64)
        ));

        let regex = make_guard(false, vec![rule("email", Operator::Regex, vec![], true)]);
        assert!(!evaluate_guard(
            &regex,
            &Properties::new().set("email", "alice@acme.com")
        ));
    }

    #[test]
    fn numeric_gt() {
        let g = make_guard(
            false,
            vec![rule("score", Operator::Gt, vec![50.0_f64.into()], true)],
        );
        assert!(evaluate_guard(
            &g,
            &Properties::new().set("score", 51.0_f64)
        ));
        assert!(!evaluate_guard(
            &g,
            &Properties::new().set("score", 50.0_f64)
        ));
    }

    #[test]
    fn first_match_wins() {
        let g = make_guard(
            false,
            vec![
                rule("plan", Operator::Equals, vec!["pro".into()], true),
                rule("plan", Operator::Equals, vec!["pro".into()], false),
            ],
        );
        assert!(evaluate_guard(&g, &Properties::new().set("plan", "pro")));
    }

    #[test]
    fn missing_property_no_match() {
        let g = make_guard(
            false,
            vec![rule("plan", Operator::Equals, vec!["pro".into()], true)],
        );
        assert!(!evaluate_guard(&g, &Properties::new()));
    }
}