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