use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use crate::error::Result;
use crate::murmur::murmurhash3;
use crate::transport::{RequestOptions, Transport};
use crate::types::{
FeatureFlagDefinition, FlagEvalOptions, FlagValue, Operator, Properties, TargetingRule,
};
const FLAG_DEFINITIONS_TTL: Duration = Duration::from_secs(5 * 60);
struct Cache {
definitions: HashMap<String, FeatureFlagDefinition>,
fetched_at: Option<Instant>,
}
pub struct Flags {
transport: Transport,
api_key: String,
local_evaluation: bool,
enable_logging: bool,
cache: Mutex<Cache>,
}
impl Flags {
pub fn new(
transport: Transport,
api_key: String,
local_evaluation: bool,
enable_logging: bool,
) -> Self {
Self {
transport,
api_key,
local_evaluation,
enable_logging,
cache: Mutex::new(Cache {
definitions: HashMap::new(),
fetched_at: None,
}),
}
}
fn log(&self, msg: &str) {
if self.enable_logging {
eprintln!("[BilldogEng:flags] {msg}");
}
}
pub fn get_feature_flag(
&self,
key: &str,
distinct_id: &str,
opts: &FlagEvalOptions,
) -> Result<Option<FlagValue>> {
if self.local_evaluation {
self.ensure_definitions()?;
let cache = self.cache.lock().unwrap();
if !cache.definitions.contains_key(key) {
return Ok(None);
}
let def = cache.definitions.get(key).cloned();
drop(cache);
return Ok(Some(evaluate_def(
def.as_ref(),
key,
distinct_id,
opts.person_properties.as_ref(),
)));
}
let map = self.fetch_remote(distinct_id, opts.person_properties.as_ref())?;
match map.get(key) {
None => Ok(None),
Some(Value::Bool(b)) => Ok(Some(FlagValue::Bool(*b))),
Some(Value::String(s)) => Ok(Some(FlagValue::Variant(s.clone()))),
Some(_) => Ok(Some(FlagValue::Bool(true))),
}
}
pub fn is_feature_enabled(
&self,
key: &str,
distinct_id: &str,
opts: &FlagEvalOptions,
) -> Result<bool> {
Ok(self
.get_feature_flag(key, distinct_id, opts)?
.map(|v| v.is_enabled())
.unwrap_or(false))
}
pub fn get_feature_flag_payload(
&self,
key: &str,
distinct_id: &str,
opts: &FlagEvalOptions,
) -> Result<Option<Value>> {
self.ensure_definitions()?;
let cache = self.cache.lock().unwrap();
let def = match cache.definitions.get(key) {
Some(d) => d.clone(),
None => return Ok(None),
};
drop(cache);
let verdict = evaluate_def(Some(&def), key, distinct_id, opts.person_properties.as_ref());
match verdict {
FlagValue::Bool(false) => Ok(None),
FlagValue::Variant(ref vkey) => {
if let Some(variants) = &def.variants {
if let Some(v) = variants.iter().find(|v| &v.key == vkey) {
if let Some(p) = &v.payload {
return Ok(Some(p.clone()));
}
}
}
Ok(def.payload.clone())
}
FlagValue::Bool(true) => Ok(def.payload.clone()),
}
}
pub fn get_all_flags(
&self,
distinct_id: &str,
opts: &FlagEvalOptions,
) -> Result<HashMap<String, FlagValue>> {
if self.local_evaluation {
self.ensure_definitions()?;
let cache = self.cache.lock().unwrap();
let defs: Vec<(String, FeatureFlagDefinition)> = cache
.definitions
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
drop(cache);
let mut out = HashMap::new();
for (key, def) in defs {
out.insert(
key.clone(),
evaluate_def(Some(&def), &key, distinct_id, opts.person_properties.as_ref()),
);
}
return Ok(out);
}
let map = self.fetch_remote(distinct_id, opts.person_properties.as_ref())?;
let mut out = HashMap::new();
for (k, v) in map {
let fv = match v {
Value::Bool(b) => FlagValue::Bool(b),
Value::String(s) => FlagValue::Variant(s),
_ => FlagValue::Bool(true),
};
out.insert(k, fv);
}
Ok(out)
}
pub fn reload_feature_flag_definitions(&self) -> Result<()> {
self.fetch_definitions()
}
pub fn set_definitions(&self, defs: Vec<FeatureFlagDefinition>) {
let mut cache = self.cache.lock().unwrap();
cache.definitions = defs.into_iter().map(|d| (d.key.clone(), d)).collect();
cache.fetched_at = Some(Instant::now());
}
fn ensure_definitions(&self) -> Result<()> {
{
let cache = self.cache.lock().unwrap();
let fresh = !cache.definitions.is_empty()
&& cache
.fetched_at
.map(|t| t.elapsed() < FLAG_DEFINITIONS_TTL)
.unwrap_or(false);
if fresh {
return Ok(());
}
}
self.fetch_definitions()
}
fn fetch_definitions(&self) -> Result<()> {
let opts = RequestOptions::post(
"/feature-flag-definitions",
json!({ "api_key": self.api_key }),
)
.header("x-api-key", &self.api_key)
.gzip(false);
match self.transport.request(&opts) {
Ok(data) => {
let flags: Vec<FeatureFlagDefinition> = data
.get("flags")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
let mut cache = self.cache.lock().unwrap();
cache.definitions = flags.into_iter().map(|d| (d.key.clone(), d)).collect();
cache.fetched_at = Some(Instant::now());
self.log(&format!(
"loaded {} flag definitions",
cache.definitions.len()
));
Ok(())
}
Err(e) => {
self.log(&format!("failed to load flag definitions: {}", e.message));
Ok(())
}
}
}
fn fetch_remote(
&self,
distinct_id: &str,
attributes: Option<&Properties>,
) -> Result<HashMap<String, Value>> {
let attrs = attributes.cloned().unwrap_or_default();
let opts = RequestOptions::post(
"/experiment-config",
json!({
"api_key": self.api_key,
"user_id": distinct_id,
"attributes": attrs,
}),
)
.header("x-api-key", &self.api_key)
.gzip(false);
let data = self.transport.request(&opts)?;
let map = data
.get("feature_flags")
.and_then(|v| v.as_object())
.map(|o| o.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
Ok(map)
}
}
pub fn evaluate_def(
def: Option<&FeatureFlagDefinition>,
key: &str,
distinct_id: &str,
attributes: Option<&Properties>,
) -> FlagValue {
let def = match def {
Some(d) if d.active => d,
_ => return FlagValue::Bool(false),
};
if let Some(rules) = &def.targeting_rules {
let empty = Properties::new();
let attrs = attributes.unwrap_or(&empty);
for rule in rules {
if !matches_rule(rule, attrs.get(&rule.attribute)) {
return FlagValue::Bool(false);
}
}
}
let bucket = murmurhash3(&format!("{key}.{distinct_id}")) % 100;
if bucket >= def.rollout_percentage {
return FlagValue::Bool(false);
}
if let Some(variants) = &def.variants {
if !variants.is_empty() {
let mut cumulative = 0u32;
for variant in variants {
cumulative += variant.rollout_percentage;
if bucket < cumulative {
return FlagValue::Variant(variant.key.clone());
}
}
return FlagValue::Bool(true);
}
}
FlagValue::Bool(true)
}
fn to_comparable(v: &Value) -> Option<String> {
match v {
Value::Null => None,
Value::String(s) => Some(s.clone()),
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
other => Some(other.to_string()),
}
}
fn expected_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Null => "null".to_string(),
other => other.to_string(),
}
}
fn matches_rule(rule: &TargetingRule, value: Option<&Value>) -> bool {
let actual = value.unwrap_or(&Value::Null);
let actual_string = to_comparable(actual);
let expected = &rule.value;
match rule.operator {
Operator::Exists => {
return matches!(&actual_string, Some(s) if !s.is_empty());
}
Operator::NotExists => {
return match &actual_string {
None => true,
Some(s) => s.is_empty(),
};
}
_ => {}
}
let actual_string = match actual_string {
Some(s) => s,
None => return false,
};
match rule.operator {
Operator::Is | Operator::Equals => actual_string == expected_string(expected),
Operator::IsNot | Operator::NotEquals => actual_string != expected_string(expected),
Operator::AnyOf => match expected.as_array() {
Some(arr) => arr.iter().any(|e| expected_string(e) == actual_string),
None => false,
},
Operator::NotAnyOf => match expected.as_array() {
Some(arr) => !arr.iter().any(|e| expected_string(e) == actual_string),
None => false,
},
Operator::Contains => actual_string.contains(&expected_string(expected)),
Operator::NotContains => !actual_string.contains(&expected_string(expected)),
Operator::GreaterThan | Operator::Gt => {
compare_ordered(&actual_string, expected, |o| o == std::cmp::Ordering::Greater)
}
Operator::LessThan | Operator::Lt => {
compare_ordered(&actual_string, expected, |o| o == std::cmp::Ordering::Less)
}
Operator::GreaterThanOrEqual | Operator::Gte => compare_ordered(&actual_string, expected, |o| {
o == std::cmp::Ordering::Greater || o == std::cmp::Ordering::Equal
}),
Operator::LessThanOrEqual | Operator::Lte => compare_ordered(&actual_string, expected, |o| {
o == std::cmp::Ordering::Less || o == std::cmp::Ordering::Equal
}),
Operator::Exists | Operator::NotExists => false,
}
}
fn compare_ordered(actual_string: &str, expected: &Value, pred: impl Fn(std::cmp::Ordering) -> bool) -> bool {
let expected_string = expected_string(expected);
if let (Some(a), Some(b)) = (try_parse_date(actual_string), try_parse_date(&expected_string)) {
return pred(a.cmp(&b));
}
let a = actual_string.parse::<f64>().unwrap_or(f64::NAN);
let b = expected_string.parse::<f64>().unwrap_or(f64::NAN);
match a.partial_cmp(&b) {
Some(ord) => pred(ord),
None => false, }
}
fn try_parse_date(s: &str) -> Option<i64> {
let b = s.as_bytes();
if b.len() < 10 {
return None;
}
let is_d = |i: usize| b[i].is_ascii_digit();
if !(is_d(0) && is_d(1) && is_d(2) && is_d(3) && b[4] == b'-' && is_d(5) && is_d(6) && b[7] == b'-' && is_d(8) && is_d(9))
{
return None;
}
let year: i64 = s[0..4].parse().ok()?;
let month: i64 = s[5..7].parse().ok()?;
let day: i64 = s[8..10].parse().ok()?;
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
return None;
}
let mut hour: i64 = 0;
let mut min: i64 = 0;
let mut sec: i64 = 0;
let mut millis: i64 = 0;
let mut tz_offset_min: i64 = 0;
let rest = &s[10..];
if !rest.is_empty() {
let rb = rest.as_bytes();
if rb[0] != b'T' && rb[0] != b' ' {
return None;
}
let time = &rest[1..];
let (clock, tz) = split_timezone(time);
let parts: Vec<&str> = clock.split(':').collect();
if parts.len() < 2 {
return None;
}
hour = parts[0].parse().ok()?;
min = parts[1].parse().ok()?;
if parts.len() >= 3 {
let secpart = parts[2];
if let Some(dot) = secpart.find('.') {
sec = secpart[..dot].parse().ok()?;
let frac = &secpart[dot + 1..];
let frac3: String = frac.chars().take(3).chain(std::iter::repeat('0')).take(3).collect();
millis = frac3.parse().ok()?;
} else {
sec = secpart.parse().ok()?;
}
}
if let Some(off) = tz {
tz_offset_min = off?;
}
}
let days = days_from_civil(year, month, day);
let total_secs = days * 86_400 + hour * 3600 + min * 60 + sec - tz_offset_min * 60;
Some(total_secs * 1000 + millis)
}
fn split_timezone(time: &str) -> (&str, Option<Option<i64>>) {
if let Some(stripped) = time.strip_suffix('Z') {
return (stripped, Some(Some(0)));
}
for (i, c) in time.char_indices() {
if (c == '+' || c == '-') && i > 0 {
let clock = &time[..i];
let sign = if c == '-' { -1 } else { 1 };
let off = &time[i + 1..];
let parsed = parse_offset(off).map(|m| sign * m);
return (clock, Some(parsed));
}
}
(time, None)
}
fn parse_offset(off: &str) -> Option<i64> {
let parts: Vec<&str> = off.split(':').collect();
let h: i64 = parts.first()?.parse().ok()?;
let m: i64 = parts.get(1).map(|s| s.parse().ok()).unwrap_or(Some(0))?;
Some(h * 60 + m)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::matches_rule;
use crate::types::{Operator, TargetingRule};
use serde_json::{json, Value};
fn rule(op: Operator, expected: Value) -> TargetingRule {
TargetingRule {
attribute: "attr".to_string(),
operator: op,
value: expected,
}
}
#[test]
fn canonical_operator_parity_vectors() {
let s = |v: &str| Value::String(v.to_string());
let cases: Vec<(Operator, Option<Value>, Value, bool)> = vec![
(Operator::Exists, Some(s("x")), Value::Null, true),
(Operator::Exists, Some(s("")), Value::Null, false),
(Operator::NotExists, None, Value::Null, true),
(Operator::Equals, Some(s("5")), s("5"), true),
(Operator::Is, Some(s("5")), s("5"), true),
(Operator::NotEquals, Some(s("5")), s("6"), true),
(Operator::IsNot, Some(s("a")), s("a"), false),
(Operator::AnyOf, Some(s("b")), json!(["a", "b", "c"]), true),
(Operator::AnyOf, Some(s("z")), json!(["a", "b", "c"]), false),
(Operator::NotAnyOf, Some(s("z")), json!(["a", "b", "c"]), true),
(Operator::Contains, Some(s("hello")), s("ell"), true),
(Operator::NotContains, Some(s("hello")), s("xyz"), true),
(Operator::GreaterThan, Some(s("10")), s("5"), true),
(Operator::Gt, Some(s("3")), s("5"), false),
(Operator::LessThan, Some(s("3")), s("5"), true),
(Operator::Gte, Some(s("5")), s("5"), true),
(Operator::Lte, Some(s("5")), s("5"), true),
(Operator::GreaterThan, Some(s("2026-02-01")), s("2026-01-01"), true),
(Operator::LessThan, Some(s("2026-01-01")), s("2026-02-01"), true),
(Operator::Gte, Some(s("2026-01-01")), s("2026-01-01"), true),
];
for (i, (op, actual, expected, want)) in cases.into_iter().enumerate() {
let r = rule(op.clone(), expected.clone());
let got = matches_rule(&r, actual.as_ref());
assert_eq!(
got, want,
"vector #{i}: op={op:?} actual={actual:?} expected={expected:?} => got {got}, want {want}"
);
}
}
#[test]
fn date_compare_with_time_and_zone() {
let s = |v: &str| Value::String(v.to_string());
let r = rule(Operator::GreaterThan, s("2026-01-01T00:00:00+01:00"));
assert!(matches_rule(&r, Some(&s("2026-01-01T00:00:00Z")))); let r = rule(Operator::LessThanOrEqual, s("2026-01-01T12:00:00.500Z"));
assert!(matches_rule(&r, Some(&s("2026-01-01T12:00:00.499Z"))));
}
#[test]
fn plain_number_not_treated_as_date() {
let s = |v: &str| Value::String(v.to_string());
let r = rule(Operator::GreaterThan, s("5"));
assert!(matches_rule(&r, Some(&s("10"))));
}
}