1use chrono::{DateTime, NaiveDate, NaiveDateTime};
7use num::bigint::{BigInt, Sign};
8use std::cell::{Cell, OnceCell};
9use std::collections::HashMap;
10
11use serde_json::Value;
12use sha1::{Digest, Sha1};
13
14const FEATURE_BUCKETING_EPOCH: NaiveDate = NaiveDate::from_ymd_opt(2026, 10, 5).unwrap();
21
22fn parse_created_at(raw: &str) -> Option<NaiveDate> {
28 if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
29 return Some(date);
30 }
31 if let Ok(datetime) = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
32 return Some(datetime.date());
33 }
34 DateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f%z")
35 .ok()
36 .map(|datetime| datetime.naive_utc().date())
37}
38
39fn created_after_epoch(created_at: Option<&str>) -> bool {
41 created_at
42 .and_then(parse_created_at)
43 .is_some_and(|created_at| created_at > FEATURE_BUCKETING_EPOCH)
44}
45
46fn value_to_id_string(value: &Value) -> String {
48 match value {
49 Value::String(s) => s.clone(),
50 Value::Number(n) => {
51 if let Some(i) = n.as_i64() {
52 i.to_string()
53 } else if let Some(f) = n.as_f64() {
54 if f.fract() == 0.0 {
56 format!("{f:.1}")
57 } else {
58 f.to_string()
59 }
60 } else {
61 n.to_string()
62 }
63 }
64 Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
65 Value::Array(arr) => {
66 let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
67 format!("[{}]", items.join(", "))
68 }
69 Value::Null => "None".to_string(),
70 Value::Object(_) => value.to_string(),
71 }
72}
73
74pub struct FeatureContext {
79 data: HashMap<String, Value>,
80 identity_fields: Vec<String>,
81 cached_identity: OnceCell<String>,
84 cached_id: Cell<Option<u64>>,
85}
86
87impl FeatureContext {
88 pub fn new() -> Self {
89 Self {
90 data: HashMap::new(),
91 identity_fields: Vec::new(),
92 cached_identity: OnceCell::new(),
93 cached_id: Cell::new(None),
94 }
95 }
96
97 pub fn identity_fields(&mut self, fields: Vec<&str>) {
102 self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
103 self.cached_id.set(None);
104 self.cached_identity.take();
105 }
106
107 pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
109 self.data.insert(key.to_string(), value.into());
110 self.cached_id.set(None);
111 self.cached_identity.take();
112 }
113
114 pub fn get(&self, key: &str) -> Option<&Value> {
116 self.data.get(key)
117 }
118
119 pub fn has(&self, key: &str) -> bool {
121 self.data.contains_key(key)
122 }
123
124 pub fn id(&self) -> u64 {
131 if let Some(id) = self.cached_id.get() {
132 return id;
133 }
134 let id = self.compute_id(None);
135 self.cached_id.set(Some(id));
136 id
137 }
138
139 pub fn bucket_id(&self, feature_name: Option<&str>) -> u64 {
146 match feature_name {
147 None => self.id(),
148 Some(name) => self.compute_id(Some(name)),
149 }
150 }
151
152 fn compute_identity(&self) -> String {
153 let mut identity_fields: Vec<&String> = self
154 .identity_fields
155 .iter()
156 .filter(|f| self.data.contains_key(f.as_str()))
157 .collect();
158 if identity_fields.is_empty() {
159 identity_fields = self.data.keys().collect();
160 }
161 identity_fields.sort();
162
163 let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
164 for key in identity_fields {
165 parts.push(key.clone());
166 parts.push(value_to_id_string(&self.data[key.as_str()]));
167 }
168 parts.join(":")
169 }
170
171 fn compute_id(&self, feature_name: Option<&str>) -> u64 {
180 let identity = self.cached_identity.get_or_init(|| self.compute_identity());
181 let mut hasher = Sha1::new();
182 if let Some(name) = feature_name {
183 hasher.update(name.as_bytes());
184 hasher.update(b":");
185 }
186 hasher.update(identity.as_bytes());
187 let digest = hasher.finalize();
188
189 let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
191
192 let small: BigInt = bigint % 1000000000;
196 let id_parts = small.to_u64_digits().1;
197 if id_parts.is_empty() { 0 } else { id_parts[0] }
198 }
199}
200
201impl Default for FeatureContext {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207#[derive(Debug)]
208enum OperatorKind {
209 In,
210 NotIn,
211 Contains,
212 NotContains,
213 Equals,
214 NotEquals,
215 Matches,
216 NotMatches,
217}
218
219impl OperatorKind {
220 const ALL: [Self; 8] = [
221 Self::In,
222 Self::NotIn,
223 Self::Contains,
224 Self::NotContains,
225 Self::Equals,
226 Self::NotEquals,
227 Self::Matches,
228 Self::NotMatches,
229 ];
230
231 fn as_str(&self) -> &'static str {
232 match self {
233 Self::In => "in",
234 Self::NotIn => "not_in",
235 Self::Contains => "contains",
236 Self::NotContains => "not_contains",
237 Self::Equals => "equals",
238 Self::NotEquals => "not_equals",
239 Self::Matches => "matches",
240 Self::NotMatches => "not_matches",
241 }
242 }
243}
244
245pub fn condition_operators() -> Vec<&'static str> {
247 OperatorKind::ALL.iter().map(OperatorKind::as_str).collect()
248}
249
250#[derive(Debug)]
251struct Condition {
252 property: String,
253 operator: OperatorKind,
254 value: Value,
255}
256
257#[derive(Debug)]
258struct Segment {
259 rollout: u64,
260 conditions: Vec<Condition>,
261}
262
263#[derive(Debug)]
264struct Feature {
265 name: String,
266 enabled: bool,
267 segments: Vec<Segment>,
268 buckets_by_feature: bool,
269}
270
271impl Feature {
272 fn from_json(name: &str, value: &Value) -> Option<Self> {
273 let enabled = value
275 .get("enabled")
276 .and_then(|v| v.as_bool())
277 .unwrap_or(true);
278 let segments = value
279 .get("segments")?
280 .as_array()?
281 .iter()
282 .filter_map(Segment::from_json)
283 .collect();
284 Some(Feature {
285 name: name.to_owned(),
286 enabled,
287 segments,
288 buckets_by_feature: created_after_epoch(
289 value.get("created_at").and_then(Value::as_str),
290 ),
291 })
292 }
293
294 fn matches(&self, context: &FeatureContext) -> bool {
295 if !self.enabled {
296 return false;
297 }
298 let feature_name = self.buckets_by_feature.then_some(self.name.as_str());
299 for segment in &self.segments {
300 if segment.conditions_match(context) {
301 return segment.in_rollout(context, feature_name);
302 }
303 }
304 false
305 }
306}
307
308impl Segment {
309 fn from_json(value: &Value) -> Option<Self> {
310 let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
311 let conditions = value
312 .get("conditions")
313 .and_then(|v| v.as_array())
314 .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
315 .unwrap_or_default();
316 Some(Segment {
317 rollout,
318 conditions,
319 })
320 }
321
322 fn conditions_match(&self, context: &FeatureContext) -> bool {
323 self.conditions.iter().all(|c| c.matches(context))
324 }
325
326 fn in_rollout(&self, context: &FeatureContext, feature_name: Option<&str>) -> bool {
327 if self.rollout == 0 {
328 return false;
329 }
330 if self.rollout >= 100 {
331 return true;
332 }
333 context.bucket_id(feature_name) % 100 <= self.rollout
334 }
335}
336
337impl Condition {
338 fn from_json(value: &Value) -> Option<Self> {
339 let property = value.get("property")?.as_str()?.to_string();
340 let operator = value.get("operator")?.as_str()?;
341 let operator = OperatorKind::ALL
342 .into_iter()
343 .find(|kind| kind.as_str() == operator)?;
344 let value = value.get("value")?.clone();
345 Some(Condition {
346 property,
347 operator,
348 value,
349 })
350 }
351
352 fn matches(&self, context: &FeatureContext) -> bool {
353 let ctx_val = context.get(&self.property);
354 let result = match &self.operator {
355 OperatorKind::In => eval_in(ctx_val, &self.value),
356 OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
357 OperatorKind::Contains => eval_contains(ctx_val, &self.value),
358 OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
359 OperatorKind::Equals => eval_equals(ctx_val, &self.value),
360 OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
361 OperatorKind::Matches => eval_matches(ctx_val, &self.value),
362 OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
363 };
364 result.unwrap_or(false)
368 }
369}
370
371fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
377 let arr = condition_val.as_array()?;
378 match ctx_val {
379 None => Some(false),
380 Some(Value::Object(_)) => None,
381 Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
382 Some(scalar) => Some(scalar_in(scalar, arr)),
383 }
384}
385
386fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
387 match ctx_val {
388 Value::String(s) => {
389 let s_lower = s.to_lowercase();
390 arr.iter()
391 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
392 }
393 Value::Number(n) => arr
394 .iter()
395 .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
396 Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
397 _ => false,
398 }
399}
400
401fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
404 if !a.is_f64() && !b.is_f64() {
406 return a == b;
407 }
408 matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
410}
411
412fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
418 let ctx_arr = ctx_val?.as_array()?;
419 match condition_val {
420 Value::String(s) => {
421 let s_lower = s.to_lowercase();
422 Some(
423 ctx_arr
424 .iter()
425 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
426 )
427 }
428 Value::Number(n) => Some(
429 ctx_arr
430 .iter()
431 .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
432 ),
433 Value::Bool(b) => Some(
434 ctx_arr
435 .iter()
436 .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
437 ),
438 _ => None,
439 }
440}
441
442fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
445 Some(match ctx_val {
446 Some(ctx_val) => values_equal(ctx_val, condition_val),
447 None => false,
448 })
449}
450
451fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
454 match (ctx_val, condition_val) {
455 (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
456 (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
457 (Value::Bool(a), Value::Bool(b)) => a == b,
458 (Value::Array(a), Value::Array(b)) => {
459 a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
460 }
461 _ => false,
462 }
463}
464
465fn glob_star_match(pattern: &str, value: &str) -> bool {
469 let pattern = pattern.to_lowercase();
470 let value = value.to_lowercase();
471 let parts: Vec<&str> = pattern.split('*').collect();
472 if parts.len() == 1 {
474 return value == pattern;
475 }
476 if !value.starts_with(parts[0]) {
478 return false;
479 }
480 if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
482 return false;
483 }
484 let end = if parts[parts.len() - 1].is_empty() {
486 value.len()
487 } else {
488 value.len() - parts[parts.len() - 1].len()
489 };
490 let mut start = parts[0].len();
491 if start > end {
494 return false;
495 }
496 for part in &parts[1..parts.len() - 1] {
498 if part.is_empty() {
499 continue;
501 }
502 match value[start..end].find(*part) {
503 Some(idx) => start += idx + part.len(),
504 None => return false,
505 }
506 }
507 true
508}
509
510fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
514 let arr = condition_val.as_array()?;
515 match ctx_val {
516 Some(Value::Array(_) | Value::Object(_)) => None,
518 None => Some(false),
519 Some(scalar) => {
520 let Some(s) = scalar.as_str() else {
521 return Some(false);
522 };
523 Some(arr.iter().any(|v| {
524 v.as_str()
525 .is_some_and(|pattern| glob_star_match(pattern, s))
526 }))
527 }
528 }
529}
530
531#[derive(Debug, thiserror::Error)]
536pub enum FeatureError {
537 #[error("Options not initialized - call init() first")]
538 NotInitialized,
539
540 #[error("Value for '{key}' is not a valid feature")]
541 InvalidValue { key: String },
542
543 #[error(transparent)]
544 Options(#[from] crate::OptionsError),
545}
546
547pub struct FeatureChecker {
548 namespace: String,
549 options: Option<&'static crate::Options>,
550}
551
552impl FeatureChecker {
553 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
554 Self {
555 namespace,
556 options: Some(options),
557 }
558 }
559
560 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
565 match self.try_has(feature_name, context) {
567 Ok(Some(result)) => result,
568 Ok(None) => false,
569 Err(e) => {
570 tracing::debug!(feature = feature_name, error = %e, "Feature evaluation failed");
571 false
572 }
573 }
574 }
575
576 pub fn try_has(
585 &self,
586 feature_name: &str,
587 context: &FeatureContext,
588 ) -> Result<Option<bool>, FeatureError> {
589 let opts = self.options.ok_or(FeatureError::NotInitialized)?;
590 let key = format!("feature.{feature_name}");
591
592 let feature_val = match opts.get(&self.namespace, &key) {
593 Ok(v) => v,
594 Err(crate::OptionsError::UnknownOption { .. }) => return Ok(None),
597 Err(e) => return Err(e.into()),
598 };
599
600 let feature = Feature::from_json(feature_name, &feature_val)
601 .ok_or_else(|| FeatureError::InvalidValue { key: key.clone() })?;
602
603 let result = feature.matches(context);
604 tracing::debug!(
605 feature = feature_name,
606 result,
607 bucket_id = context.bucket_id(feature.buckets_by_feature.then_some(feature_name)),
608 "Feature match result"
609 );
610 Ok(Some(result))
611 }
612}
613
614pub fn features(namespace: &str) -> FeatureChecker {
618 FeatureChecker {
619 namespace: namespace.to_string(),
620 options: crate::GLOBAL_OPTIONS.get(),
621 }
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627 use crate::Options;
628 use serde_json::json;
629 use std::fs;
630 use std::path::Path;
631 use tempfile::TempDir;
632
633 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
634 let schema_dir = dir.join(namespace);
635 fs::create_dir_all(&schema_dir).unwrap();
636 fs::write(schema_dir.join("schema.json"), schema).unwrap();
637 }
638
639 fn create_values(dir: &Path, namespace: &str, values: &str) {
640 let ns_dir = dir.join(namespace);
641 fs::create_dir_all(&ns_dir).unwrap();
642 fs::write(ns_dir.join("values.json"), values).unwrap();
643 }
644
645 const FEATURE_SCHEMA: &str = r##"{
646 "version": "1.0",
647 "type": "object",
648 "properties": {
649 "feature.organizations:test-feature": {
650 "$ref": "#/definitions/Feature"
651 }
652 }
653 }"##;
654
655 fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
656 let temp = TempDir::new().unwrap();
657 let schemas = temp.path().join("schemas");
658 fs::create_dir_all(&schemas).unwrap();
659 create_schema(&schemas, "test", FEATURE_SCHEMA);
660
661 let values = temp.path().join("values");
662 let values_json = format!(
663 r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
664 feature_json
665 );
666 create_values(&values, "test", &values_json);
667
668 let opts = Options::from_directory(temp.path()).unwrap();
669 (opts, temp)
670 }
671
672 fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
673 format!(
674 r#"{{
675 "name": "test-feature",
676 "enabled": {enabled},
677 "owner": {{"team": "test-team"}},
678 "created_at": "2024-01-01",
679 "segments": [{{
680 "name": "test-segment",
681 "rollout": {rollout},
682 "conditions": [{conditions}]
683 }}]
684 }}"#
685 )
686 }
687
688 fn in_condition(property: &str, values: &str) -> String {
689 format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
690 }
691
692 fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
693 let key = format!("feature.{feature}");
694 let Ok(val) = opts.get("test", &key) else {
695 return false;
696 };
697 Feature::from_json(feature, &val).is_some_and(|parsed_feature| parsed_feature.matches(ctx))
698 }
699
700 #[test]
701 fn test_feature_context_insert_and_get() {
702 let mut ctx = FeatureContext::new();
703 ctx.insert("org_id", json!(123));
704 ctx.insert("name", json!("sentry"));
705 ctx.insert("active", json!(true));
706
707 assert!(ctx.has("org_id"));
708 assert!(!ctx.has("missing"));
709 assert_eq!(ctx.get("org_id"), Some(&json!(123)));
710 assert_eq!(ctx.get("name"), Some(&json!("sentry")));
711 }
712
713 #[test]
714 fn test_feature_context_id_is_cached() {
715 let mut ctx = FeatureContext::new();
716 ctx.identity_fields(vec!["user_id"]);
717 ctx.insert("user_id", json!(42));
718
719 let id1 = ctx.id();
720 let id2 = ctx.id();
721 assert_eq!(id1, id2, "ID should be cached and consistent");
722 }
723
724 #[test]
725 fn test_feature_context_id_resets_on_identity_change() {
726 let mut ctx = FeatureContext::new();
727 ctx.insert("user_id", json!(1));
728 ctx.insert("org_id", json!(2));
729
730 ctx.identity_fields(vec!["user_id"]);
731 let id_user = ctx.id();
732
733 ctx.identity_fields(vec!["org_id"]);
734 let id_org = ctx.id();
735
736 assert_ne!(
737 id_user, id_org,
738 "Different identity fields should produce different IDs"
739 );
740 }
741
742 #[test]
743 fn test_bucket_id_matches_bigint_reference() {
744 use num::ToPrimitive;
745 use num::bigint::{BigInt, Sign};
746
747 let cases = [
748 (0, "organizations:a"),
749 (123, "organizations:performance-view"),
750 (456, "organizations:performance-view"),
751 (123, "organizations:dashboards-edit"),
752 (
754 10_000,
755 "organizations:a-feature-name-long-enough-to-span-multiple-sha1-blocks",
756 ),
757 ];
758 for (organization_id, feature_name) in cases {
759 let mut context = FeatureContext::new();
760 context.insert("organization_id", json!(organization_id));
761 context.identity_fields(vec!["organization_id"]);
762 let input = format!("{feature_name}:organization_id:{organization_id}");
763 let digest = Sha1::digest(input.as_bytes());
764 let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
765 let expected: BigInt = bigint % 1_000_000_000;
766 assert_eq!(
767 context.bucket_id(Some(feature_name)),
768 expected.to_u64().unwrap(),
769 "Hash mismatch for {input}"
770 );
771 }
772 }
773
774 #[test]
775 fn test_bucket_id_resets_on_context_changes() {
776 for feature_name in [None, Some("organizations:test-feature")] {
777 let mut context = FeatureContext::new();
778 context.insert("organization_id", json!(123));
779 context.insert("user_id", json!(42));
780 context.identity_fields(vec!["organization_id"]);
781 let original_id = context.bucket_id(feature_name);
782
783 context.insert("organization_id", json!(456));
784 let updated_id = context.bucket_id(feature_name);
785 assert_ne!(original_id, updated_id);
786
787 context.identity_fields(vec!["user_id"]);
788 assert_ne!(updated_id, context.bucket_id(feature_name));
789 }
790 }
791
792 #[test]
793 fn test_feature_context_id_deterministic() {
794 let make_ctx = || {
795 let mut ctx = FeatureContext::new();
796 ctx.identity_fields(vec!["user_id", "org_id"]);
797 ctx.insert("user_id", json!(456));
798 ctx.insert("org_id", json!(123));
799 ctx
800 };
801
802 assert_eq!(make_ctx().id(), make_ctx().id());
803
804 let mut other_ctx = FeatureContext::new();
805 other_ctx.identity_fields(vec!["user_id", "org_id"]);
806 other_ctx.insert("user_id", json!(789));
807 other_ctx.insert("org_id", json!(123));
808
809 assert_ne!(make_ctx().id(), other_ctx.id());
810 }
811
812 #[test]
813 fn test_feature_context_id_value_align_with_python() {
814 let ctx = FeatureContext::new();
818 assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
819
820 let mut ctx = FeatureContext::new();
821 ctx.insert("foo", json!("bar"));
822 ctx.insert("baz", json!("barfoo"));
823 ctx.identity_fields(vec!["foo"]);
824 assert_eq!(ctx.id() % 100, 62);
825
826 let mut ctx = FeatureContext::new();
828 ctx.insert("foo", json!("bar"));
829 ctx.insert("baz", json!("barfoo"));
830 ctx.identity_fields(vec!["foo", "whoops"]);
831 assert_eq!(ctx.id() % 100, 62);
832
833 let mut ctx = FeatureContext::new();
834 ctx.insert("foo", json!("bar"));
835 ctx.insert("baz", json!("barfoo"));
836 ctx.identity_fields(vec!["foo", "baz"]);
837 assert_eq!(ctx.id() % 100, 1);
838
839 let mut ctx = FeatureContext::new();
840 ctx.insert("foo", json!("bar"));
841 ctx.insert("baz", json!("barfoo"));
842 ctx.identity_fields(vec!["whoops", "nope"]);
845 assert_eq!(ctx.id() % 100, 1);
846 }
847
848 #[test]
849 fn test_feature_prefix_is_added() {
850 let cond = in_condition("organization_id", "123");
851 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
852
853 let mut ctx = FeatureContext::new();
854 ctx.insert("organization_id", json!(123));
855
856 assert!(check(&opts, "organizations:test-feature", &ctx));
857 }
858
859 #[test]
860 fn test_undefined_feature_returns_false() {
861 let cond = in_condition("organization_id", "123");
862 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
863
864 let ctx = FeatureContext::new();
865 assert!(!check(&opts, "nonexistent", &ctx));
866 }
867
868 #[test]
869 fn test_missing_context_field_returns_false() {
870 let cond = in_condition("organization_id", "123");
871 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
872
873 let ctx = FeatureContext::new();
874 assert!(!check(&opts, "organizations:test-feature", &ctx));
875 }
876
877 #[test]
878 fn test_matching_context_returns_true() {
879 let cond = in_condition("organization_id", "123, 456");
880 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
881
882 let mut ctx = FeatureContext::new();
883 ctx.insert("organization_id", json!(123));
884
885 assert!(check(&opts, "organizations:test-feature", &ctx));
886 }
887
888 #[test]
889 fn test_non_matching_context_returns_false() {
890 let cond = in_condition("organization_id", "123, 456");
891 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
892
893 let mut ctx = FeatureContext::new();
894 ctx.insert("organization_id", json!(999));
895
896 assert!(!check(&opts, "organizations:test-feature", &ctx));
897 }
898
899 #[test]
900 fn test_disabled_feature_returns_false() {
901 let cond = in_condition("organization_id", "123");
902 let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
903
904 let mut ctx = FeatureContext::new();
905 ctx.insert("organization_id", json!(123));
906
907 assert!(!check(&opts, "organizations:test-feature", &ctx));
908 }
909
910 #[test]
911 fn test_rollout_zero_returns_false() {
912 let cond = in_condition("organization_id", "123");
913 let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
914
915 let mut ctx = FeatureContext::new();
916 ctx.insert("organization_id", json!(123));
917
918 assert!(!check(&opts, "organizations:test-feature", &ctx));
919 }
920
921 #[test]
922 fn test_rollout_100_returns_true() {
923 let cond = in_condition("organization_id", "123");
924 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
925
926 let mut ctx = FeatureContext::new();
927 ctx.insert("organization_id", json!(123));
928
929 assert!(check(&opts, "organizations:test-feature", &ctx));
930 }
931
932 #[test]
933 fn test_rollout_is_deterministic() {
934 let mut ctx = FeatureContext::new();
935 ctx.identity_fields(vec!["user_id"]);
936 ctx.insert("user_id", json!(42));
937 ctx.insert("organization_id", json!(123));
938
939 let id_mod = (ctx.id() % 100) + 1;
941 let cond = in_condition("organization_id", "123");
942
943 let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
944 assert!(check(&opts_at, "organizations:test-feature", &ctx));
945 }
946
947 #[test]
948 fn test_bucket_id_pinned_values() {
949 let mut ctx = FeatureContext::new();
953 ctx.insert("organization_id", json!(123));
954 ctx.insert("organization_slug", json!("sentry"));
955 ctx.identity_fields(vec!["organization_id"]);
956
957 assert_eq!(ctx.bucket_id(None), ctx.id());
958 assert_eq!(
959 ctx.bucket_id(Some("organizations:performance-view")) % 100,
960 64
961 );
962 assert_eq!(
963 ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
964 75
965 );
966
967 let ctx = FeatureContext::new();
968 assert_eq!(
969 ctx.bucket_id(Some("organizations:performance-view")) % 100,
970 85
971 );
972 }
973
974 #[test]
975 fn test_created_after_epoch() {
976 let cases = [
977 ("2026-10-05", false),
978 ("2026-10-05T00:00:00", false),
979 ("2026-10-05T00:00:01", false),
980 ("2026-10-05T00:00:00.000001", false),
981 ("2026-10-05T23:59:59.999999999", false),
982 ("2026-10-06", true),
983 ("2026-10-06T00:00:00", true),
984 ("2026-10-04T23:00:00-02:00", false),
985 ("2026-10-05T23:00:00-02:00", true),
986 ("2026-10-05T23:00:00-0200", true),
987 ("2026-10-06T01:00:00+02:00", false),
988 ("2026-10-06T01:00:00+0200", false),
989 ("2024-01-01", false),
990 ("None", false),
991 ("not a date", false),
992 ];
993 for (created_at, expected) in cases {
994 assert_eq!(
995 created_after_epoch(Some(created_at)),
996 expected,
997 "{created_at}"
998 );
999 }
1000 assert!(!created_after_epoch(None));
1001 }
1002
1003 #[test]
1004 fn test_rollout_keeps_identity_bucketing_for_features_created_before_epoch() {
1005 let mut ctx = FeatureContext::new();
1008 ctx.insert("organization_id", json!(123));
1009 ctx.identity_fields(vec!["organization_id"]);
1010 assert_eq!(ctx.id() % 100, 56);
1011
1012 let feature = |rollout: u64| {
1013 let value = json!({
1014 "created_at": "2024-01-01",
1015 "segments": [{"name": "all", "rollout": rollout, "conditions": []}]
1016 });
1017 Feature::from_json("organizations:performance-view", &value)
1018 .unwrap()
1019 .matches(&ctx)
1020 };
1021 assert!(feature(56));
1022 assert!(!feature(55));
1023 }
1024
1025 #[test]
1026 fn test_rollout_buckets_by_feature_for_features_created_after_epoch() {
1027 let mut ctx = FeatureContext::new();
1030 ctx.insert("organization_id", json!(123));
1031 ctx.identity_fields(vec!["organization_id"]);
1032 assert_eq!(
1033 ctx.bucket_id(Some("organizations:performance-view")) % 100,
1034 64
1035 );
1036 assert_eq!(
1037 ctx.bucket_id(Some("organizations:dashboards-edit")) % 100,
1038 75
1039 );
1040
1041 let feature = |name: &str, rollout: u64| {
1042 let value = json!({
1043 "created_at": "2026-12-01",
1044 "segments": [{"name": "all", "rollout": rollout, "conditions": []}]
1045 });
1046 Feature::from_json(name, &value).unwrap().matches(&ctx)
1047 };
1048 assert!(feature("organizations:performance-view", 64));
1049 assert!(!feature("organizations:performance-view", 63));
1050 assert!(!feature("organizations:dashboards-edit", 64));
1051 }
1052
1053 #[test]
1054 fn test_first_matching_segment_decides_rollout() {
1055 let mut context = FeatureContext::new();
1056 context.insert("organization_id", json!(123));
1057 context.identity_fields(vec!["organization_id"]);
1058
1059 for (rollout, expected) in [
1060 (0, false),
1061 (63, false),
1062 (64, true),
1063 (100, true),
1064 (101, true),
1065 ] {
1066 let value = json!({
1067 "created_at": "2026-12-01",
1068 "segments": [
1069 {"name": "first", "rollout": rollout, "conditions": []},
1070 {"name": "second", "rollout": 100, "conditions": []}
1071 ]
1072 });
1073 let feature = Feature::from_json("organizations:performance-view", &value).unwrap();
1074
1075 assert_eq!(feature.matches(&context), expected, "rollout {rollout}");
1076 }
1077 }
1078
1079 #[test]
1080 fn test_condition_in_string_case_insensitive() {
1081 let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
1082 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1083
1084 let mut ctx = FeatureContext::new();
1085 ctx.insert("slug", json!("sentry"));
1086 assert!(check(&opts, "organizations:test-feature", &ctx));
1087 }
1088
1089 #[test]
1090 fn test_condition_not_in() {
1091 let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
1092 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1093
1094 let mut ctx = FeatureContext::new();
1095 ctx.insert("organization_id", json!(123));
1096 assert!(check(&opts, "organizations:test-feature", &ctx));
1097
1098 let mut ctx2 = FeatureContext::new();
1099 ctx2.insert("organization_id", json!(999));
1100 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1101 }
1102
1103 #[test]
1104 fn test_condition_in_list_property_matches_on_overlap() {
1105 let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
1106 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1107
1108 let mut ctx = FeatureContext::new();
1109 ctx.insert("plan_family", json!(["enterprise business", "Business"]));
1110 assert!(check(&opts, "organizations:test-feature", &ctx));
1111
1112 let mut ctx2 = FeatureContext::new();
1113 ctx2.insert("plan_family", json!(["team"]));
1114 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1115
1116 let mut ctx3 = FeatureContext::new();
1117 ctx3.insert("plan_family", json!([]));
1118 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1119 }
1120
1121 #[test]
1122 fn test_condition_not_in_list_property_requires_no_overlap() {
1123 let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
1124 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1125
1126 let mut ctx = FeatureContext::new();
1127 ctx.insert("plan_family", json!(["enterprise business", "business"]));
1128 assert!(!check(&opts, "organizations:test-feature", &ctx));
1129
1130 let mut ctx2 = FeatureContext::new();
1131 ctx2.insert("plan_family", json!(["team"]));
1132 assert!(check(&opts, "organizations:test-feature", &ctx2));
1133 }
1134
1135 #[test]
1136 fn test_condition_in_list_property_no_type_coercion() {
1137 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
1138 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1139
1140 let mut ctx = FeatureContext::new();
1141 ctx.insert("org_id", json!([123]));
1142 assert!(!check(&opts, "organizations:test-feature", &ctx));
1143
1144 let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
1145 let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
1146 assert!(check(&int_opts, "organizations:test-feature", &ctx));
1147 }
1148
1149 #[test]
1150 fn test_condition_in_matches_across_int_and_float() {
1151 let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
1152 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1153
1154 let mut ctx = FeatureContext::new();
1155 ctx.insert("org_id", json!(123));
1156 assert!(check(&opts, "organizations:test-feature", &ctx));
1157
1158 let mut list_ctx = FeatureContext::new();
1159 list_ctx.insert("org_id", json!([123]));
1160 assert!(check(&opts, "organizations:test-feature", &list_ctx));
1161
1162 let mut ctx2 = FeatureContext::new();
1163 ctx2.insert("org_id", json!(124));
1164 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1165 }
1166
1167 #[test]
1168 fn test_condition_in_large_ints_compare_exactly() {
1169 let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
1171 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1172
1173 let mut ctx = FeatureContext::new();
1174 ctx.insert("org_id", json!(9007199254740992i64));
1175 assert!(!check(&opts, "organizations:test-feature", &ctx));
1176
1177 let mut ctx2 = FeatureContext::new();
1178 ctx2.insert("org_id", json!(9007199254740993i64));
1179 assert!(check(&opts, "organizations:test-feature", &ctx2));
1180
1181 let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
1183 let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
1184
1185 let mut ctx3 = FeatureContext::new();
1186 ctx3.insert("org_id", json!(18446744073709551614u64));
1187 assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
1188
1189 let mut ctx4 = FeatureContext::new();
1190 ctx4.insert("org_id", json!(18446744073709551615u64));
1191 assert!(check(&big_opts, "organizations:test-feature", &ctx4));
1192 }
1193
1194 #[test]
1195 fn test_condition_contains() {
1196 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
1197 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1198
1199 let mut ctx = FeatureContext::new();
1200 ctx.insert("tags", json!(["alpha", "beta"]));
1201 assert!(check(&opts, "organizations:test-feature", &ctx));
1202
1203 let mut ctx2 = FeatureContext::new();
1204 ctx2.insert("tags", json!(["alpha"]));
1205 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1206 }
1207
1208 #[test]
1209 fn test_condition_contains_matches_across_int_and_float() {
1210 let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
1211 let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
1212
1213 let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
1214 let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
1215
1216 let mut int_ctx = FeatureContext::new();
1217 int_ctx.insert("ids", json!([123]));
1218 let mut float_ctx = FeatureContext::new();
1219 float_ctx.insert("ids", json!([123.0]));
1220
1221 assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
1223 assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
1224 assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
1225 assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
1226
1227 let mut other = FeatureContext::new();
1228 other.insert("ids", json!([124]));
1229 assert!(!check(&int_opts, "organizations:test-feature", &other));
1230 }
1231
1232 #[test]
1233 fn test_condition_equals() {
1234 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
1235 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1236
1237 let mut ctx = FeatureContext::new();
1238 ctx.insert("plan", json!("Enterprise"));
1239 assert!(check(&opts, "organizations:test-feature", &ctx));
1240
1241 let mut ctx2 = FeatureContext::new();
1242 ctx2.insert("plan", json!("free"));
1243 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1244 }
1245
1246 #[test]
1247 fn test_condition_equals_large_ints_compare_exactly() {
1248 let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
1250 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1251
1252 let mut ctx = FeatureContext::new();
1253 ctx.insert("org_id", json!(18446744073709551614u64));
1254 assert!(!check(&opts, "organizations:test-feature", &ctx));
1255
1256 let mut ctx2 = FeatureContext::new();
1257 ctx2.insert("org_id", json!(18446744073709551615u64));
1258 assert!(check(&opts, "organizations:test-feature", &ctx2));
1259 }
1260
1261 #[test]
1262 fn test_condition_equals_bool() {
1263 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
1264 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1265
1266 let mut ctx = FeatureContext::new();
1267 ctx.insert("is_free", json!(true));
1268 assert!(check(&opts, "organizations:test-feature", &ctx));
1269
1270 let mut ctx2 = FeatureContext::new();
1271 ctx2.insert("is_free", json!(false));
1272 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1273 }
1274
1275 #[test]
1276 fn test_segment_with_no_conditions_always_matches() {
1277 let feature = r#"{
1278 "name": "test-feature",
1279 "enabled": true,
1280 "owner": {"team": "test-team"},
1281 "created_at": "2024-01-01",
1282 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
1283 }"#;
1284 let (opts, _t) = setup_feature_options(feature);
1285
1286 let ctx = FeatureContext::new();
1287 assert!(check(&opts, "organizations:test-feature", &ctx));
1288 }
1289
1290 #[test]
1291 fn test_feature_enabled_and_rollout_default_values() {
1292 let feature = r#"{
1294 "name": "test-feature",
1295 "owner": {"team": "test-team"},
1296 "created_at": "2024-01-01",
1297 "segments": [
1298 {
1299 "name": "first",
1300 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
1301 }
1302 ]
1303 }"#;
1304 let (opts, _t) = setup_feature_options(feature);
1305
1306 let mut ctx = FeatureContext::new();
1307 ctx.insert("org_id", 1);
1308 ctx.identity_fields(vec!["org_id"]);
1309 assert!(check(&opts, "organizations:test-feature", &ctx));
1310 }
1311
1312 #[test]
1313 fn test_feature_with_no_segments_returns_false() {
1314 let feature = r#"{
1315 "name": "test-feature",
1316 "enabled": true,
1317 "owner": {"team": "test-team"},
1318 "created_at": "2024-01-01",
1319 "segments": []
1320 }"#;
1321 let (opts, _t) = setup_feature_options(feature);
1322
1323 let ctx = FeatureContext::new();
1324 assert!(!check(&opts, "organizations:test-feature", &ctx));
1325 }
1326
1327 #[test]
1328 fn test_multiple_segments_or_logic() {
1329 let feature = r#"{
1330 "name": "test-feature",
1331 "enabled": true,
1332 "owner": {"team": "test-team"},
1333 "created_at": "2024-01-01",
1334 "segments": [
1335 {
1336 "name": "segment-a",
1337 "rollout": 100,
1338 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1339 },
1340 {
1341 "name": "segment-b",
1342 "rollout": 100,
1343 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1344 }
1345 ]
1346 }"#;
1347 let (opts, _t) = setup_feature_options(feature);
1348
1349 let mut ctx1 = FeatureContext::new();
1350 ctx1.insert("org_id", json!(1));
1351 assert!(check(&opts, "organizations:test-feature", &ctx1));
1352
1353 let mut ctx2 = FeatureContext::new();
1354 ctx2.insert("org_id", json!(2));
1355 assert!(check(&opts, "organizations:test-feature", &ctx2));
1356
1357 let mut ctx3 = FeatureContext::new();
1358 ctx3.insert("org_id", json!(3));
1359 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1360 }
1361
1362 #[test]
1363 fn test_multiple_conditions_and_logic() {
1364 let conds = r#"
1365 {"property": "org_id", "operator": "in", "value": [123]},
1366 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1367 "#;
1368 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1369
1370 let mut ctx = FeatureContext::new();
1371 ctx.insert("org_id", json!(123));
1372 ctx.insert("user_email", json!("admin@example.com"));
1373 assert!(check(&opts, "organizations:test-feature", &ctx));
1374
1375 let mut ctx2 = FeatureContext::new();
1376 ctx2.insert("org_id", json!(123));
1377 ctx2.insert("user_email", json!("other@example.com"));
1378 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1379 }
1380
1381 #[test]
1382 fn test_in_int_context_against_string_list_returns_false() {
1383 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1384 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1385
1386 let mut ctx = FeatureContext::new();
1387 ctx.insert("org_id", json!(123));
1388 assert!(!check(&opts, "organizations:test-feature", &ctx));
1389 }
1390
1391 #[test]
1392 fn test_in_string_context_against_int_list_returns_false() {
1393 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1394 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1395
1396 let mut ctx = FeatureContext::new();
1397 ctx.insert("slug", json!("123"));
1398 assert!(!check(&opts, "organizations:test-feature", &ctx));
1399 }
1400
1401 #[test]
1402 fn test_in_bool_context_against_string_list_returns_false() {
1403 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1404 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1405
1406 let mut ctx = FeatureContext::new();
1407 ctx.insert("active", json!(true));
1408 assert!(!check(&opts, "organizations:test-feature", &ctx));
1409 }
1410
1411 #[test]
1412 fn test_in_float_context_against_string_list_returns_false() {
1413 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1414 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1415
1416 let mut ctx = FeatureContext::new();
1417 ctx.insert("score", json!(0.5));
1418 assert!(!check(&opts, "organizations:test-feature", &ctx));
1419 }
1420
1421 #[test]
1422 fn test_not_in_int_context_against_string_list_returns_true() {
1423 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1425 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1426
1427 let mut ctx = FeatureContext::new();
1428 ctx.insert("org_id", json!(123));
1429 assert!(check(&opts, "organizations:test-feature", &ctx));
1430 }
1431
1432 #[test]
1433 fn test_not_in_string_context_against_int_list_returns_true() {
1434 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1436 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1437
1438 let mut ctx = FeatureContext::new();
1439 ctx.insert("slug", json!("123"));
1440 assert!(check(&opts, "organizations:test-feature", &ctx));
1441 }
1442
1443 #[test]
1444 fn test_condition_matches_literal() {
1445 let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1446 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1447
1448 let mut ctx = FeatureContext::new();
1449 ctx.insert("slug", json!("sentry"));
1450 assert!(check(&opts, "organizations:test-feature", &ctx));
1451
1452 let mut ctx2 = FeatureContext::new();
1453 ctx2.insert("slug", json!("getsentry"));
1454 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1455 }
1456
1457 #[test]
1458 fn test_condition_matches_prefix_wildcard() {
1459 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1460 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1461
1462 let mut ctx = FeatureContext::new();
1463 ctx.insert("slug", json!("jayonb73"));
1464 assert!(check(&opts, "organizations:test-feature", &ctx));
1465
1466 let mut ctx2 = FeatureContext::new();
1468 ctx2.insert("slug", json!("jayonb"));
1469 assert!(check(&opts, "organizations:test-feature", &ctx2));
1470
1471 let mut ctx3 = FeatureContext::new();
1472 ctx3.insert("slug", json!("dangoldonb1"));
1473 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1474 }
1475
1476 #[test]
1477 fn test_condition_matches_prefix_and_suffix_wildcard() {
1478 let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1479 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1480
1481 let mut ctx = FeatureContext::new();
1482 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1483 assert!(check(&opts, "organizations:test-feature", &ctx));
1484
1485 let mut ctx2 = FeatureContext::new();
1487 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1488 assert!(check(&opts, "organizations:test-feature", &ctx2));
1489
1490 let mut ctx3 = FeatureContext::new();
1491 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1492 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1493 }
1494
1495 #[test]
1496 fn test_condition_matches_suffix_wildcard() {
1497 let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1498 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1499
1500 let mut ctx = FeatureContext::new();
1501 ctx.insert("email", json!("user@sentry.io"));
1502 assert!(check(&opts, "organizations:test-feature", &ctx));
1503
1504 let mut ctx2 = FeatureContext::new();
1505 ctx2.insert("email", json!("user@example.com"));
1506 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1507 }
1508
1509 #[test]
1510 fn test_condition_matches_multi_segment_wildcard() {
1511 let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1512 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1513
1514 let mut ctx = FeatureContext::new();
1515 ctx.insert("name", json!("abc"));
1516 assert!(check(&opts, "organizations:test-feature", &ctx));
1517
1518 let mut ctx2 = FeatureContext::new();
1519 ctx2.insert("name", json!("aXbYc"));
1520 assert!(check(&opts, "organizations:test-feature", &ctx2));
1521
1522 let mut ctx3 = FeatureContext::new();
1523 ctx3.insert("name", json!("aXXbYYc"));
1524 assert!(check(&opts, "organizations:test-feature", &ctx3));
1525
1526 let mut ctx4 = FeatureContext::new();
1527 ctx4.insert("name", json!("aXXc"));
1528 assert!(!check(&opts, "organizations:test-feature", &ctx4));
1529 }
1530
1531 #[test]
1532 fn test_condition_matches_star_only_pattern() {
1533 let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1534 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1535
1536 let mut ctx = FeatureContext::new();
1537 ctx.insert("slug", json!("anything"));
1538 assert!(check(&opts, "organizations:test-feature", &ctx));
1539
1540 let mut ctx2 = FeatureContext::new();
1541 ctx2.insert("slug", json!(""));
1542 assert!(check(&opts, "organizations:test-feature", &ctx2));
1543 }
1544
1545 #[test]
1546 fn test_condition_matches_case_insensitive() {
1547 let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1548 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1549
1550 let mut ctx = FeatureContext::new();
1551 ctx.insert("slug", json!("jayonb73"));
1552 assert!(check(&opts, "organizations:test-feature", &ctx));
1553
1554 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1556 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1557
1558 let mut ctx2 = FeatureContext::new();
1559 ctx2.insert("slug", json!("JAYONB73"));
1560 assert!(check(&opts2, "organizations:test-feature", &ctx2));
1561 }
1562
1563 #[test]
1564 fn test_condition_matches_no_match() {
1565 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1566 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1567
1568 let mut ctx = FeatureContext::new();
1569 ctx.insert("slug", json!("dangoldonb1"));
1570 assert!(!check(&opts, "organizations:test-feature", &ctx));
1571 }
1572
1573 #[test]
1574 fn test_condition_matches_multiple_patterns() {
1575 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1576 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1577
1578 let slugs = [
1579 ("jayonb73", true),
1580 ("dangoldonb3", true),
1581 ("value-disc-7", true),
1582 ("other-org", false),
1583 ];
1584 for (slug, expected) in slugs {
1585 let mut ctx = FeatureContext::new();
1586 ctx.insert("slug", json!(slug));
1587 assert_eq!(
1588 check(&opts, "organizations:test-feature", &ctx),
1589 expected,
1590 "slug={slug}"
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1597 let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1599 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1600
1601 let mut ctx = FeatureContext::new();
1602 ctx.insert("slug", json!("a"));
1603 assert!(!check(&opts, "organizations:test-feature", &ctx));
1604
1605 let mut ctx2 = FeatureContext::new();
1606 ctx2.insert("slug", json!("aa"));
1607 assert!(check(&opts, "organizations:test-feature", &ctx2));
1608
1609 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1611 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1612
1613 let mut ctx3 = FeatureContext::new();
1614 ctx3.insert("slug", json!("ab"));
1615 assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1616
1617 let mut ctx4 = FeatureContext::new();
1618 ctx4.insert("slug", json!("abab"));
1619 assert!(check(&opts2, "organizations:test-feature", &ctx4));
1620 }
1621
1622 #[test]
1623 fn test_condition_matches_non_string_context_returns_false() {
1624 let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1626 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1627
1628 let mut ctx = FeatureContext::new();
1629 ctx.insert("org_id", json!(123));
1630 assert!(!check(&opts, "organizations:test-feature", &ctx));
1631 }
1632
1633 #[test]
1634 fn test_condition_not_matches_literal() {
1635 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1636 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1637
1638 let mut ctx = FeatureContext::new();
1640 ctx.insert("slug", json!("sentry"));
1641 assert!(!check(&opts, "organizations:test-feature", &ctx));
1642
1643 let mut ctx2 = FeatureContext::new();
1645 ctx2.insert("slug", json!("getsentry"));
1646 assert!(check(&opts, "organizations:test-feature", &ctx2));
1647 }
1648
1649 #[test]
1650 fn test_condition_not_matches_prefix_wildcard() {
1651 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1652 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1653
1654 let mut ctx = FeatureContext::new();
1655 ctx.insert("slug", json!("jayonb73"));
1656 assert!(!check(&opts, "organizations:test-feature", &ctx));
1657
1658 let mut ctx2 = FeatureContext::new();
1660 ctx2.insert("slug", json!("jayonb"));
1661 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1662
1663 let mut ctx3 = FeatureContext::new();
1664 ctx3.insert("slug", json!("dangoldonb1"));
1665 assert!(check(&opts, "organizations:test-feature", &ctx3));
1666 }
1667
1668 #[test]
1669 fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1670 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1671 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1672
1673 let mut ctx = FeatureContext::new();
1674 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1675 assert!(!check(&opts, "organizations:test-feature", &ctx));
1676
1677 let mut ctx2 = FeatureContext::new();
1678 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1679 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1680
1681 let mut ctx3 = FeatureContext::new();
1682 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1683 assert!(check(&opts, "organizations:test-feature", &ctx3));
1684 }
1685
1686 #[test]
1687 fn test_condition_not_matches_suffix_wildcard() {
1688 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1689 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1690
1691 let mut ctx = FeatureContext::new();
1692 ctx.insert("email", json!("user@sentry.io"));
1693 assert!(!check(&opts, "organizations:test-feature", &ctx));
1694
1695 let mut ctx2 = FeatureContext::new();
1696 ctx2.insert("email", json!("user@example.com"));
1697 assert!(check(&opts, "organizations:test-feature", &ctx2));
1698 }
1699
1700 #[test]
1701 fn test_condition_not_matches_multi_segment_wildcard() {
1702 let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1703 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1704
1705 let mut ctx = FeatureContext::new();
1706 ctx.insert("name", json!("abc"));
1707 assert!(!check(&opts, "organizations:test-feature", &ctx));
1708
1709 let mut ctx2 = FeatureContext::new();
1710 ctx2.insert("name", json!("aXbYc"));
1711 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1712
1713 let mut ctx3 = FeatureContext::new();
1714 ctx3.insert("name", json!("aXXbYYc"));
1715 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1716
1717 let mut ctx4 = FeatureContext::new();
1719 ctx4.insert("name", json!("aXXc"));
1720 assert!(check(&opts, "organizations:test-feature", &ctx4));
1721 }
1722
1723 #[test]
1724 fn test_condition_not_matches_star_only_pattern() {
1725 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1727 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1728
1729 let mut ctx = FeatureContext::new();
1730 ctx.insert("slug", json!("anything"));
1731 assert!(!check(&opts, "organizations:test-feature", &ctx));
1732
1733 let mut ctx2 = FeatureContext::new();
1734 ctx2.insert("slug", json!(""));
1735 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1736 }
1737
1738 #[test]
1739 fn test_condition_not_matches_case_insensitive() {
1740 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1741 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1742
1743 let mut ctx = FeatureContext::new();
1745 ctx.insert("slug", json!("jayonb73"));
1746 assert!(!check(&opts, "organizations:test-feature", &ctx));
1747
1748 let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1750 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1751
1752 let mut ctx2 = FeatureContext::new();
1753 ctx2.insert("slug", json!("JAYONB73"));
1754 assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1755 }
1756
1757 #[test]
1758 fn test_condition_not_matches_no_match() {
1759 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1760 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1761
1762 let mut ctx = FeatureContext::new();
1763 ctx.insert("slug", json!("dangoldonb1"));
1764 assert!(check(&opts, "organizations:test-feature", &ctx));
1765 }
1766
1767 #[test]
1768 fn test_condition_not_matches_multiple_patterns() {
1769 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1770 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1771
1772 let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1774 for slug in slugs_false {
1775 let mut ctx = FeatureContext::new();
1776 ctx.insert("slug", json!(slug));
1777 assert!(
1778 !check(&opts, "organizations:test-feature", &ctx),
1779 "slug={slug} should not match"
1780 );
1781 }
1782
1783 let mut ctx = FeatureContext::new();
1785 ctx.insert("slug", json!("other-org"));
1786 assert!(check(&opts, "organizations:test-feature", &ctx));
1787 }
1788
1789 #[test]
1790 fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1791 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1793 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1794
1795 let mut ctx = FeatureContext::new();
1796 ctx.insert("slug", json!("a"));
1797 assert!(check(&opts, "organizations:test-feature", &ctx));
1798
1799 let mut ctx2 = FeatureContext::new();
1800 ctx2.insert("slug", json!("aa"));
1801 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1802 }
1803
1804 #[test]
1805 fn test_condition_not_matches_non_string_context_returns_true() {
1806 let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1808 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1809
1810 let mut ctx = FeatureContext::new();
1811 ctx.insert("org_id", json!(123));
1812 assert!(check(&opts, "organizations:test-feature", &ctx));
1813 }
1814
1815 #[test]
1816 fn test_id_string_is_stable() {
1817 for (value, expected) in [
1818 (json!("sentry"), "sentry"),
1819 (json!(123), "123"),
1820 (json!(-7), "-7"),
1821 (json!(true), "True"),
1822 (json!(false), "False"),
1823 (json!(null), "None"),
1824 (json!(1.0), "1.0"),
1825 (json!(1.5), "1.5"),
1826 (json!(["a", "b"]), "[a, b]"),
1827 (json!([1, 2]), "[1, 2]"),
1828 (json!([]), "[]"),
1829 ] {
1830 assert_eq!(value_to_id_string(&value), expected, "for {value}");
1831 }
1832 }
1833
1834 fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1835 let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1836 Condition::from_json(&serde_json::from_str(&json).unwrap())
1837 .expect("condition should parse")
1838 .matches(ctx)
1839 }
1840
1841 #[test]
1842 fn test_missing_property_matches_only_negated_conditions() {
1843 let ctx = FeatureContext::new();
1844 for (operator, value, expected) in [
1845 ("in", "[1]", false),
1846 ("not_in", "[1]", true),
1847 ("equals", "1", false),
1848 ("not_equals", "1", true),
1849 ("matches", r#"["a"]"#, false),
1850 ("not_matches", r#"["a"]"#, true),
1851 ("contains", r#""a""#, false),
1853 ("not_contains", r#""a""#, false),
1854 ] {
1855 assert_eq!(
1856 condition_matches(operator, value, &ctx),
1857 expected,
1858 "{operator}"
1859 );
1860 }
1861 }
1862
1863 #[test]
1864 fn test_unusable_property_type_matches_neither_form() {
1865 let mut map = FeatureContext::new();
1866 map.insert("prop", json!({"k": "v"}));
1867 let mut scalar = FeatureContext::new();
1868 scalar.insert("prop", json!("a"));
1869 let mut list = FeatureContext::new();
1870 list.insert("prop", json!(["b"]));
1871
1872 for (operator, value, ctx) in [
1873 ("in", "[1]", &map),
1874 ("not_in", "[1]", &map),
1875 ("contains", r#""a""#, &scalar),
1876 ("not_contains", r#""a""#, &scalar),
1877 ("matches", r#"["b"]"#, &list),
1878 ("not_matches", r#"["b"]"#, &list),
1879 ] {
1880 assert!(!condition_matches(operator, value, ctx), "{operator}");
1881 }
1882
1883 assert!(condition_matches("not_contains", r#""a""#, &list));
1885 }
1886
1887 fn checker(opts: Options, namespace: &str) -> FeatureChecker {
1889 FeatureChecker::new(namespace.to_string(), Box::leak(Box::new(opts)))
1890 }
1891
1892 fn setup_without_value() -> (Options, TempDir) {
1894 let temp = TempDir::new().unwrap();
1895 let schemas = temp.path().join("schemas");
1896 fs::create_dir_all(&schemas).unwrap();
1897 create_schema(&schemas, "test", FEATURE_SCHEMA);
1898 create_values(&temp.path().join("values"), "test", r#"{"options": {}}"#);
1899 (Options::from_directory(temp.path()).unwrap(), temp)
1900 }
1901
1902 #[test]
1903 fn test_try_has_evaluated() {
1904 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1905 let checker = checker(opts, "test");
1906
1907 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1908 assert_eq!(result.unwrap(), Some(true));
1909 }
1910
1911 #[test]
1912 fn test_try_has_evaluated_false_is_not_absent() {
1913 let (opts, _temp) = setup_feature_options(&feature_json(false, 100, ""));
1916 let checker = checker(opts, "test");
1917
1918 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1919 assert_eq!(result.unwrap(), Some(false));
1920 }
1921
1922 #[test]
1923 fn test_try_has_absent_when_no_value_set() {
1924 let (opts, _temp) = setup_without_value();
1925 let checker = checker(opts, "test");
1926
1927 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1928 assert_eq!(result.unwrap(), None);
1929 }
1930
1931 #[test]
1932 fn test_try_has_absent_when_feature_not_in_schema() {
1933 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1934 let checker = checker(opts, "test");
1935
1936 let result = checker.try_has("organizations:never-declared", &FeatureContext::new());
1937 assert_eq!(result.unwrap(), None);
1938 }
1939
1940 #[test]
1941 fn test_try_has_errors_on_unknown_namespace() {
1942 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1943 let checker = checker(opts, "not-a-namespace");
1944
1945 assert!(matches!(
1946 checker.try_has("organizations:test-feature", &FeatureContext::new()),
1947 Err(FeatureError::Options(
1948 crate::OptionsError::UnknownNamespace(_)
1949 ))
1950 ));
1951 }
1952
1953 #[test]
1954 fn test_try_has_errors_when_uninitialized() {
1955 let checker = FeatureChecker {
1956 namespace: "test".to_string(),
1957 options: None,
1958 };
1959
1960 assert!(matches!(
1961 checker.try_has("organizations:test-feature", &FeatureContext::new()),
1962 Err(FeatureError::NotInitialized)
1963 ));
1964 }
1965
1966 #[test]
1967 fn test_has_still_collapses_every_failure_to_false() {
1968 let (opts, _temp) = setup_without_value();
1970 let absent = checker(opts, "test");
1971 assert!(!absent.has("organizations:test-feature", &FeatureContext::new()));
1972
1973 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1974 let unknown_ns = checker(opts, "not-a-namespace");
1975 assert!(!unknown_ns.has("organizations:test-feature", &FeatureContext::new()));
1976
1977 let uninitialized = FeatureChecker {
1978 namespace: "test".to_string(),
1979 options: None,
1980 };
1981 assert!(!uninitialized.has("organizations:test-feature", &FeatureContext::new()));
1982 }
1983}