use chrono::{DateTime, NaiveDate, NaiveDateTime};
use num::bigint::{BigInt, Sign};
use std::cell::{Cell, OnceCell};
use std::collections::HashMap;
use serde_json::Value;
use sha1::{Digest, Sha1};
const FEATURE_BUCKETING_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(2026, 10, 5).unwrap();
fn parse_created_at(raw: &str) -> Option<NaiveDate> {
if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
return Some(date);
}
if let Ok(datetime) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
return Some(datetime.date());
}
DateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f%z")
.ok()
.map(|datetime| datetime.naive_utc().date())
}
fn created_after_epoch(created_at: Option<&str>) -> bool {
created_at
.and_then(parse_created_at)
.is_some_and(|created_at| created_at > FEATURE_BUCKETING_EPOCH)
}
fn value_to_id_string(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.to_string()
} else if let Some(f) = n.as_f64() {
if f.fract() == 0.0 {
format!("{f:.1}")
} else {
f.to_string()
}
} else {
n.to_string()
}
}
Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
format!("[{}]", items.join(", "))
}
Value::Null => "None".to_string(),
Value::Object(_) => value.to_string(),
}
}
pub struct FeatureContext {
data: HashMap<String, Value>,
identity_fields: Vec<String>,
cached_identity: OnceCell<String>,
cached_id: Cell<Option<u64>>,
}
impl FeatureContext {
pub fn new() -> Self {
Self {
data: HashMap::new(),
identity_fields: Vec::new(),
cached_identity: OnceCell::new(),
cached_id: Cell::new(None),
}
}
pub fn identity_fields(&mut self, fields: Vec<&str>) {
self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
self.cached_id.set(None);
self.cached_identity.take();
}
pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
self.data.insert(key.to_string(), value.into());
self.cached_id.set(None);
self.cached_identity.take();
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn has(&self, key: &str) -> bool {
self.data.contains_key(key)
}
pub fn id(&self) -> u64 {
if let Some(id) = self.cached_id.get() {
return id;
}
let id = self.compute_id(None);
self.cached_id.set(Some(id));
id
}
pub fn bucket_id(&self, feature_name: Option<&str>) -> u64 {
match feature_name {
None => self.id(),
Some(name) => self.compute_id(Some(name)),
}
}
fn compute_identity(&self) -> String {
let mut identity_fields: Vec<&String> = self
.identity_fields
.iter()
.filter(|f| self.data.contains_key(f.as_str()))
.collect();
if identity_fields.is_empty() {
identity_fields = self.data.keys().collect();
}
identity_fields.sort();
let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
for key in identity_fields {
parts.push(key.clone());
parts.push(value_to_id_string(&self.data[key.as_str()]));
}
parts.join(":")
}
fn compute_id(&self, feature_name: Option<&str>) -> u64 {
let identity = self.cached_identity.get_or_init(|| self.compute_identity());
let mut hasher = Sha1::new();
if let Some(name) = feature_name {
hasher.update(name.as_bytes());
hasher.update(b":");
}
hasher.update(identity.as_bytes());
let digest = hasher.finalize();
let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
let small: BigInt = bigint % 1000000000;
let id_parts = small.to_u64_digits().1;
if id_parts.is_empty() { 0 } else { id_parts[0] }
}
}
impl Default for FeatureContext {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
enum OperatorKind {
In,
NotIn,
Contains,
NotContains,
Equals,
NotEquals,
Matches,
NotMatches,
}
#[derive(Debug)]
struct Condition {
property: String,
operator: OperatorKind,
value: Value,
}
#[derive(Debug)]
struct Segment {
rollout: u64,
conditions: Vec<Condition>,
}
#[derive(Debug)]
struct Feature {
name: String,
enabled: bool,
segments: Vec<Segment>,
buckets_by_feature: bool,
}
impl Feature {
fn from_json(name: &str, value: &Value) -> Option<Self> {
let enabled = value
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let segments = value
.get("segments")?
.as_array()?
.iter()
.filter_map(Segment::from_json)
.collect();
Some(Feature {
name: name.to_owned(),
enabled,
segments,
buckets_by_feature: created_after_epoch(
value.get("created_at").and_then(Value::as_str),
),
})
}
fn matches(&self, context: &FeatureContext) -> bool {
if !self.enabled {
return false;
}
let feature_name = self.buckets_by_feature.then_some(self.name.as_str());
for segment in &self.segments {
if segment.conditions_match(context) {
return segment.in_rollout(context, feature_name);
}
}
false
}
}
impl Segment {
fn from_json(value: &Value) -> Option<Self> {
let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
let conditions = value
.get("conditions")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(Condition::from_json).collect())
.unwrap_or_default();
Some(Segment {
rollout,
conditions,
})
}
fn conditions_match(&self, context: &FeatureContext) -> bool {
self.conditions.iter().all(|c| c.matches(context))
}
fn in_rollout(&self, context: &FeatureContext, feature_name: Option<&str>) -> bool {
if self.rollout == 0 {
return false;
}
if self.rollout >= 100 {
return true;
}
context.bucket_id(feature_name) % 100 <= self.rollout
}
}
impl Condition {
fn from_json(value: &Value) -> Option<Self> {
let property = value.get("property")?.as_str()?.to_string();
let operator = match value.get("operator")?.as_str()? {
"in" => OperatorKind::In,
"not_in" => OperatorKind::NotIn,
"contains" => OperatorKind::Contains,
"not_contains" => OperatorKind::NotContains,
"equals" => OperatorKind::Equals,
"not_equals" => OperatorKind::NotEquals,
"matches" => OperatorKind::Matches,
"not_matches" => OperatorKind::NotMatches,
_ => return None,
};
let value = value.get("value")?.clone();
Some(Condition {
property,
operator,
value,
})
}
fn matches(&self, context: &FeatureContext) -> bool {
let ctx_val = context.get(&self.property);
let result = match &self.operator {
OperatorKind::In => eval_in(ctx_val, &self.value),
OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
OperatorKind::Contains => eval_contains(ctx_val, &self.value),
OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
OperatorKind::Equals => eval_equals(ctx_val, &self.value),
OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
OperatorKind::Matches => eval_matches(ctx_val, &self.value),
OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
};
result.unwrap_or(false)
}
}
fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
let arr = condition_val.as_array()?;
match ctx_val {
None => Some(false),
Some(Value::Object(_)) => None,
Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
Some(scalar) => Some(scalar_in(scalar, arr)),
}
}
fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
match ctx_val {
Value::String(s) => {
let s_lower = s.to_lowercase();
arr.iter()
.any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
}
Value::Number(n) => arr
.iter()
.any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
_ => false,
}
}
fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
if !a.is_f64() && !b.is_f64() {
return a == b;
}
matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
}
fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
let ctx_arr = ctx_val?.as_array()?;
match condition_val {
Value::String(s) => {
let s_lower = s.to_lowercase();
Some(
ctx_arr
.iter()
.any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
)
}
Value::Number(n) => Some(
ctx_arr
.iter()
.any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
),
Value::Bool(b) => Some(
ctx_arr
.iter()
.any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
),
_ => None,
}
}
fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
Some(match ctx_val {
Some(ctx_val) => values_equal(ctx_val, condition_val),
None => false,
})
}
fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
match (ctx_val, condition_val) {
(Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
(Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
}
_ => false,
}
}
fn glob_star_match(pattern: &str, value: &str) -> bool {
let pattern = pattern.to_lowercase();
let value = value.to_lowercase();
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return value == pattern;
}
if !value.starts_with(parts[0]) {
return false;
}
if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
return false;
}
let end = if parts[parts.len() - 1].is_empty() {
value.len()
} else {
value.len() - parts[parts.len() - 1].len()
};
let mut start = parts[0].len();
if start > end {
return false;
}
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
match value[start..end].find(*part) {
Some(idx) => start += idx + part.len(),
None => return false,
}
}
true
}
fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
let arr = condition_val.as_array()?;
match ctx_val {
Some(Value::Array(_) | Value::Object(_)) => None,
None => Some(false),
Some(scalar) => {
let Some(s) = scalar.as_str() else {
return Some(false);
};
Some(arr.iter().any(|v| {
v.as_str()
.is_some_and(|pattern| glob_star_match(pattern, s))
}))
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum FeatureError {
#[error("Options not initialized - call init() first")]
NotInitialized,
#[error("Value for '{key}' is not a valid feature")]
InvalidValue { key: String },
#[error(transparent)]
Options(#[from] crate::OptionsError),
}
pub struct FeatureChecker {
namespace: String,
options: Option<&'static crate::Options>,
}
impl FeatureChecker {
pub fn new(namespace: String, options: &'static crate::Options) -> Self {
Self {
namespace,
options: Some(options),
}
}
pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
match self.try_has(feature_name, context) {
Ok(Some(result)) => result,
Ok(None) => false,
Err(e) => {
tracing::debug!(feature = feature_name, error = %e, "Feature evaluation failed");
false
}
}
}
pub fn try_has(
&self,
feature_name: &str,
context: &FeatureContext,
) -> Result<Option<bool>, FeatureError> {
let opts = self.options.ok_or(FeatureError::NotInitialized)?;
let key = format!("feature.{feature_name}");
let feature_val = match opts.get(&self.namespace, &key) {
Ok(v) => v,
Err(crate::OptionsError::UnknownOption { .. }) => return Ok(None),
Err(e) => return Err(e.into()),
};
let feature = Feature::from_json(feature_name, &feature_val)
.ok_or_else(|| FeatureError::InvalidValue { key: key.clone() })?;
let result = feature.matches(context);
tracing::debug!(
feature = feature_name,
result,
bucket_id = context.bucket_id(feature.buckets_by_feature.then_some(feature_name)),
"Feature match result"
);
Ok(Some(result))
}
}
pub fn features(namespace: &str) -> FeatureChecker {
FeatureChecker {
namespace: namespace.to_string(),
options: crate::GLOBAL_OPTIONS.get(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Options;
use serde_json::json;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn create_schema(dir: &Path, namespace: &str, schema: &str) {
let schema_dir = dir.join(namespace);
fs::create_dir_all(&schema_dir).unwrap();
fs::write(schema_dir.join("schema.json"), schema).unwrap();
}
fn create_values(dir: &Path, namespace: &str, values: &str) {
let ns_dir = dir.join(namespace);
fs::create_dir_all(&ns_dir).unwrap();
fs::write(ns_dir.join("values.json"), values).unwrap();
}
const FEATURE_SCHEMA: &str = r##"{
"version": "1.0",
"type": "object",
"properties": {
"feature.organizations:test-feature": {
"$ref": "#/definitions/Feature"
}
}
}"##;
fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
let temp = TempDir::new().unwrap();
let schemas = temp.path().join("schemas");
fs::create_dir_all(&schemas).unwrap();
create_schema(&schemas, "test", FEATURE_SCHEMA);
let values = temp.path().join("values");
let values_json = format!(
r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
feature_json
);
create_values(&values, "test", &values_json);
let opts = Options::from_directory(temp.path()).unwrap();
(opts, temp)
}
fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
format!(
r#"{{
"name": "test-feature",
"enabled": {enabled},
"owner": {{"team": "test-team"}},
"created_at": "2024-01-01",
"segments": [{{
"name": "test-segment",
"rollout": {rollout},
"conditions": [{conditions}]
}}]
}}"#
)
}
fn in_condition(property: &str, values: &str) -> String {
format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
}
fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
let key = format!("feature.{feature}");
let Ok(val) = opts.get("test", &key) else {
return false;
};
Feature::from_json(feature, &val).is_some_and(|parsed_feature| parsed_feature.matches(ctx))
}
#[test]
fn test_feature_context_insert_and_get() {
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
ctx.insert("name", json!("sentry"));
ctx.insert("active", json!(true));
assert!(ctx.has("org_id"));
assert!(!ctx.has("missing"));
assert_eq!(ctx.get("org_id"), Some(&json!(123)));
assert_eq!(ctx.get("name"), Some(&json!("sentry")));
}
#[test]
fn test_feature_context_id_is_cached() {
let mut ctx = FeatureContext::new();
ctx.identity_fields(vec!["user_id"]);
ctx.insert("user_id", json!(42));
let id1 = ctx.id();
let id2 = ctx.id();
assert_eq!(id1, id2, "ID should be cached and consistent");
}
#[test]
fn test_feature_context_id_resets_on_identity_change() {
let mut ctx = FeatureContext::new();
ctx.insert("user_id", json!(1));
ctx.insert("org_id", json!(2));
ctx.identity_fields(vec!["user_id"]);
let id_user = ctx.id();
ctx.identity_fields(vec!["org_id"]);
let id_org = ctx.id();
assert_ne!(
id_user, id_org,
"Different identity fields should produce different IDs"
);
}
#[test]
fn test_bucket_id_matches_bigint_reference() {
use num::ToPrimitive;
use num::bigint::{BigInt, Sign};
let cases = [
(0, "organizations:a"),
(123, "organizations:performance-view"),
(456, "organizations:performance-view"),
(123, "organizations:dashboards-edit"),
(
10_000,
"organizations:a-feature-name-long-enough-to-span-multiple-sha1-blocks",
),
];
for (organization_id, feature_name) in cases {
let mut context = FeatureContext::new();
context.insert("organization_id", json!(organization_id));
context.identity_fields(vec!["organization_id"]);
let input = format!("{feature_name}:organization_id:{organization_id}");
let digest = Sha1::digest(input.as_bytes());
let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
let expected: BigInt = bigint % 1_000_000_000;
assert_eq!(
context.bucket_id(Some(feature_name)),
expected.to_u64().unwrap(),
"Hash mismatch for {input}"
);
}
}
#[test]
fn test_bucket_id_resets_on_context_changes() {
for feature_name in [None, Some("organizations:test-feature")] {
let mut context = FeatureContext::new();
context.insert("organization_id", json!(123));
context.insert("user_id", json!(42));
context.identity_fields(vec!["organization_id"]);
let original_id = context.bucket_id(feature_name);
context.insert("organization_id", json!(456));
let updated_id = context.bucket_id(feature_name);
assert_ne!(original_id, updated_id);
context.identity_fields(vec!["user_id"]);
assert_ne!(updated_id, context.bucket_id(feature_name));
}
}
#[test]
fn test_feature_context_id_deterministic() {
let make_ctx = || {
let mut ctx = FeatureContext::new();
ctx.identity_fields(vec!["user_id", "org_id"]);
ctx.insert("user_id", json!(456));
ctx.insert("org_id", json!(123));
ctx
};
assert_eq!(make_ctx().id(), make_ctx().id());
let mut other_ctx = FeatureContext::new();
other_ctx.identity_fields(vec!["user_id", "org_id"]);
other_ctx.insert("user_id", json!(789));
other_ctx.insert("org_id", json!(123));
assert_ne!(make_ctx().id(), other_ctx.id());
}
#[test]
fn test_feature_context_id_value_align_with_python() {
let ctx = FeatureContext::new();
assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
let mut ctx = FeatureContext::new();
ctx.insert("foo", json!("bar"));
ctx.insert("baz", json!("barfoo"));
ctx.identity_fields(vec!["foo"]);
assert_eq!(ctx.id() % 100, 62);
let mut ctx = FeatureContext::new();
ctx.insert("foo", json!("bar"));
ctx.insert("baz", json!("barfoo"));
ctx.identity_fields(vec!["foo", "whoops"]);
assert_eq!(ctx.id() % 100, 62);
let mut ctx = FeatureContext::new();
ctx.insert("foo", json!("bar"));
ctx.insert("baz", json!("barfoo"));
ctx.identity_fields(vec!["foo", "baz"]);
assert_eq!(ctx.id() % 100, 1);
let mut ctx = FeatureContext::new();
ctx.insert("foo", json!("bar"));
ctx.insert("baz", json!("barfoo"));
ctx.identity_fields(vec!["whoops", "nope"]);
assert_eq!(ctx.id() % 100, 1);
}
#[test]
fn test_feature_prefix_is_added() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_undefined_feature_returns_false() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let ctx = FeatureContext::new();
assert!(!check(&opts, "nonexistent", &ctx));
}
#[test]
fn test_missing_context_field_returns_false() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let ctx = FeatureContext::new();
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_matching_context_returns_true() {
let cond = in_condition("organization_id", "123, 456");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_non_matching_context_returns_false() {
let cond = in_condition("organization_id", "123, 456");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(999));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_disabled_feature_returns_false() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_rollout_zero_returns_false() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_rollout_100_returns_true() {
let cond = in_condition("organization_id", "123");
let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_rollout_is_deterministic() {
let mut ctx = FeatureContext::new();
ctx.identity_fields(vec!["user_id"]);
ctx.insert("user_id", json!(42));
ctx.insert("organization_id", json!(123));
let id_mod = (ctx.id() % 100) + 1;
let cond = in_condition("organization_id", "123");
let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
assert!(check(&opts_at, "organizations:test-feature", &ctx));
}
#[test]
fn test_bucket_id_pinned_values() {
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
ctx.insert("organization_slug", json!("sentry"));
ctx.identity_fields(vec!["organization_id"]);
assert_eq!(ctx.bucket_id(None), ctx.id());
assert_eq!(
ctx.bucket_id(Some("organizations:performance-view")) % 100,
64
);
assert_eq!(
ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
75
);
let ctx = FeatureContext::new();
assert_eq!(
ctx.bucket_id(Some("organizations:performance-view")) % 100,
85
);
}
#[test]
fn test_created_after_epoch() {
let cases = [
("2026-10-05", false),
("2026-10-05T00:00:00", false),
("2026-10-05T00:00:01", false),
("2026-10-05T00:00:00.000001", false),
("2026-10-05T23:59:59.999999999", false),
("2026-10-06", true),
("2026-10-06T00:00:00", true),
("2026-10-04T23:00:00-02:00", false),
("2026-10-05T23:00:00-02:00", true),
("2026-10-05T23:00:00-0200", true),
("2026-10-06T01:00:00+02:00", false),
("2026-10-06T01:00:00+0200", false),
("2024-01-01", false),
("None", false),
("not a date", false),
];
for (created_at, expected) in cases {
assert_eq!(
created_after_epoch(Some(created_at)),
expected,
"{created_at}"
);
}
assert!(!created_after_epoch(None));
}
#[test]
fn test_rollout_keeps_identity_bucketing_for_features_created_before_epoch() {
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
ctx.identity_fields(vec!["organization_id"]);
assert_eq!(ctx.id() % 100, 56);
let feature = |rollout: u64| {
let value = json!({
"created_at": "2024-01-01",
"segments": [{"name": "all", "rollout": rollout, "conditions": []}]
});
Feature::from_json("organizations:performance-view", &value)
.unwrap()
.matches(&ctx)
};
assert!(feature(56));
assert!(!feature(55));
}
#[test]
fn test_rollout_buckets_by_feature_for_features_created_after_epoch() {
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
ctx.identity_fields(vec!["organization_id"]);
assert_eq!(
ctx.bucket_id(Some("organizations:performance-view")) % 100,
64
);
assert_eq!(
ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
75
);
let feature = |name: &str, rollout: u64| {
let value = json!({
"created_at": "2026-12-01",
"segments": [{"name": "all", "rollout": rollout, "conditions": []}]
});
Feature::from_json(name, &value).unwrap().matches(&ctx)
};
assert!(feature("organizations:performance-view", 64));
assert!(!feature("organizations:performance-view", 63));
assert!(!feature("organizations:dashboards-edit", 64));
}
#[test]
fn test_first_matching_segment_decides_rollout() {
let mut context = FeatureContext::new();
context.insert("organization_id", json!(123));
context.identity_fields(vec!["organization_id"]);
for (rollout, expected) in [
(0, false),
(63, false),
(64, true),
(100, true),
(101, true),
] {
let value = json!({
"created_at": "2026-12-01",
"segments": [
{"name": "first", "rollout": rollout, "conditions": []},
{"name": "second", "rollout": 100, "conditions": []}
]
});
let feature = Feature::from_json("organizations:performance-view", &value).unwrap();
assert_eq!(feature.matches(&context), expected, "rollout {rollout}");
}
}
#[test]
fn test_condition_in_string_case_insensitive() {
let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("sentry"));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_not_in() {
let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("organization_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("organization_id", json!(999));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_in_list_property_matches_on_overlap() {
let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("plan_family", json!(["enterprise business", "Business"]));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("plan_family", json!(["team"]));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("plan_family", json!([]));
assert!(!check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_condition_not_in_list_property_requires_no_overlap() {
let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("plan_family", json!(["enterprise business", "business"]));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("plan_family", json!(["team"]));
assert!(check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_in_list_property_no_type_coercion() {
let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!([123]));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
assert!(check(&int_opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_in_matches_across_int_and_float() {
let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut list_ctx = FeatureContext::new();
list_ctx.insert("org_id", json!([123]));
assert!(check(&opts, "organizations:test-feature", &list_ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("org_id", json!(124));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_in_large_ints_compare_exactly() {
let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(9007199254740992i64));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("org_id", json!(9007199254740993i64));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
let mut ctx3 = FeatureContext::new();
ctx3.insert("org_id", json!(18446744073709551614u64));
assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
let mut ctx4 = FeatureContext::new();
ctx4.insert("org_id", json!(18446744073709551615u64));
assert!(check(&big_opts, "organizations:test-feature", &ctx4));
}
#[test]
fn test_condition_contains() {
let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("tags", json!(["alpha", "beta"]));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("tags", json!(["alpha"]));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_contains_matches_across_int_and_float() {
let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
let mut int_ctx = FeatureContext::new();
int_ctx.insert("ids", json!([123]));
let mut float_ctx = FeatureContext::new();
float_ctx.insert("ids", json!([123.0]));
assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
let mut other = FeatureContext::new();
other.insert("ids", json!([124]));
assert!(!check(&int_opts, "organizations:test-feature", &other));
}
#[test]
fn test_condition_equals() {
let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("plan", json!("Enterprise"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("plan", json!("free"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_equals_large_ints_compare_exactly() {
let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(18446744073709551614u64));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("org_id", json!(18446744073709551615u64));
assert!(check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_equals_bool() {
let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("is_free", json!(true));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("is_free", json!(false));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_segment_with_no_conditions_always_matches() {
let feature = r#"{
"name": "test-feature",
"enabled": true,
"owner": {"team": "test-team"},
"created_at": "2024-01-01",
"segments": [{"name": "open", "rollout": 100, "conditions": []}]
}"#;
let (opts, _t) = setup_feature_options(feature);
let ctx = FeatureContext::new();
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_feature_enabled_and_rollout_default_values() {
let feature = r#"{
"name": "test-feature",
"owner": {"team": "test-team"},
"created_at": "2024-01-01",
"segments": [
{
"name": "first",
"conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
}
]
}"#;
let (opts, _t) = setup_feature_options(feature);
let mut ctx = FeatureContext::new();
ctx.insert("org_id", 1);
ctx.identity_fields(vec!["org_id"]);
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_feature_with_no_segments_returns_false() {
let feature = r#"{
"name": "test-feature",
"enabled": true,
"owner": {"team": "test-team"},
"created_at": "2024-01-01",
"segments": []
}"#;
let (opts, _t) = setup_feature_options(feature);
let ctx = FeatureContext::new();
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_multiple_segments_or_logic() {
let feature = r#"{
"name": "test-feature",
"enabled": true,
"owner": {"team": "test-team"},
"created_at": "2024-01-01",
"segments": [
{
"name": "segment-a",
"rollout": 100,
"conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
},
{
"name": "segment-b",
"rollout": 100,
"conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
}
]
}"#;
let (opts, _t) = setup_feature_options(feature);
let mut ctx1 = FeatureContext::new();
ctx1.insert("org_id", json!(1));
assert!(check(&opts, "organizations:test-feature", &ctx1));
let mut ctx2 = FeatureContext::new();
ctx2.insert("org_id", json!(2));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("org_id", json!(3));
assert!(!check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_multiple_conditions_and_logic() {
let conds = r#"
{"property": "org_id", "operator": "in", "value": [123]},
{"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
ctx.insert("user_email", json!("admin@example.com"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("org_id", json!(123));
ctx2.insert("user_email", json!("other@example.com"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_in_int_context_against_string_list_returns_false() {
let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_in_string_context_against_int_list_returns_false() {
let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("123"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_in_bool_context_against_string_list_returns_false() {
let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("active", json!(true));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_in_float_context_against_string_list_returns_false() {
let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("score", json!(0.5));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_not_in_int_context_against_string_list_returns_true() {
let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_not_in_string_context_against_int_list_returns_true() {
let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("123"));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_matches_literal() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("sentry"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("getsentry"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_matches_prefix_wildcard() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("jayonb73"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("jayonb"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("slug", json!("dangoldonb1"));
assert!(!check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_condition_matches_prefix_and_suffix_wildcard() {
let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
assert!(!check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_condition_matches_suffix_wildcard() {
let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("email", json!("user@sentry.io"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("email", json!("user@example.com"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_matches_multi_segment_wildcard() {
let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("name", json!("abc"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("name", json!("aXbYc"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("name", json!("aXXbYYc"));
assert!(check(&opts, "organizations:test-feature", &ctx3));
let mut ctx4 = FeatureContext::new();
ctx4.insert("name", json!("aXXc"));
assert!(!check(&opts, "organizations:test-feature", &ctx4));
}
#[test]
fn test_condition_matches_star_only_pattern() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("anything"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!(""));
assert!(check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_matches_case_insensitive() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("jayonb73"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("JAYONB73"));
assert!(check(&opts2, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_matches_no_match() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("dangoldonb1"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_matches_multiple_patterns() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let slugs = [
("jayonb73", true),
("dangoldonb3", true),
("value-disc-7", true),
("other-org", false),
];
for (slug, expected) in slugs {
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!(slug));
assert_eq!(
check(&opts, "organizations:test-feature", &ctx),
expected,
"slug={slug}"
);
}
}
#[test]
fn test_condition_matches_overlapping_prefix_suffix_anchors() {
let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("a"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("aa"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("slug", json!("ab"));
assert!(!check(&opts2, "organizations:test-feature", &ctx3));
let mut ctx4 = FeatureContext::new();
ctx4.insert("slug", json!("abab"));
assert!(check(&opts2, "organizations:test-feature", &ctx4));
}
#[test]
fn test_condition_matches_non_string_context_returns_false() {
let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
assert!(!check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_not_matches_literal() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("sentry"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("getsentry"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_not_matches_prefix_wildcard() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("jayonb73"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("jayonb"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("slug", json!("dangoldonb1"));
assert!(check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_condition_not_matches_prefix_and_suffix_wildcard() {
let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
assert!(check(&opts, "organizations:test-feature", &ctx3));
}
#[test]
fn test_condition_not_matches_suffix_wildcard() {
let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("email", json!("user@sentry.io"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("email", json!("user@example.com"));
assert!(check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_not_matches_multi_segment_wildcard() {
let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("name", json!("abc"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("name", json!("aXbYc"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
let mut ctx3 = FeatureContext::new();
ctx3.insert("name", json!("aXXbYYc"));
assert!(!check(&opts, "organizations:test-feature", &ctx3));
let mut ctx4 = FeatureContext::new();
ctx4.insert("name", json!("aXXc"));
assert!(check(&opts, "organizations:test-feature", &ctx4));
}
#[test]
fn test_condition_not_matches_star_only_pattern() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("anything"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!(""));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_not_matches_case_insensitive() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("jayonb73"));
assert!(!check(&opts, "organizations:test-feature", &ctx));
let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("JAYONB73"));
assert!(!check(&opts2, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_not_matches_no_match() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("dangoldonb1"));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_not_matches_multiple_patterns() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
for slug in slugs_false {
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!(slug));
assert!(
!check(&opts, "organizations:test-feature", &ctx),
"slug={slug} should not match"
);
}
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("other-org"));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("slug", json!("a"));
assert!(check(&opts, "organizations:test-feature", &ctx));
let mut ctx2 = FeatureContext::new();
ctx2.insert("slug", json!("aa"));
assert!(!check(&opts, "organizations:test-feature", &ctx2));
}
#[test]
fn test_condition_not_matches_non_string_context_returns_true() {
let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
let mut ctx = FeatureContext::new();
ctx.insert("org_id", json!(123));
assert!(check(&opts, "organizations:test-feature", &ctx));
}
#[test]
fn test_id_string_is_stable() {
for (value, expected) in [
(json!("sentry"), "sentry"),
(json!(123), "123"),
(json!(-7), "-7"),
(json!(true), "True"),
(json!(false), "False"),
(json!(null), "None"),
(json!(1.0), "1.0"),
(json!(1.5), "1.5"),
(json!(["a", "b"]), "[a, b]"),
(json!([1, 2]), "[1, 2]"),
(json!([]), "[]"),
] {
assert_eq!(value_to_id_string(&value), expected, "for {value}");
}
}
fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
Condition::from_json(&serde_json::from_str(&json).unwrap())
.expect("condition should parse")
.matches(ctx)
}
#[test]
fn test_missing_property_matches_only_negated_conditions() {
let ctx = FeatureContext::new();
for (operator, value, expected) in [
("in", "[1]", false),
("not_in", "[1]", true),
("equals", "1", false),
("not_equals", "1", true),
("matches", r#"["a"]"#, false),
("not_matches", r#"["a"]"#, true),
("contains", r#""a""#, false),
("not_contains", r#""a""#, false),
] {
assert_eq!(
condition_matches(operator, value, &ctx),
expected,
"{operator}"
);
}
}
#[test]
fn test_unusable_property_type_matches_neither_form() {
let mut map = FeatureContext::new();
map.insert("prop", json!({"k": "v"}));
let mut scalar = FeatureContext::new();
scalar.insert("prop", json!("a"));
let mut list = FeatureContext::new();
list.insert("prop", json!(["b"]));
for (operator, value, ctx) in [
("in", "[1]", &map),
("not_in", "[1]", &map),
("contains", r#""a""#, &scalar),
("not_contains", r#""a""#, &scalar),
("matches", r#"["b"]"#, &list),
("not_matches", r#"["b"]"#, &list),
] {
assert!(!condition_matches(operator, value, ctx), "{operator}");
}
assert!(condition_matches("not_contains", r#""a""#, &list));
}
fn checker(opts: Options, namespace: &str) -> FeatureChecker {
FeatureChecker::new(namespace.to_string(), Box::leak(Box::new(opts)))
}
fn setup_without_value() -> (Options, TempDir) {
let temp = TempDir::new().unwrap();
let schemas = temp.path().join("schemas");
fs::create_dir_all(&schemas).unwrap();
create_schema(&schemas, "test", FEATURE_SCHEMA);
create_values(&temp.path().join("values"), "test", r#"{"options": {}}"#);
(Options::from_directory(temp.path()).unwrap(), temp)
}
#[test]
fn test_try_has_evaluated() {
let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
let checker = checker(opts, "test");
let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
assert_eq!(result.unwrap(), Some(true));
}
#[test]
fn test_try_has_evaluated_false_is_not_absent() {
let (opts, _temp) = setup_feature_options(&feature_json(false, 100, ""));
let checker = checker(opts, "test");
let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
assert_eq!(result.unwrap(), Some(false));
}
#[test]
fn test_try_has_absent_when_no_value_set() {
let (opts, _temp) = setup_without_value();
let checker = checker(opts, "test");
let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_try_has_absent_when_feature_not_in_schema() {
let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
let checker = checker(opts, "test");
let result = checker.try_has("organizations:never-declared", &FeatureContext::new());
assert_eq!(result.unwrap(), None);
}
#[test]
fn test_try_has_errors_on_unknown_namespace() {
let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
let checker = checker(opts, "not-a-namespace");
assert!(matches!(
checker.try_has("organizations:test-feature", &FeatureContext::new()),
Err(FeatureError::Options(
crate::OptionsError::UnknownNamespace(_)
))
));
}
#[test]
fn test_try_has_errors_when_uninitialized() {
let checker = FeatureChecker {
namespace: "test".to_string(),
options: None,
};
assert!(matches!(
checker.try_has("organizations:test-feature", &FeatureContext::new()),
Err(FeatureError::NotInitialized)
));
}
#[test]
fn test_has_still_collapses_every_failure_to_false() {
let (opts, _temp) = setup_without_value();
let absent = checker(opts, "test");
assert!(!absent.has("organizations:test-feature", &FeatureContext::new()));
let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
let unknown_ns = checker(opts, "not-a-namespace");
assert!(!unknown_ns.has("organizations:test-feature", &FeatureContext::new()));
let uninitialized = FeatureChecker {
namespace: "test".to_string(),
options: None,
};
assert!(!uninitialized.has("organizations:test-feature", &FeatureContext::new()));
}
}