1use num::bigint::{BigInt, Sign};
7use std::cell::Cell;
8use std::collections::HashMap;
9
10use serde_json::Value;
11use sha1::{Digest, Sha1};
12
13fn value_to_id_string(value: &Value) -> String {
15 match value {
16 Value::String(s) => s.clone(),
17 Value::Number(n) => {
18 if let Some(i) = n.as_i64() {
19 i.to_string()
20 } else if let Some(f) = n.as_f64() {
21 if f.fract() == 0.0 {
23 format!("{f:.1}")
24 } else {
25 f.to_string()
26 }
27 } else {
28 n.to_string()
29 }
30 }
31 Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
32 Value::Array(arr) => {
33 let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
34 format!("[{}]", items.join(", "))
35 }
36 Value::Null => "None".to_string(),
37 Value::Object(_) => value.to_string(),
38 }
39}
40
41pub struct FeatureContext {
46 data: HashMap<String, Value>,
47 identity_fields: Vec<String>,
48 cached_id: Cell<Option<u64>>,
49}
50
51impl FeatureContext {
52 pub fn new() -> Self {
53 Self {
54 data: HashMap::new(),
55 identity_fields: Vec::new(),
56 cached_id: Cell::new(None),
57 }
58 }
59
60 pub fn identity_fields(&mut self, fields: Vec<&str>) {
65 self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
66 self.cached_id.set(None);
67 }
68
69 pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
71 self.data.insert(key.to_string(), value.into());
72 self.cached_id.set(None);
73 }
74
75 pub fn get(&self, key: &str) -> Option<&Value> {
77 self.data.get(key)
78 }
79
80 pub fn has(&self, key: &str) -> bool {
82 self.data.contains_key(key)
83 }
84
85 pub fn id(&self) -> u64 {
92 if let Some(id) = self.cached_id.get() {
93 return id;
94 }
95 let id = self.compute_id();
96 self.cached_id.set(Some(id));
97 id
98 }
99
100 fn compute_id(&self) -> u64 {
109 let mut identity_fields: Vec<&String> = self
110 .identity_fields
111 .iter()
112 .filter(|f| self.data.contains_key(f.as_str()))
113 .collect();
114 if identity_fields.is_empty() {
115 identity_fields = self.data.keys().collect();
116 }
117 identity_fields.sort();
118
119 let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
120 for key in identity_fields {
121 parts.push(key.clone());
122 parts.push(value_to_id_string(&self.data[key.as_str()]));
123 }
124 let mut hasher = Sha1::new();
125 hasher.update(parts.join(":").as_bytes());
126 let digest = hasher.finalize();
127
128 let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
130
131 let small: BigInt = bigint % 1000000000;
135 let id_parts = small.to_u64_digits().1;
136 if id_parts.is_empty() { 0 } else { id_parts[0] }
137 }
138}
139
140impl Default for FeatureContext {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146#[derive(Debug)]
147enum OperatorKind {
148 In,
149 NotIn,
150 Contains,
151 NotContains,
152 Equals,
153 NotEquals,
154 Matches,
155 NotMatches,
156}
157
158#[derive(Debug)]
159struct Condition {
160 property: String,
161 operator: OperatorKind,
162 value: Value,
163}
164
165#[derive(Debug)]
166struct Segment {
167 rollout: u64,
168 conditions: Vec<Condition>,
169}
170
171#[derive(Debug)]
172struct Feature {
173 enabled: bool,
174 segments: Vec<Segment>,
175}
176
177impl Feature {
178 fn from_json(value: &Value) -> Option<Self> {
179 let enabled = value
181 .get("enabled")
182 .and_then(|v| v.as_bool())
183 .unwrap_or(true);
184 let segments = value
185 .get("segments")?
186 .as_array()?
187 .iter()
188 .filter_map(Segment::from_json)
189 .collect();
190 Some(Feature { enabled, segments })
191 }
192
193 fn matches(&self, context: &FeatureContext) -> bool {
194 if !self.enabled {
195 return false;
196 }
197 for segment in &self.segments {
198 if segment.conditions_match(context) {
199 return segment.in_rollout(context);
200 }
201 }
202 false
203 }
204}
205
206impl Segment {
207 fn from_json(value: &Value) -> Option<Self> {
208 let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
209 let conditions = value
210 .get("conditions")
211 .and_then(|v| v.as_array())
212 .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
213 .unwrap_or_default();
214 Some(Segment {
215 rollout,
216 conditions,
217 })
218 }
219
220 fn conditions_match(&self, context: &FeatureContext) -> bool {
221 self.conditions.iter().all(|c| c.matches(context))
222 }
223
224 fn in_rollout(&self, context: &FeatureContext) -> bool {
225 if self.rollout == 0 {
226 return false;
227 }
228 if self.rollout >= 100 {
229 return true;
230 }
231 context.id() % 100 <= self.rollout
232 }
233}
234
235impl Condition {
236 fn from_json(value: &Value) -> Option<Self> {
237 let property = value.get("property")?.as_str()?.to_string();
238 let operator = match value.get("operator")?.as_str()? {
239 "in" => OperatorKind::In,
240 "not_in" => OperatorKind::NotIn,
241 "contains" => OperatorKind::Contains,
242 "not_contains" => OperatorKind::NotContains,
243 "equals" => OperatorKind::Equals,
244 "not_equals" => OperatorKind::NotEquals,
245 "matches" => OperatorKind::Matches,
246 "not_matches" => OperatorKind::NotMatches,
247 _ => return None,
248 };
249 let value = value.get("value")?.clone();
250 Some(Condition {
251 property,
252 operator,
253 value,
254 })
255 }
256
257 fn matches(&self, context: &FeatureContext) -> bool {
258 let ctx_val = context.get(&self.property);
259 let result = match &self.operator {
260 OperatorKind::In => eval_in(ctx_val, &self.value),
261 OperatorKind::NotIn => eval_in(ctx_val, &self.value).map(|m| !m),
262 OperatorKind::Contains => eval_contains(ctx_val, &self.value),
263 OperatorKind::NotContains => eval_contains(ctx_val, &self.value).map(|m| !m),
264 OperatorKind::Equals => eval_equals(ctx_val, &self.value),
265 OperatorKind::NotEquals => eval_equals(ctx_val, &self.value).map(|m| !m),
266 OperatorKind::Matches => eval_matches(ctx_val, &self.value),
267 OperatorKind::NotMatches => eval_matches(ctx_val, &self.value).map(|m| !m),
268 };
269 result.unwrap_or(false)
273 }
274}
275
276fn eval_in(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
282 let arr = condition_val.as_array()?;
283 match ctx_val {
284 None => Some(false),
285 Some(Value::Object(_)) => None,
286 Some(Value::Array(ctx_arr)) => Some(ctx_arr.iter().any(|v| scalar_in(v, arr))),
287 Some(scalar) => Some(scalar_in(scalar, arr)),
288 }
289}
290
291fn scalar_in(ctx_val: &Value, arr: &[Value]) -> bool {
292 match ctx_val {
293 Value::String(s) => {
294 let s_lower = s.to_lowercase();
295 arr.iter()
296 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
297 }
298 Value::Number(n) => arr
299 .iter()
300 .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
301 Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
302 _ => false,
303 }
304}
305
306fn numbers_equal(a: &serde_json::Number, b: &serde_json::Number) -> bool {
309 if !a.is_f64() && !b.is_f64() {
311 return a == b;
312 }
313 matches!((a.as_f64(), b.as_f64()), (Some(x), Some(y)) if x == y)
315}
316
317fn eval_contains(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
323 let ctx_arr = ctx_val?.as_array()?;
324 match condition_val {
325 Value::String(s) => {
326 let s_lower = s.to_lowercase();
327 Some(
328 ctx_arr
329 .iter()
330 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower)),
331 )
332 }
333 Value::Number(n) => Some(
334 ctx_arr
335 .iter()
336 .any(|v| v.as_number().is_some_and(|cv| numbers_equal(n, cv))),
337 ),
338 Value::Bool(b) => Some(
339 ctx_arr
340 .iter()
341 .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
342 ),
343 _ => None,
344 }
345}
346
347fn eval_equals(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
350 Some(match ctx_val {
351 Some(ctx_val) => values_equal(ctx_val, condition_val),
352 None => false,
353 })
354}
355
356fn values_equal(ctx_val: &Value, condition_val: &Value) -> bool {
359 match (ctx_val, condition_val) {
360 (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
361 (Value::Number(a), Value::Number(b)) => numbers_equal(a, b),
362 (Value::Bool(a), Value::Bool(b)) => a == b,
363 (Value::Array(a), Value::Array(b)) => {
364 a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| values_equal(av, bv))
365 }
366 _ => false,
367 }
368}
369
370fn glob_star_match(pattern: &str, value: &str) -> bool {
374 let pattern = pattern.to_lowercase();
375 let value = value.to_lowercase();
376 let parts: Vec<&str> = pattern.split('*').collect();
377 if parts.len() == 1 {
379 return value == pattern;
380 }
381 if !value.starts_with(parts[0]) {
383 return false;
384 }
385 if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
387 return false;
388 }
389 let end = if parts[parts.len() - 1].is_empty() {
391 value.len()
392 } else {
393 value.len() - parts[parts.len() - 1].len()
394 };
395 let mut start = parts[0].len();
396 if start > end {
399 return false;
400 }
401 for part in &parts[1..parts.len() - 1] {
403 if part.is_empty() {
404 continue;
406 }
407 match value[start..end].find(*part) {
408 Some(idx) => start += idx + part.len(),
409 None => return false,
410 }
411 }
412 true
413}
414
415fn eval_matches(ctx_val: Option<&Value>, condition_val: &Value) -> Option<bool> {
419 let arr = condition_val.as_array()?;
420 match ctx_val {
421 Some(Value::Array(_) | Value::Object(_)) => None,
423 None => Some(false),
424 Some(scalar) => {
425 let Some(s) = scalar.as_str() else {
426 return Some(false);
427 };
428 Some(arr.iter().any(|v| {
429 v.as_str()
430 .is_some_and(|pattern| glob_star_match(pattern, s))
431 }))
432 }
433 }
434}
435
436pub struct FeatureChecker {
438 namespace: String,
439 options: Option<&'static crate::Options>,
440}
441
442impl FeatureChecker {
443 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
444 Self {
445 namespace,
446 options: Some(options),
447 }
448 }
449
450 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
455 let Some(opts) = self.options else {
456 return false;
457 };
458 let key = format!("feature.{feature_name}");
459
460 let feature_val = match opts.get(&self.namespace, &key) {
461 Ok(v) => v,
462 Err(e) => {
463 tracing::debug!(key = %key, error = %e, "Failed to get feature");
464 return false;
465 }
466 };
467
468 let feature = match Feature::from_json(&feature_val) {
469 Some(f) => {
470 tracing::debug!(key = %key, "Parsed feature");
471 f
472 }
473 None => {
474 tracing::debug!(key = %key, "Failed to parse feature");
475 return false;
476 }
477 };
478
479 let result = feature.matches(context);
480 tracing::debug!(
481 feature = feature_name,
482 result,
483 context_id = context.id(),
484 "Feature match result"
485 );
486 result
487 }
488}
489
490pub fn features(namespace: &str) -> FeatureChecker {
494 FeatureChecker {
495 namespace: namespace.to_string(),
496 options: crate::GLOBAL_OPTIONS.get(),
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::Options;
504 use serde_json::json;
505 use std::fs;
506 use std::path::Path;
507 use tempfile::TempDir;
508
509 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
510 let schema_dir = dir.join(namespace);
511 fs::create_dir_all(&schema_dir).unwrap();
512 fs::write(schema_dir.join("schema.json"), schema).unwrap();
513 }
514
515 fn create_values(dir: &Path, namespace: &str, values: &str) {
516 let ns_dir = dir.join(namespace);
517 fs::create_dir_all(&ns_dir).unwrap();
518 fs::write(ns_dir.join("values.json"), values).unwrap();
519 }
520
521 const FEATURE_SCHEMA: &str = r##"{
522 "version": "1.0",
523 "type": "object",
524 "properties": {
525 "feature.organizations:test-feature": {
526 "$ref": "#/definitions/Feature"
527 }
528 }
529 }"##;
530
531 fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
532 let temp = TempDir::new().unwrap();
533 let schemas = temp.path().join("schemas");
534 fs::create_dir_all(&schemas).unwrap();
535 create_schema(&schemas, "test", FEATURE_SCHEMA);
536
537 let values = temp.path().join("values");
538 let values_json = format!(
539 r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
540 feature_json
541 );
542 create_values(&values, "test", &values_json);
543
544 let opts = Options::from_directory(temp.path()).unwrap();
545 (opts, temp)
546 }
547
548 fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
549 format!(
550 r#"{{
551 "name": "test-feature",
552 "enabled": {enabled},
553 "owner": {{"team": "test-team"}},
554 "created_at": "2024-01-01",
555 "segments": [{{
556 "name": "test-segment",
557 "rollout": {rollout},
558 "conditions": [{conditions}]
559 }}]
560 }}"#
561 )
562 }
563
564 fn in_condition(property: &str, values: &str) -> String {
565 format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
566 }
567
568 fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
569 let key = format!("feature.{feature}");
570 let Ok(val) = opts.get("test", &key) else {
571 return false;
572 };
573 Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
574 }
575
576 #[test]
577 fn test_feature_context_insert_and_get() {
578 let mut ctx = FeatureContext::new();
579 ctx.insert("org_id", json!(123));
580 ctx.insert("name", json!("sentry"));
581 ctx.insert("active", json!(true));
582
583 assert!(ctx.has("org_id"));
584 assert!(!ctx.has("missing"));
585 assert_eq!(ctx.get("org_id"), Some(&json!(123)));
586 assert_eq!(ctx.get("name"), Some(&json!("sentry")));
587 }
588
589 #[test]
590 fn test_feature_context_id_is_cached() {
591 let mut ctx = FeatureContext::new();
592 ctx.identity_fields(vec!["user_id"]);
593 ctx.insert("user_id", json!(42));
594
595 let id1 = ctx.id();
596 let id2 = ctx.id();
597 assert_eq!(id1, id2, "ID should be cached and consistent");
598 }
599
600 #[test]
601 fn test_feature_context_id_resets_on_identity_change() {
602 let mut ctx = FeatureContext::new();
603 ctx.insert("user_id", json!(1));
604 ctx.insert("org_id", json!(2));
605
606 ctx.identity_fields(vec!["user_id"]);
607 let id_user = ctx.id();
608
609 ctx.identity_fields(vec!["org_id"]);
610 let id_org = ctx.id();
611
612 assert_ne!(
613 id_user, id_org,
614 "Different identity fields should produce different IDs"
615 );
616 }
617
618 #[test]
619 fn test_feature_context_id_deterministic() {
620 let make_ctx = || {
621 let mut ctx = FeatureContext::new();
622 ctx.identity_fields(vec!["user_id", "org_id"]);
623 ctx.insert("user_id", json!(456));
624 ctx.insert("org_id", json!(123));
625 ctx
626 };
627
628 assert_eq!(make_ctx().id(), make_ctx().id());
629
630 let mut other_ctx = FeatureContext::new();
631 other_ctx.identity_fields(vec!["user_id", "org_id"]);
632 other_ctx.insert("user_id", json!(789));
633 other_ctx.insert("org_id", json!(123));
634
635 assert_ne!(make_ctx().id(), other_ctx.id());
636 }
637
638 #[test]
639 fn test_feature_context_id_value_align_with_python() {
640 let ctx = FeatureContext::new();
644 assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
645
646 let mut ctx = FeatureContext::new();
647 ctx.insert("foo", json!("bar"));
648 ctx.insert("baz", json!("barfoo"));
649 ctx.identity_fields(vec!["foo"]);
650 assert_eq!(ctx.id() % 100, 62);
651
652 let mut ctx = FeatureContext::new();
654 ctx.insert("foo", json!("bar"));
655 ctx.insert("baz", json!("barfoo"));
656 ctx.identity_fields(vec!["foo", "whoops"]);
657 assert_eq!(ctx.id() % 100, 62);
658
659 let mut ctx = FeatureContext::new();
660 ctx.insert("foo", json!("bar"));
661 ctx.insert("baz", json!("barfoo"));
662 ctx.identity_fields(vec!["foo", "baz"]);
663 assert_eq!(ctx.id() % 100, 1);
664
665 let mut ctx = FeatureContext::new();
666 ctx.insert("foo", json!("bar"));
667 ctx.insert("baz", json!("barfoo"));
668 ctx.identity_fields(vec!["whoops", "nope"]);
671 assert_eq!(ctx.id() % 100, 1);
672 }
673
674 #[test]
675 fn test_feature_prefix_is_added() {
676 let cond = in_condition("organization_id", "123");
677 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
678
679 let mut ctx = FeatureContext::new();
680 ctx.insert("organization_id", json!(123));
681
682 assert!(check(&opts, "organizations:test-feature", &ctx));
683 }
684
685 #[test]
686 fn test_undefined_feature_returns_false() {
687 let cond = in_condition("organization_id", "123");
688 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
689
690 let ctx = FeatureContext::new();
691 assert!(!check(&opts, "nonexistent", &ctx));
692 }
693
694 #[test]
695 fn test_missing_context_field_returns_false() {
696 let cond = in_condition("organization_id", "123");
697 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
698
699 let ctx = FeatureContext::new();
700 assert!(!check(&opts, "organizations:test-feature", &ctx));
701 }
702
703 #[test]
704 fn test_matching_context_returns_true() {
705 let cond = in_condition("organization_id", "123, 456");
706 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
707
708 let mut ctx = FeatureContext::new();
709 ctx.insert("organization_id", json!(123));
710
711 assert!(check(&opts, "organizations:test-feature", &ctx));
712 }
713
714 #[test]
715 fn test_non_matching_context_returns_false() {
716 let cond = in_condition("organization_id", "123, 456");
717 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
718
719 let mut ctx = FeatureContext::new();
720 ctx.insert("organization_id", json!(999));
721
722 assert!(!check(&opts, "organizations:test-feature", &ctx));
723 }
724
725 #[test]
726 fn test_disabled_feature_returns_false() {
727 let cond = in_condition("organization_id", "123");
728 let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
729
730 let mut ctx = FeatureContext::new();
731 ctx.insert("organization_id", json!(123));
732
733 assert!(!check(&opts, "organizations:test-feature", &ctx));
734 }
735
736 #[test]
737 fn test_rollout_zero_returns_false() {
738 let cond = in_condition("organization_id", "123");
739 let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
740
741 let mut ctx = FeatureContext::new();
742 ctx.insert("organization_id", json!(123));
743
744 assert!(!check(&opts, "organizations:test-feature", &ctx));
745 }
746
747 #[test]
748 fn test_rollout_100_returns_true() {
749 let cond = in_condition("organization_id", "123");
750 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
751
752 let mut ctx = FeatureContext::new();
753 ctx.insert("organization_id", json!(123));
754
755 assert!(check(&opts, "organizations:test-feature", &ctx));
756 }
757
758 #[test]
759 fn test_rollout_is_deterministic() {
760 let mut ctx = FeatureContext::new();
761 ctx.identity_fields(vec!["user_id"]);
762 ctx.insert("user_id", json!(42));
763 ctx.insert("organization_id", json!(123));
764
765 let id_mod = (ctx.id() % 100) + 1;
767 let cond = in_condition("organization_id", "123");
768
769 let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
770 assert!(check(&opts_at, "organizations:test-feature", &ctx));
771 }
772
773 #[test]
774 fn test_condition_in_string_case_insensitive() {
775 let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
776 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
777
778 let mut ctx = FeatureContext::new();
779 ctx.insert("slug", json!("sentry"));
780 assert!(check(&opts, "organizations:test-feature", &ctx));
781 }
782
783 #[test]
784 fn test_condition_not_in() {
785 let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
786 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
787
788 let mut ctx = FeatureContext::new();
789 ctx.insert("organization_id", json!(123));
790 assert!(check(&opts, "organizations:test-feature", &ctx));
791
792 let mut ctx2 = FeatureContext::new();
793 ctx2.insert("organization_id", json!(999));
794 assert!(!check(&opts, "organizations:test-feature", &ctx2));
795 }
796
797 #[test]
798 fn test_condition_in_list_property_matches_on_overlap() {
799 let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
800 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
801
802 let mut ctx = FeatureContext::new();
803 ctx.insert("plan_family", json!(["enterprise business", "Business"]));
804 assert!(check(&opts, "organizations:test-feature", &ctx));
805
806 let mut ctx2 = FeatureContext::new();
807 ctx2.insert("plan_family", json!(["team"]));
808 assert!(!check(&opts, "organizations:test-feature", &ctx2));
809
810 let mut ctx3 = FeatureContext::new();
811 ctx3.insert("plan_family", json!([]));
812 assert!(!check(&opts, "organizations:test-feature", &ctx3));
813 }
814
815 #[test]
816 fn test_condition_not_in_list_property_requires_no_overlap() {
817 let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
818 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
819
820 let mut ctx = FeatureContext::new();
821 ctx.insert("plan_family", json!(["enterprise business", "business"]));
822 assert!(!check(&opts, "organizations:test-feature", &ctx));
823
824 let mut ctx2 = FeatureContext::new();
825 ctx2.insert("plan_family", json!(["team"]));
826 assert!(check(&opts, "organizations:test-feature", &ctx2));
827 }
828
829 #[test]
830 fn test_condition_in_list_property_no_type_coercion() {
831 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
832 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
833
834 let mut ctx = FeatureContext::new();
835 ctx.insert("org_id", json!([123]));
836 assert!(!check(&opts, "organizations:test-feature", &ctx));
837
838 let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
839 let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
840 assert!(check(&int_opts, "organizations:test-feature", &ctx));
841 }
842
843 #[test]
844 fn test_condition_in_matches_across_int_and_float() {
845 let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
846 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
847
848 let mut ctx = FeatureContext::new();
849 ctx.insert("org_id", json!(123));
850 assert!(check(&opts, "organizations:test-feature", &ctx));
851
852 let mut list_ctx = FeatureContext::new();
853 list_ctx.insert("org_id", json!([123]));
854 assert!(check(&opts, "organizations:test-feature", &list_ctx));
855
856 let mut ctx2 = FeatureContext::new();
857 ctx2.insert("org_id", json!(124));
858 assert!(!check(&opts, "organizations:test-feature", &ctx2));
859 }
860
861 #[test]
862 fn test_condition_in_large_ints_compare_exactly() {
863 let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
865 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
866
867 let mut ctx = FeatureContext::new();
868 ctx.insert("org_id", json!(9007199254740992i64));
869 assert!(!check(&opts, "organizations:test-feature", &ctx));
870
871 let mut ctx2 = FeatureContext::new();
872 ctx2.insert("org_id", json!(9007199254740993i64));
873 assert!(check(&opts, "organizations:test-feature", &ctx2));
874
875 let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
877 let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
878
879 let mut ctx3 = FeatureContext::new();
880 ctx3.insert("org_id", json!(18446744073709551614u64));
881 assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
882
883 let mut ctx4 = FeatureContext::new();
884 ctx4.insert("org_id", json!(18446744073709551615u64));
885 assert!(check(&big_opts, "organizations:test-feature", &ctx4));
886 }
887
888 #[test]
889 fn test_condition_contains() {
890 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
891 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
892
893 let mut ctx = FeatureContext::new();
894 ctx.insert("tags", json!(["alpha", "beta"]));
895 assert!(check(&opts, "organizations:test-feature", &ctx));
896
897 let mut ctx2 = FeatureContext::new();
898 ctx2.insert("tags", json!(["alpha"]));
899 assert!(!check(&opts, "organizations:test-feature", &ctx2));
900 }
901
902 #[test]
903 fn test_condition_contains_matches_across_int_and_float() {
904 let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
905 let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
906
907 let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
908 let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
909
910 let mut int_ctx = FeatureContext::new();
911 int_ctx.insert("ids", json!([123]));
912 let mut float_ctx = FeatureContext::new();
913 float_ctx.insert("ids", json!([123.0]));
914
915 assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
917 assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
918 assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
919 assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
920
921 let mut other = FeatureContext::new();
922 other.insert("ids", json!([124]));
923 assert!(!check(&int_opts, "organizations:test-feature", &other));
924 }
925
926 #[test]
927 fn test_condition_equals() {
928 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
929 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
930
931 let mut ctx = FeatureContext::new();
932 ctx.insert("plan", json!("Enterprise"));
933 assert!(check(&opts, "organizations:test-feature", &ctx));
934
935 let mut ctx2 = FeatureContext::new();
936 ctx2.insert("plan", json!("free"));
937 assert!(!check(&opts, "organizations:test-feature", &ctx2));
938 }
939
940 #[test]
941 fn test_condition_equals_large_ints_compare_exactly() {
942 let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
944 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
945
946 let mut ctx = FeatureContext::new();
947 ctx.insert("org_id", json!(18446744073709551614u64));
948 assert!(!check(&opts, "organizations:test-feature", &ctx));
949
950 let mut ctx2 = FeatureContext::new();
951 ctx2.insert("org_id", json!(18446744073709551615u64));
952 assert!(check(&opts, "organizations:test-feature", &ctx2));
953 }
954
955 #[test]
956 fn test_condition_equals_bool() {
957 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
958 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
959
960 let mut ctx = FeatureContext::new();
961 ctx.insert("is_free", json!(true));
962 assert!(check(&opts, "organizations:test-feature", &ctx));
963
964 let mut ctx2 = FeatureContext::new();
965 ctx2.insert("is_free", json!(false));
966 assert!(!check(&opts, "organizations:test-feature", &ctx2));
967 }
968
969 #[test]
970 fn test_segment_with_no_conditions_always_matches() {
971 let feature = r#"{
972 "name": "test-feature",
973 "enabled": true,
974 "owner": {"team": "test-team"},
975 "created_at": "2024-01-01",
976 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
977 }"#;
978 let (opts, _t) = setup_feature_options(feature);
979
980 let ctx = FeatureContext::new();
981 assert!(check(&opts, "organizations:test-feature", &ctx));
982 }
983
984 #[test]
985 fn test_feature_enabled_and_rollout_default_values() {
986 let feature = r#"{
988 "name": "test-feature",
989 "owner": {"team": "test-team"},
990 "created_at": "2024-01-01",
991 "segments": [
992 {
993 "name": "first",
994 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
995 }
996 ]
997 }"#;
998 let (opts, _t) = setup_feature_options(feature);
999
1000 let mut ctx = FeatureContext::new();
1001 ctx.insert("org_id", 1);
1002 ctx.identity_fields(vec!["org_id"]);
1003 assert!(check(&opts, "organizations:test-feature", &ctx));
1004 }
1005
1006 #[test]
1007 fn test_feature_with_no_segments_returns_false() {
1008 let feature = r#"{
1009 "name": "test-feature",
1010 "enabled": true,
1011 "owner": {"team": "test-team"},
1012 "created_at": "2024-01-01",
1013 "segments": []
1014 }"#;
1015 let (opts, _t) = setup_feature_options(feature);
1016
1017 let ctx = FeatureContext::new();
1018 assert!(!check(&opts, "organizations:test-feature", &ctx));
1019 }
1020
1021 #[test]
1022 fn test_multiple_segments_or_logic() {
1023 let feature = r#"{
1024 "name": "test-feature",
1025 "enabled": true,
1026 "owner": {"team": "test-team"},
1027 "created_at": "2024-01-01",
1028 "segments": [
1029 {
1030 "name": "segment-a",
1031 "rollout": 100,
1032 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1033 },
1034 {
1035 "name": "segment-b",
1036 "rollout": 100,
1037 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1038 }
1039 ]
1040 }"#;
1041 let (opts, _t) = setup_feature_options(feature);
1042
1043 let mut ctx1 = FeatureContext::new();
1044 ctx1.insert("org_id", json!(1));
1045 assert!(check(&opts, "organizations:test-feature", &ctx1));
1046
1047 let mut ctx2 = FeatureContext::new();
1048 ctx2.insert("org_id", json!(2));
1049 assert!(check(&opts, "organizations:test-feature", &ctx2));
1050
1051 let mut ctx3 = FeatureContext::new();
1052 ctx3.insert("org_id", json!(3));
1053 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1054 }
1055
1056 #[test]
1057 fn test_multiple_conditions_and_logic() {
1058 let conds = r#"
1059 {"property": "org_id", "operator": "in", "value": [123]},
1060 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1061 "#;
1062 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1063
1064 let mut ctx = FeatureContext::new();
1065 ctx.insert("org_id", json!(123));
1066 ctx.insert("user_email", json!("admin@example.com"));
1067 assert!(check(&opts, "organizations:test-feature", &ctx));
1068
1069 let mut ctx2 = FeatureContext::new();
1070 ctx2.insert("org_id", json!(123));
1071 ctx2.insert("user_email", json!("other@example.com"));
1072 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1073 }
1074
1075 #[test]
1076 fn test_in_int_context_against_string_list_returns_false() {
1077 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1078 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1079
1080 let mut ctx = FeatureContext::new();
1081 ctx.insert("org_id", json!(123));
1082 assert!(!check(&opts, "organizations:test-feature", &ctx));
1083 }
1084
1085 #[test]
1086 fn test_in_string_context_against_int_list_returns_false() {
1087 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1088 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1089
1090 let mut ctx = FeatureContext::new();
1091 ctx.insert("slug", json!("123"));
1092 assert!(!check(&opts, "organizations:test-feature", &ctx));
1093 }
1094
1095 #[test]
1096 fn test_in_bool_context_against_string_list_returns_false() {
1097 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1098 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1099
1100 let mut ctx = FeatureContext::new();
1101 ctx.insert("active", json!(true));
1102 assert!(!check(&opts, "organizations:test-feature", &ctx));
1103 }
1104
1105 #[test]
1106 fn test_in_float_context_against_string_list_returns_false() {
1107 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1108 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1109
1110 let mut ctx = FeatureContext::new();
1111 ctx.insert("score", json!(0.5));
1112 assert!(!check(&opts, "organizations:test-feature", &ctx));
1113 }
1114
1115 #[test]
1116 fn test_not_in_int_context_against_string_list_returns_true() {
1117 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1119 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1120
1121 let mut ctx = FeatureContext::new();
1122 ctx.insert("org_id", json!(123));
1123 assert!(check(&opts, "organizations:test-feature", &ctx));
1124 }
1125
1126 #[test]
1127 fn test_not_in_string_context_against_int_list_returns_true() {
1128 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1130 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1131
1132 let mut ctx = FeatureContext::new();
1133 ctx.insert("slug", json!("123"));
1134 assert!(check(&opts, "organizations:test-feature", &ctx));
1135 }
1136
1137 #[test]
1138 fn test_condition_matches_literal() {
1139 let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1140 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1141
1142 let mut ctx = FeatureContext::new();
1143 ctx.insert("slug", json!("sentry"));
1144 assert!(check(&opts, "organizations:test-feature", &ctx));
1145
1146 let mut ctx2 = FeatureContext::new();
1147 ctx2.insert("slug", json!("getsentry"));
1148 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1149 }
1150
1151 #[test]
1152 fn test_condition_matches_prefix_wildcard() {
1153 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1154 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1155
1156 let mut ctx = FeatureContext::new();
1157 ctx.insert("slug", json!("jayonb73"));
1158 assert!(check(&opts, "organizations:test-feature", &ctx));
1159
1160 let mut ctx2 = FeatureContext::new();
1162 ctx2.insert("slug", json!("jayonb"));
1163 assert!(check(&opts, "organizations:test-feature", &ctx2));
1164
1165 let mut ctx3 = FeatureContext::new();
1166 ctx3.insert("slug", json!("dangoldonb1"));
1167 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1168 }
1169
1170 #[test]
1171 fn test_condition_matches_prefix_and_suffix_wildcard() {
1172 let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1173 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1174
1175 let mut ctx = FeatureContext::new();
1176 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1177 assert!(check(&opts, "organizations:test-feature", &ctx));
1178
1179 let mut ctx2 = FeatureContext::new();
1181 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1182 assert!(check(&opts, "organizations:test-feature", &ctx2));
1183
1184 let mut ctx3 = FeatureContext::new();
1185 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1186 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1187 }
1188
1189 #[test]
1190 fn test_condition_matches_suffix_wildcard() {
1191 let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1192 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1193
1194 let mut ctx = FeatureContext::new();
1195 ctx.insert("email", json!("user@sentry.io"));
1196 assert!(check(&opts, "organizations:test-feature", &ctx));
1197
1198 let mut ctx2 = FeatureContext::new();
1199 ctx2.insert("email", json!("user@example.com"));
1200 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1201 }
1202
1203 #[test]
1204 fn test_condition_matches_multi_segment_wildcard() {
1205 let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1206 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1207
1208 let mut ctx = FeatureContext::new();
1209 ctx.insert("name", json!("abc"));
1210 assert!(check(&opts, "organizations:test-feature", &ctx));
1211
1212 let mut ctx2 = FeatureContext::new();
1213 ctx2.insert("name", json!("aXbYc"));
1214 assert!(check(&opts, "organizations:test-feature", &ctx2));
1215
1216 let mut ctx3 = FeatureContext::new();
1217 ctx3.insert("name", json!("aXXbYYc"));
1218 assert!(check(&opts, "organizations:test-feature", &ctx3));
1219
1220 let mut ctx4 = FeatureContext::new();
1221 ctx4.insert("name", json!("aXXc"));
1222 assert!(!check(&opts, "organizations:test-feature", &ctx4));
1223 }
1224
1225 #[test]
1226 fn test_condition_matches_star_only_pattern() {
1227 let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1228 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1229
1230 let mut ctx = FeatureContext::new();
1231 ctx.insert("slug", json!("anything"));
1232 assert!(check(&opts, "organizations:test-feature", &ctx));
1233
1234 let mut ctx2 = FeatureContext::new();
1235 ctx2.insert("slug", json!(""));
1236 assert!(check(&opts, "organizations:test-feature", &ctx2));
1237 }
1238
1239 #[test]
1240 fn test_condition_matches_case_insensitive() {
1241 let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1242 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1243
1244 let mut ctx = FeatureContext::new();
1245 ctx.insert("slug", json!("jayonb73"));
1246 assert!(check(&opts, "organizations:test-feature", &ctx));
1247
1248 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1250 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1251
1252 let mut ctx2 = FeatureContext::new();
1253 ctx2.insert("slug", json!("JAYONB73"));
1254 assert!(check(&opts2, "organizations:test-feature", &ctx2));
1255 }
1256
1257 #[test]
1258 fn test_condition_matches_no_match() {
1259 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1260 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1261
1262 let mut ctx = FeatureContext::new();
1263 ctx.insert("slug", json!("dangoldonb1"));
1264 assert!(!check(&opts, "organizations:test-feature", &ctx));
1265 }
1266
1267 #[test]
1268 fn test_condition_matches_multiple_patterns() {
1269 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1270 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1271
1272 let slugs = [
1273 ("jayonb73", true),
1274 ("dangoldonb3", true),
1275 ("value-disc-7", true),
1276 ("other-org", false),
1277 ];
1278 for (slug, expected) in slugs {
1279 let mut ctx = FeatureContext::new();
1280 ctx.insert("slug", json!(slug));
1281 assert_eq!(
1282 check(&opts, "organizations:test-feature", &ctx),
1283 expected,
1284 "slug={slug}"
1285 );
1286 }
1287 }
1288
1289 #[test]
1290 fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1291 let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1293 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1294
1295 let mut ctx = FeatureContext::new();
1296 ctx.insert("slug", json!("a"));
1297 assert!(!check(&opts, "organizations:test-feature", &ctx));
1298
1299 let mut ctx2 = FeatureContext::new();
1300 ctx2.insert("slug", json!("aa"));
1301 assert!(check(&opts, "organizations:test-feature", &ctx2));
1302
1303 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1305 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1306
1307 let mut ctx3 = FeatureContext::new();
1308 ctx3.insert("slug", json!("ab"));
1309 assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1310
1311 let mut ctx4 = FeatureContext::new();
1312 ctx4.insert("slug", json!("abab"));
1313 assert!(check(&opts2, "organizations:test-feature", &ctx4));
1314 }
1315
1316 #[test]
1317 fn test_condition_matches_non_string_context_returns_false() {
1318 let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1320 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1321
1322 let mut ctx = FeatureContext::new();
1323 ctx.insert("org_id", json!(123));
1324 assert!(!check(&opts, "organizations:test-feature", &ctx));
1325 }
1326
1327 #[test]
1328 fn test_condition_not_matches_literal() {
1329 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1330 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1331
1332 let mut ctx = FeatureContext::new();
1334 ctx.insert("slug", json!("sentry"));
1335 assert!(!check(&opts, "organizations:test-feature", &ctx));
1336
1337 let mut ctx2 = FeatureContext::new();
1339 ctx2.insert("slug", json!("getsentry"));
1340 assert!(check(&opts, "organizations:test-feature", &ctx2));
1341 }
1342
1343 #[test]
1344 fn test_condition_not_matches_prefix_wildcard() {
1345 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1346 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1347
1348 let mut ctx = FeatureContext::new();
1349 ctx.insert("slug", json!("jayonb73"));
1350 assert!(!check(&opts, "organizations:test-feature", &ctx));
1351
1352 let mut ctx2 = FeatureContext::new();
1354 ctx2.insert("slug", json!("jayonb"));
1355 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1356
1357 let mut ctx3 = FeatureContext::new();
1358 ctx3.insert("slug", json!("dangoldonb1"));
1359 assert!(check(&opts, "organizations:test-feature", &ctx3));
1360 }
1361
1362 #[test]
1363 fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1364 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1365 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1366
1367 let mut ctx = FeatureContext::new();
1368 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1369 assert!(!check(&opts, "organizations:test-feature", &ctx));
1370
1371 let mut ctx2 = FeatureContext::new();
1372 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1373 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1374
1375 let mut ctx3 = FeatureContext::new();
1376 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1377 assert!(check(&opts, "organizations:test-feature", &ctx3));
1378 }
1379
1380 #[test]
1381 fn test_condition_not_matches_suffix_wildcard() {
1382 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1383 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1384
1385 let mut ctx = FeatureContext::new();
1386 ctx.insert("email", json!("user@sentry.io"));
1387 assert!(!check(&opts, "organizations:test-feature", &ctx));
1388
1389 let mut ctx2 = FeatureContext::new();
1390 ctx2.insert("email", json!("user@example.com"));
1391 assert!(check(&opts, "organizations:test-feature", &ctx2));
1392 }
1393
1394 #[test]
1395 fn test_condition_not_matches_multi_segment_wildcard() {
1396 let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1397 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1398
1399 let mut ctx = FeatureContext::new();
1400 ctx.insert("name", json!("abc"));
1401 assert!(!check(&opts, "organizations:test-feature", &ctx));
1402
1403 let mut ctx2 = FeatureContext::new();
1404 ctx2.insert("name", json!("aXbYc"));
1405 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1406
1407 let mut ctx3 = FeatureContext::new();
1408 ctx3.insert("name", json!("aXXbYYc"));
1409 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1410
1411 let mut ctx4 = FeatureContext::new();
1413 ctx4.insert("name", json!("aXXc"));
1414 assert!(check(&opts, "organizations:test-feature", &ctx4));
1415 }
1416
1417 #[test]
1418 fn test_condition_not_matches_star_only_pattern() {
1419 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1421 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1422
1423 let mut ctx = FeatureContext::new();
1424 ctx.insert("slug", json!("anything"));
1425 assert!(!check(&opts, "organizations:test-feature", &ctx));
1426
1427 let mut ctx2 = FeatureContext::new();
1428 ctx2.insert("slug", json!(""));
1429 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1430 }
1431
1432 #[test]
1433 fn test_condition_not_matches_case_insensitive() {
1434 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1435 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1436
1437 let mut ctx = FeatureContext::new();
1439 ctx.insert("slug", json!("jayonb73"));
1440 assert!(!check(&opts, "organizations:test-feature", &ctx));
1441
1442 let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1444 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1445
1446 let mut ctx2 = FeatureContext::new();
1447 ctx2.insert("slug", json!("JAYONB73"));
1448 assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1449 }
1450
1451 #[test]
1452 fn test_condition_not_matches_no_match() {
1453 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1454 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1455
1456 let mut ctx = FeatureContext::new();
1457 ctx.insert("slug", json!("dangoldonb1"));
1458 assert!(check(&opts, "organizations:test-feature", &ctx));
1459 }
1460
1461 #[test]
1462 fn test_condition_not_matches_multiple_patterns() {
1463 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1464 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1465
1466 let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1468 for slug in slugs_false {
1469 let mut ctx = FeatureContext::new();
1470 ctx.insert("slug", json!(slug));
1471 assert!(
1472 !check(&opts, "organizations:test-feature", &ctx),
1473 "slug={slug} should not match"
1474 );
1475 }
1476
1477 let mut ctx = FeatureContext::new();
1479 ctx.insert("slug", json!("other-org"));
1480 assert!(check(&opts, "organizations:test-feature", &ctx));
1481 }
1482
1483 #[test]
1484 fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1485 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1487 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1488
1489 let mut ctx = FeatureContext::new();
1490 ctx.insert("slug", json!("a"));
1491 assert!(check(&opts, "organizations:test-feature", &ctx));
1492
1493 let mut ctx2 = FeatureContext::new();
1494 ctx2.insert("slug", json!("aa"));
1495 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1496 }
1497
1498 #[test]
1499 fn test_condition_not_matches_non_string_context_returns_true() {
1500 let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1502 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1503
1504 let mut ctx = FeatureContext::new();
1505 ctx.insert("org_id", json!(123));
1506 assert!(check(&opts, "organizations:test-feature", &ctx));
1507 }
1508
1509 #[test]
1510 fn test_id_string_is_stable() {
1511 for (value, expected) in [
1512 (json!("sentry"), "sentry"),
1513 (json!(123), "123"),
1514 (json!(-7), "-7"),
1515 (json!(true), "True"),
1516 (json!(false), "False"),
1517 (json!(null), "None"),
1518 (json!(1.0), "1.0"),
1519 (json!(1.5), "1.5"),
1520 (json!(["a", "b"]), "[a, b]"),
1521 (json!([1, 2]), "[1, 2]"),
1522 (json!([]), "[]"),
1523 ] {
1524 assert_eq!(value_to_id_string(&value), expected, "for {value}");
1525 }
1526 }
1527
1528 fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1529 let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1530 Condition::from_json(&serde_json::from_str(&json).unwrap())
1531 .expect("condition should parse")
1532 .matches(ctx)
1533 }
1534
1535 #[test]
1536 fn test_missing_property_matches_only_negated_conditions() {
1537 let ctx = FeatureContext::new();
1538 for (operator, value, expected) in [
1539 ("in", "[1]", false),
1540 ("not_in", "[1]", true),
1541 ("equals", "1", false),
1542 ("not_equals", "1", true),
1543 ("matches", r#"["a"]"#, false),
1544 ("not_matches", r#"["a"]"#, true),
1545 ("contains", r#""a""#, false),
1547 ("not_contains", r#""a""#, false),
1548 ] {
1549 assert_eq!(
1550 condition_matches(operator, value, &ctx),
1551 expected,
1552 "{operator}"
1553 );
1554 }
1555 }
1556
1557 #[test]
1558 fn test_unusable_property_type_matches_neither_form() {
1559 let mut map = FeatureContext::new();
1560 map.insert("prop", json!({"k": "v"}));
1561 let mut scalar = FeatureContext::new();
1562 scalar.insert("prop", json!("a"));
1563 let mut list = FeatureContext::new();
1564 list.insert("prop", json!(["b"]));
1565
1566 for (operator, value, ctx) in [
1567 ("in", "[1]", &map),
1568 ("not_in", "[1]", &map),
1569 ("contains", r#""a""#, &scalar),
1570 ("not_contains", r#""a""#, &scalar),
1571 ("matches", r#"["b"]"#, &list),
1572 ("not_matches", r#"["b"]"#, &list),
1573 ] {
1574 assert!(!condition_matches(operator, value, ctx), "{operator}");
1575 }
1576
1577 assert!(condition_matches("not_contains", r#""a""#, &list));
1579 }
1580}