feature-flag 0.1.0

Server-side feature flag evaluation for async Rust: targeting rules, sticky percentage rollouts, hot reload, zero RNG.
Documentation
use feature_flag::{FlagEvaluator, FlagSet, Subject};
use serde_json::json;

fn build(set: &str) -> FlagEvaluator {
    FlagEvaluator::new(FlagSet::from_json(set).expect("valid"))
}

const ALWAYS_ON: &str = r#"{
  "flags": [{
    "id": "f",
    "variants": ["on", "off"],
    "default_variant": "off",
    "rules": [
      {"id": "r", "when": {"kind": "always"},
       "outcome": {"kind": "variant", "variant": "on"}}
    ]
  }]
}"#;

#[test]
fn rule_match_returns_rule_variant() {
    let e = build(ALWAYS_ON);
    let r = e.evaluate("f", &Subject::new("u1")).unwrap();
    assert_eq!(r.variant, "on");
    assert_eq!(r.matched_rule_id.as_deref(), Some("r"));
    assert_eq!(r.reason, "rule_match");
}

#[test]
fn default_returned_when_no_rule_matches() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [
          {"id": "us-only",
           "when": {"kind": "compare", "attr": "country", "op": "eq", "value": "US"},
           "outcome": {"kind": "variant", "variant": "on"}}
        ]
      }]
    }"#;
    let e = build(set);
    let r = e
        .evaluate("f", &Subject::new("u1").with_attr("country", "DE"))
        .unwrap();
    assert_eq!(r.variant, "off");
    assert_eq!(r.reason, "default");
    assert!(r.matched_rule_id.is_none());
}

#[test]
fn disabled_flag_skips_rules() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "enabled": false,
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [
          {"id": "r", "when": {"kind": "always"},
           "outcome": {"kind": "variant", "variant": "on"}}
        ]
      }]
    }"#;
    let e = build(set);
    let r = e.evaluate("f", &Subject::new("u1")).unwrap();
    assert_eq!(r.variant, "off");
    assert_eq!(r.reason, "disabled");
}

#[test]
fn unknown_flag_yields_error() {
    let e = build(ALWAYS_ON);
    let err = e.evaluate("missing", &Subject::new("u")).unwrap_err();
    assert!(matches!(
        err,
        feature_flag::FeatureFlagError::UnknownFlag(_)
    ));
}

#[test]
fn variant_or_falls_back_for_unknown_flag() {
    let e = build(ALWAYS_ON);
    let v = e.variant_or("missing", &Subject::new("u"), "off");
    assert_eq!(v, "off");
}

#[test]
fn rollout_distributes_buckets_sticky() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [{
          "id": "rollout",
          "when": {"kind": "always"},
          "outcome": {
            "kind": "rollout",
            "variants": [
              {"variant": "on", "weight": 50},
              {"variant": "off", "weight": 50}
            ]
          }
        }]
      }]
    }"#;
    let e = build(set);
    // Same subject -> same variant on every call.
    let s = Subject::new("user-42");
    let first = e.evaluate("f", &s).unwrap().variant;
    for _ in 0..50 {
        assert_eq!(e.evaluate("f", &s).unwrap().variant, first);
    }
}

#[test]
fn rollout_distributes_roughly_evenly_over_many_subjects() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["a", "b", "c"],
        "default_variant": "a",
        "rules": [{
          "id": "rollout",
          "when": {"kind": "always"},
          "outcome": {
            "kind": "rollout",
            "variants": [
              {"variant": "a", "weight": 33},
              {"variant": "b", "weight": 33},
              {"variant": "c", "weight": 34}
            ]
          }
        }]
      }]
    }"#;
    let e = build(set);
    let mut a = 0;
    let mut b = 0;
    let mut c = 0;
    for i in 0..10_000 {
        let v = e
            .evaluate("f", &Subject::new(format!("u-{i}")))
            .unwrap()
            .variant;
        match v.as_str() {
            "a" => a += 1,
            "b" => b += 1,
            "c" => c += 1,
            other => panic!("unexpected variant {other}"),
        }
    }
    // Each should be ~3300; allow ±400 (≈ 12% tolerance) to keep flake-free.
    for (label, n) in [("a", a), ("b", b), ("c", c)] {
        assert!(
            (2900..=3700).contains(&n),
            "variant {label} got {n} subjects — distribution looks off"
        );
    }
}

#[test]
fn predicate_eq_attribute() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [{
          "id": "plan-pro",
          "when": {"kind": "compare", "attr": "plan", "op": "eq", "value": "pro"},
          "outcome": {"kind": "variant", "variant": "on"}
        }]
      }]
    }"#;
    let e = build(set);
    assert_eq!(
        e.evaluate("f", &Subject::new("u").with_attr("plan", "pro"))
            .unwrap()
            .variant,
        "on"
    );
    assert_eq!(
        e.evaluate("f", &Subject::new("u").with_attr("plan", "free"))
            .unwrap()
            .variant,
        "off"
    );
}

#[test]
fn predicate_all_of_and_in() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [{
          "id": "pro-in-us-or-ca",
          "when": {
            "kind": "all_of",
            "matchers": [
              {"kind": "compare", "attr": "plan", "op": "eq", "value": "pro"},
              {"kind": "compare", "attr": "country", "op": "in",
               "value": ["US", "CA"]}
            ]
          },
          "outcome": {"kind": "variant", "variant": "on"}
        }]
      }]
    }"#;
    let e = build(set);
    let sub = |c: &str| {
        Subject::new("u")
            .with_attr("plan", "pro")
            .with_attr("country", c)
    };
    assert_eq!(e.evaluate("f", &sub("US")).unwrap().variant, "on");
    assert_eq!(e.evaluate("f", &sub("CA")).unwrap().variant, "on");
    assert_eq!(e.evaluate("f", &sub("DE")).unwrap().variant, "off");
}

#[test]
fn predicate_numeric_comparison() {
    let set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": [{
          "id": "vip",
          "when": {"kind": "compare", "attr": "ltv", "op": "gte", "value": 1000},
          "outcome": {"kind": "variant", "variant": "on"}
        }]
      }]
    }"#;
    let e = build(set);
    let s = Subject::new("u").with_attr("ltv", json!(1500));
    assert_eq!(e.evaluate("f", &s).unwrap().variant, "on");
    let s = Subject::new("u").with_attr("ltv", json!(500));
    assert_eq!(e.evaluate("f", &s).unwrap().variant, "off");
}

#[test]
fn swap_replaces_loaded_flagset() {
    let e = build(ALWAYS_ON);
    assert_eq!(e.evaluate("f", &Subject::new("u")).unwrap().variant, "on");

    let off_set = r#"{
      "flags": [{
        "id": "f",
        "variants": ["on", "off"],
        "default_variant": "off",
        "rules": []
      }]
    }"#;
    e.swap(FlagSet::from_json(off_set).unwrap());
    assert_eq!(e.evaluate("f", &Subject::new("u")).unwrap().variant, "off");
}