use num::bigint::{BigInt, Sign};
use std::cell::Cell;
use std::collections::HashMap;
use std::sync::OnceLock;
use serde_json::Value;
use sha1::{Digest, Sha1};
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_id: Cell<Option<u64>>,
}
impl FeatureContext {
pub fn new() -> Self {
Self {
data: HashMap::new(),
identity_fields: Vec::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);
}
pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
self.data.insert(key.to_string(), value.into());
self.cached_id.set(None);
}
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();
self.cached_id.set(Some(id));
id
}
fn compute_id(&self) -> u64 {
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()]));
}
let mut hasher = Sha1::new();
hasher.update(parts.join(":").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,
}
#[derive(Debug)]
struct Condition {
property: String,
operator: OperatorKind,
value: Value,
}
#[derive(Debug)]
struct Segment {
rollout: u64,
conditions: Vec<Condition>,
}
#[derive(Debug)]
struct Feature {
enabled: bool,
segments: Vec<Segment>,
}
impl Feature {
fn from_json(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 { enabled, segments })
}
fn matches(&self, context: &FeatureContext) -> bool {
if !self.enabled {
return false;
}
for segment in &self.segments {
if segment.conditions_match(context) {
return segment.in_rollout(context);
}
}
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) -> bool {
if self.rollout == 0 {
return false;
}
if self.rollout >= 100 {
return true;
}
context.id() % 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,
_ => return None,
};
let value = value.get("value")?.clone();
Some(Condition {
property,
operator,
value,
})
}
fn matches(&self, context: &FeatureContext) -> bool {
let Some(ctx_val) = context.get(&self.property) else {
return false;
};
match &self.operator {
OperatorKind::In => eval_in(ctx_val, &self.value),
OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
OperatorKind::Contains => eval_contains(ctx_val, &self.value),
OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
OperatorKind::Equals => eval_equals(ctx_val, &self.value),
OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
OperatorKind::Matches => eval_matches(ctx_val, &self.value),
}
}
}
fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
let Some(arr) = condition_val.as_array() else {
return false;
};
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) => {
if let Some(i) = n.as_i64() {
arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
} else if let Some(f) = n.as_f64() {
arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
} else {
false
}
}
Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
_ => false,
}
}
fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
let Some(ctx_arr) = ctx_val.as_array() else {
return false;
};
match condition_val {
Value::String(s) => {
let s_lower = s.to_lowercase();
ctx_arr
.iter()
.any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
}
Value::Number(n) => {
if let Some(i) = n.as_i64() {
ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
} else if let Some(f) = n.as_f64() {
ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
} else {
false
}
}
Value::Bool(b) => ctx_arr
.iter()
.any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
_ => false,
}
}
fn eval_equals(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)) => {
if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
ai == bi
} else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
af == bf
} else {
false
}
}
(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)| eval_equals(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: &Value, condition_val: &Value) -> bool {
let Some(s) = ctx_val.as_str() else {
return false;
};
let Some(arr) = condition_val.as_array() else {
return false;
};
arr.iter().any(|v| {
v.as_str()
.is_some_and(|pattern| glob_star_match(pattern, s))
})
}
#[derive(Debug, PartialEq)]
enum DebugLogLevel {
None,
Parse,
Match,
All,
}
static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
fn debug_log_level() -> &'static DebugLogLevel {
DEBUG_LOG_LEVEL.get_or_init(|| {
match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
.as_deref()
.unwrap_or("")
{
"all" => DebugLogLevel::All,
"parse" => DebugLogLevel::Parse,
"match" => DebugLogLevel::Match,
_ => DebugLogLevel::None,
}
})
}
fn debug_match_sample_rate() -> u64 {
*DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
.unwrap_or(1000)
})
}
fn debug_log_parse(msg: &str) {
match debug_log_level() {
DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
_ => {}
}
}
fn debug_log_match(feature: &str, result: bool, context_id: u64) {
match debug_log_level() {
DebugLogLevel::Match | DebugLogLevel::All
if context_id % 1000 < debug_match_sample_rate() =>
{
eprintln!(
"[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
);
}
_ => {}
}
}
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 {
let Some(opts) = self.options else {
return false;
};
let key = format!("feature.{feature_name}");
let feature_val = match opts.get(&self.namespace, &key) {
Ok(v) => v,
Err(e) => {
debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
return false;
}
};
let feature = match Feature::from_json(&feature_val) {
Some(f) => {
debug_log_parse(&format!("Parsed feature '{key}'"));
f
}
None => {
debug_log_parse(&format!("Failed to parse feature '{key}'"));
return false;
}
};
let result = feature.matches(context);
debug_log_match(feature_name, result, context.id());
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(&val).is_some_and(|f| f.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_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_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_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_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_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));
}
}