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
436#[derive(Debug, thiserror::Error)]
441pub enum FeatureError {
442 #[error("Options not initialized - call init() first")]
443 NotInitialized,
444
445 #[error("Value for '{key}' is not a valid feature")]
446 InvalidValue { key: String },
447
448 #[error(transparent)]
449 Options(#[from] crate::OptionsError),
450}
451
452pub struct FeatureChecker {
453 namespace: String,
454 options: Option<&'static crate::Options>,
455}
456
457impl FeatureChecker {
458 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
459 Self {
460 namespace,
461 options: Some(options),
462 }
463 }
464
465 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
470 match self.try_has(feature_name, context) {
472 Ok(Some(result)) => result,
473 Ok(None) => false,
474 Err(e) => {
475 tracing::debug!(feature = feature_name, error = %e, "Feature evaluation failed");
476 false
477 }
478 }
479 }
480
481 pub fn try_has(
490 &self,
491 feature_name: &str,
492 context: &FeatureContext,
493 ) -> Result<Option<bool>, FeatureError> {
494 let opts = self.options.ok_or(FeatureError::NotInitialized)?;
495 let key = format!("feature.{feature_name}");
496
497 let feature_val = match opts.get(&self.namespace, &key) {
498 Ok(v) => v,
499 Err(crate::OptionsError::UnknownOption { .. }) => return Ok(None),
502 Err(e) => return Err(e.into()),
503 };
504
505 let feature = Feature::from_json(&feature_val)
506 .ok_or_else(|| FeatureError::InvalidValue { key: key.clone() })?;
507
508 let result = feature.matches(context);
509 tracing::debug!(
510 feature = feature_name,
511 result,
512 context_id = context.id(),
513 "Feature match result"
514 );
515 Ok(Some(result))
516 }
517}
518
519pub fn features(namespace: &str) -> FeatureChecker {
523 FeatureChecker {
524 namespace: namespace.to_string(),
525 options: crate::GLOBAL_OPTIONS.get(),
526 }
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::Options;
533 use serde_json::json;
534 use std::fs;
535 use std::path::Path;
536 use tempfile::TempDir;
537
538 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
539 let schema_dir = dir.join(namespace);
540 fs::create_dir_all(&schema_dir).unwrap();
541 fs::write(schema_dir.join("schema.json"), schema).unwrap();
542 }
543
544 fn create_values(dir: &Path, namespace: &str, values: &str) {
545 let ns_dir = dir.join(namespace);
546 fs::create_dir_all(&ns_dir).unwrap();
547 fs::write(ns_dir.join("values.json"), values).unwrap();
548 }
549
550 const FEATURE_SCHEMA: &str = r##"{
551 "version": "1.0",
552 "type": "object",
553 "properties": {
554 "feature.organizations:test-feature": {
555 "$ref": "#/definitions/Feature"
556 }
557 }
558 }"##;
559
560 fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
561 let temp = TempDir::new().unwrap();
562 let schemas = temp.path().join("schemas");
563 fs::create_dir_all(&schemas).unwrap();
564 create_schema(&schemas, "test", FEATURE_SCHEMA);
565
566 let values = temp.path().join("values");
567 let values_json = format!(
568 r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
569 feature_json
570 );
571 create_values(&values, "test", &values_json);
572
573 let opts = Options::from_directory(temp.path()).unwrap();
574 (opts, temp)
575 }
576
577 fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
578 format!(
579 r#"{{
580 "name": "test-feature",
581 "enabled": {enabled},
582 "owner": {{"team": "test-team"}},
583 "created_at": "2024-01-01",
584 "segments": [{{
585 "name": "test-segment",
586 "rollout": {rollout},
587 "conditions": [{conditions}]
588 }}]
589 }}"#
590 )
591 }
592
593 fn in_condition(property: &str, values: &str) -> String {
594 format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
595 }
596
597 fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
598 let key = format!("feature.{feature}");
599 let Ok(val) = opts.get("test", &key) else {
600 return false;
601 };
602 Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
603 }
604
605 #[test]
606 fn test_feature_context_insert_and_get() {
607 let mut ctx = FeatureContext::new();
608 ctx.insert("org_id", json!(123));
609 ctx.insert("name", json!("sentry"));
610 ctx.insert("active", json!(true));
611
612 assert!(ctx.has("org_id"));
613 assert!(!ctx.has("missing"));
614 assert_eq!(ctx.get("org_id"), Some(&json!(123)));
615 assert_eq!(ctx.get("name"), Some(&json!("sentry")));
616 }
617
618 #[test]
619 fn test_feature_context_id_is_cached() {
620 let mut ctx = FeatureContext::new();
621 ctx.identity_fields(vec!["user_id"]);
622 ctx.insert("user_id", json!(42));
623
624 let id1 = ctx.id();
625 let id2 = ctx.id();
626 assert_eq!(id1, id2, "ID should be cached and consistent");
627 }
628
629 #[test]
630 fn test_feature_context_id_resets_on_identity_change() {
631 let mut ctx = FeatureContext::new();
632 ctx.insert("user_id", json!(1));
633 ctx.insert("org_id", json!(2));
634
635 ctx.identity_fields(vec!["user_id"]);
636 let id_user = ctx.id();
637
638 ctx.identity_fields(vec!["org_id"]);
639 let id_org = ctx.id();
640
641 assert_ne!(
642 id_user, id_org,
643 "Different identity fields should produce different IDs"
644 );
645 }
646
647 #[test]
648 fn test_feature_context_id_deterministic() {
649 let make_ctx = || {
650 let mut ctx = FeatureContext::new();
651 ctx.identity_fields(vec!["user_id", "org_id"]);
652 ctx.insert("user_id", json!(456));
653 ctx.insert("org_id", json!(123));
654 ctx
655 };
656
657 assert_eq!(make_ctx().id(), make_ctx().id());
658
659 let mut other_ctx = FeatureContext::new();
660 other_ctx.identity_fields(vec!["user_id", "org_id"]);
661 other_ctx.insert("user_id", json!(789));
662 other_ctx.insert("org_id", json!(123));
663
664 assert_ne!(make_ctx().id(), other_ctx.id());
665 }
666
667 #[test]
668 fn test_feature_context_id_value_align_with_python() {
669 let ctx = FeatureContext::new();
673 assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
674
675 let mut ctx = FeatureContext::new();
676 ctx.insert("foo", json!("bar"));
677 ctx.insert("baz", json!("barfoo"));
678 ctx.identity_fields(vec!["foo"]);
679 assert_eq!(ctx.id() % 100, 62);
680
681 let mut ctx = FeatureContext::new();
683 ctx.insert("foo", json!("bar"));
684 ctx.insert("baz", json!("barfoo"));
685 ctx.identity_fields(vec!["foo", "whoops"]);
686 assert_eq!(ctx.id() % 100, 62);
687
688 let mut ctx = FeatureContext::new();
689 ctx.insert("foo", json!("bar"));
690 ctx.insert("baz", json!("barfoo"));
691 ctx.identity_fields(vec!["foo", "baz"]);
692 assert_eq!(ctx.id() % 100, 1);
693
694 let mut ctx = FeatureContext::new();
695 ctx.insert("foo", json!("bar"));
696 ctx.insert("baz", json!("barfoo"));
697 ctx.identity_fields(vec!["whoops", "nope"]);
700 assert_eq!(ctx.id() % 100, 1);
701 }
702
703 #[test]
704 fn test_feature_prefix_is_added() {
705 let cond = in_condition("organization_id", "123");
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_undefined_feature_returns_false() {
716 let cond = in_condition("organization_id", "123");
717 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
718
719 let ctx = FeatureContext::new();
720 assert!(!check(&opts, "nonexistent", &ctx));
721 }
722
723 #[test]
724 fn test_missing_context_field_returns_false() {
725 let cond = in_condition("organization_id", "123");
726 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
727
728 let ctx = FeatureContext::new();
729 assert!(!check(&opts, "organizations:test-feature", &ctx));
730 }
731
732 #[test]
733 fn test_matching_context_returns_true() {
734 let cond = in_condition("organization_id", "123, 456");
735 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
736
737 let mut ctx = FeatureContext::new();
738 ctx.insert("organization_id", json!(123));
739
740 assert!(check(&opts, "organizations:test-feature", &ctx));
741 }
742
743 #[test]
744 fn test_non_matching_context_returns_false() {
745 let cond = in_condition("organization_id", "123, 456");
746 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
747
748 let mut ctx = FeatureContext::new();
749 ctx.insert("organization_id", json!(999));
750
751 assert!(!check(&opts, "organizations:test-feature", &ctx));
752 }
753
754 #[test]
755 fn test_disabled_feature_returns_false() {
756 let cond = in_condition("organization_id", "123");
757 let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
758
759 let mut ctx = FeatureContext::new();
760 ctx.insert("organization_id", json!(123));
761
762 assert!(!check(&opts, "organizations:test-feature", &ctx));
763 }
764
765 #[test]
766 fn test_rollout_zero_returns_false() {
767 let cond = in_condition("organization_id", "123");
768 let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
769
770 let mut ctx = FeatureContext::new();
771 ctx.insert("organization_id", json!(123));
772
773 assert!(!check(&opts, "organizations:test-feature", &ctx));
774 }
775
776 #[test]
777 fn test_rollout_100_returns_true() {
778 let cond = in_condition("organization_id", "123");
779 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
780
781 let mut ctx = FeatureContext::new();
782 ctx.insert("organization_id", json!(123));
783
784 assert!(check(&opts, "organizations:test-feature", &ctx));
785 }
786
787 #[test]
788 fn test_rollout_is_deterministic() {
789 let mut ctx = FeatureContext::new();
790 ctx.identity_fields(vec!["user_id"]);
791 ctx.insert("user_id", json!(42));
792 ctx.insert("organization_id", json!(123));
793
794 let id_mod = (ctx.id() % 100) + 1;
796 let cond = in_condition("organization_id", "123");
797
798 let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
799 assert!(check(&opts_at, "organizations:test-feature", &ctx));
800 }
801
802 #[test]
803 fn test_condition_in_string_case_insensitive() {
804 let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
805 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
806
807 let mut ctx = FeatureContext::new();
808 ctx.insert("slug", json!("sentry"));
809 assert!(check(&opts, "organizations:test-feature", &ctx));
810 }
811
812 #[test]
813 fn test_condition_not_in() {
814 let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
815 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
816
817 let mut ctx = FeatureContext::new();
818 ctx.insert("organization_id", json!(123));
819 assert!(check(&opts, "organizations:test-feature", &ctx));
820
821 let mut ctx2 = FeatureContext::new();
822 ctx2.insert("organization_id", json!(999));
823 assert!(!check(&opts, "organizations:test-feature", &ctx2));
824 }
825
826 #[test]
827 fn test_condition_in_list_property_matches_on_overlap() {
828 let cond = r#"{"property": "plan_family", "operator": "in", "value": ["business"]}"#;
829 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
830
831 let mut ctx = FeatureContext::new();
832 ctx.insert("plan_family", json!(["enterprise business", "Business"]));
833 assert!(check(&opts, "organizations:test-feature", &ctx));
834
835 let mut ctx2 = FeatureContext::new();
836 ctx2.insert("plan_family", json!(["team"]));
837 assert!(!check(&opts, "organizations:test-feature", &ctx2));
838
839 let mut ctx3 = FeatureContext::new();
840 ctx3.insert("plan_family", json!([]));
841 assert!(!check(&opts, "organizations:test-feature", &ctx3));
842 }
843
844 #[test]
845 fn test_condition_not_in_list_property_requires_no_overlap() {
846 let cond = r#"{"property": "plan_family", "operator": "not_in", "value": ["business"]}"#;
847 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
848
849 let mut ctx = FeatureContext::new();
850 ctx.insert("plan_family", json!(["enterprise business", "business"]));
851 assert!(!check(&opts, "organizations:test-feature", &ctx));
852
853 let mut ctx2 = FeatureContext::new();
854 ctx2.insert("plan_family", json!(["team"]));
855 assert!(check(&opts, "organizations:test-feature", &ctx2));
856 }
857
858 #[test]
859 fn test_condition_in_list_property_no_type_coercion() {
860 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123"]}"#;
861 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
862
863 let mut ctx = FeatureContext::new();
864 ctx.insert("org_id", json!([123]));
865 assert!(!check(&opts, "organizations:test-feature", &ctx));
866
867 let int_cond = r#"{"property": "org_id", "operator": "in", "value": [123]}"#;
868 let (int_opts, _t2) = setup_feature_options(&feature_json(true, 100, int_cond));
869 assert!(check(&int_opts, "organizations:test-feature", &ctx));
870 }
871
872 #[test]
873 fn test_condition_in_matches_across_int_and_float() {
874 let cond = r#"{"property": "org_id", "operator": "in", "value": [123.0]}"#;
875 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
876
877 let mut ctx = FeatureContext::new();
878 ctx.insert("org_id", json!(123));
879 assert!(check(&opts, "organizations:test-feature", &ctx));
880
881 let mut list_ctx = FeatureContext::new();
882 list_ctx.insert("org_id", json!([123]));
883 assert!(check(&opts, "organizations:test-feature", &list_ctx));
884
885 let mut ctx2 = FeatureContext::new();
886 ctx2.insert("org_id", json!(124));
887 assert!(!check(&opts, "organizations:test-feature", &ctx2));
888 }
889
890 #[test]
891 fn test_condition_in_large_ints_compare_exactly() {
892 let cond = r#"{"property": "org_id", "operator": "in", "value": [9007199254740993]}"#;
894 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
895
896 let mut ctx = FeatureContext::new();
897 ctx.insert("org_id", json!(9007199254740992i64));
898 assert!(!check(&opts, "organizations:test-feature", &ctx));
899
900 let mut ctx2 = FeatureContext::new();
901 ctx2.insert("org_id", json!(9007199254740993i64));
902 assert!(check(&opts, "organizations:test-feature", &ctx2));
903
904 let big = r#"{"property": "org_id", "operator": "in", "value": [18446744073709551615]}"#;
906 let (big_opts, _t2) = setup_feature_options(&feature_json(true, 100, big));
907
908 let mut ctx3 = FeatureContext::new();
909 ctx3.insert("org_id", json!(18446744073709551614u64));
910 assert!(!check(&big_opts, "organizations:test-feature", &ctx3));
911
912 let mut ctx4 = FeatureContext::new();
913 ctx4.insert("org_id", json!(18446744073709551615u64));
914 assert!(check(&big_opts, "organizations:test-feature", &ctx4));
915 }
916
917 #[test]
918 fn test_condition_contains() {
919 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
920 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
921
922 let mut ctx = FeatureContext::new();
923 ctx.insert("tags", json!(["alpha", "beta"]));
924 assert!(check(&opts, "organizations:test-feature", &ctx));
925
926 let mut ctx2 = FeatureContext::new();
927 ctx2.insert("tags", json!(["alpha"]));
928 assert!(!check(&opts, "organizations:test-feature", &ctx2));
929 }
930
931 #[test]
932 fn test_condition_contains_matches_across_int_and_float() {
933 let int_cond = r#"{"property": "ids", "operator": "contains", "value": 123}"#;
934 let (int_opts, _t) = setup_feature_options(&feature_json(true, 100, int_cond));
935
936 let float_cond = r#"{"property": "ids", "operator": "contains", "value": 123.0}"#;
937 let (float_opts, _t2) = setup_feature_options(&feature_json(true, 100, float_cond));
938
939 let mut int_ctx = FeatureContext::new();
940 int_ctx.insert("ids", json!([123]));
941 let mut float_ctx = FeatureContext::new();
942 float_ctx.insert("ids", json!([123.0]));
943
944 assert!(check(&int_opts, "organizations:test-feature", &int_ctx));
946 assert!(check(&int_opts, "organizations:test-feature", &float_ctx));
947 assert!(check(&float_opts, "organizations:test-feature", &int_ctx));
948 assert!(check(&float_opts, "organizations:test-feature", &float_ctx));
949
950 let mut other = FeatureContext::new();
951 other.insert("ids", json!([124]));
952 assert!(!check(&int_opts, "organizations:test-feature", &other));
953 }
954
955 #[test]
956 fn test_condition_equals() {
957 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
958 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
959
960 let mut ctx = FeatureContext::new();
961 ctx.insert("plan", json!("Enterprise"));
962 assert!(check(&opts, "organizations:test-feature", &ctx));
963
964 let mut ctx2 = FeatureContext::new();
965 ctx2.insert("plan", json!("free"));
966 assert!(!check(&opts, "organizations:test-feature", &ctx2));
967 }
968
969 #[test]
970 fn test_condition_equals_large_ints_compare_exactly() {
971 let cond = r#"{"property": "org_id", "operator": "equals", "value": 18446744073709551615}"#;
973 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
974
975 let mut ctx = FeatureContext::new();
976 ctx.insert("org_id", json!(18446744073709551614u64));
977 assert!(!check(&opts, "organizations:test-feature", &ctx));
978
979 let mut ctx2 = FeatureContext::new();
980 ctx2.insert("org_id", json!(18446744073709551615u64));
981 assert!(check(&opts, "organizations:test-feature", &ctx2));
982 }
983
984 #[test]
985 fn test_condition_equals_bool() {
986 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
987 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
988
989 let mut ctx = FeatureContext::new();
990 ctx.insert("is_free", json!(true));
991 assert!(check(&opts, "organizations:test-feature", &ctx));
992
993 let mut ctx2 = FeatureContext::new();
994 ctx2.insert("is_free", json!(false));
995 assert!(!check(&opts, "organizations:test-feature", &ctx2));
996 }
997
998 #[test]
999 fn test_segment_with_no_conditions_always_matches() {
1000 let feature = r#"{
1001 "name": "test-feature",
1002 "enabled": true,
1003 "owner": {"team": "test-team"},
1004 "created_at": "2024-01-01",
1005 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
1006 }"#;
1007 let (opts, _t) = setup_feature_options(feature);
1008
1009 let ctx = FeatureContext::new();
1010 assert!(check(&opts, "organizations:test-feature", &ctx));
1011 }
1012
1013 #[test]
1014 fn test_feature_enabled_and_rollout_default_values() {
1015 let feature = r#"{
1017 "name": "test-feature",
1018 "owner": {"team": "test-team"},
1019 "created_at": "2024-01-01",
1020 "segments": [
1021 {
1022 "name": "first",
1023 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
1024 }
1025 ]
1026 }"#;
1027 let (opts, _t) = setup_feature_options(feature);
1028
1029 let mut ctx = FeatureContext::new();
1030 ctx.insert("org_id", 1);
1031 ctx.identity_fields(vec!["org_id"]);
1032 assert!(check(&opts, "organizations:test-feature", &ctx));
1033 }
1034
1035 #[test]
1036 fn test_feature_with_no_segments_returns_false() {
1037 let feature = r#"{
1038 "name": "test-feature",
1039 "enabled": true,
1040 "owner": {"team": "test-team"},
1041 "created_at": "2024-01-01",
1042 "segments": []
1043 }"#;
1044 let (opts, _t) = setup_feature_options(feature);
1045
1046 let ctx = FeatureContext::new();
1047 assert!(!check(&opts, "organizations:test-feature", &ctx));
1048 }
1049
1050 #[test]
1051 fn test_multiple_segments_or_logic() {
1052 let feature = r#"{
1053 "name": "test-feature",
1054 "enabled": true,
1055 "owner": {"team": "test-team"},
1056 "created_at": "2024-01-01",
1057 "segments": [
1058 {
1059 "name": "segment-a",
1060 "rollout": 100,
1061 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
1062 },
1063 {
1064 "name": "segment-b",
1065 "rollout": 100,
1066 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
1067 }
1068 ]
1069 }"#;
1070 let (opts, _t) = setup_feature_options(feature);
1071
1072 let mut ctx1 = FeatureContext::new();
1073 ctx1.insert("org_id", json!(1));
1074 assert!(check(&opts, "organizations:test-feature", &ctx1));
1075
1076 let mut ctx2 = FeatureContext::new();
1077 ctx2.insert("org_id", json!(2));
1078 assert!(check(&opts, "organizations:test-feature", &ctx2));
1079
1080 let mut ctx3 = FeatureContext::new();
1081 ctx3.insert("org_id", json!(3));
1082 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1083 }
1084
1085 #[test]
1086 fn test_multiple_conditions_and_logic() {
1087 let conds = r#"
1088 {"property": "org_id", "operator": "in", "value": [123]},
1089 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
1090 "#;
1091 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
1092
1093 let mut ctx = FeatureContext::new();
1094 ctx.insert("org_id", json!(123));
1095 ctx.insert("user_email", json!("admin@example.com"));
1096 assert!(check(&opts, "organizations:test-feature", &ctx));
1097
1098 let mut ctx2 = FeatureContext::new();
1099 ctx2.insert("org_id", json!(123));
1100 ctx2.insert("user_email", json!("other@example.com"));
1101 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1102 }
1103
1104 #[test]
1105 fn test_in_int_context_against_string_list_returns_false() {
1106 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
1107 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1108
1109 let mut ctx = FeatureContext::new();
1110 ctx.insert("org_id", json!(123));
1111 assert!(!check(&opts, "organizations:test-feature", &ctx));
1112 }
1113
1114 #[test]
1115 fn test_in_string_context_against_int_list_returns_false() {
1116 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
1117 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1118
1119 let mut ctx = FeatureContext::new();
1120 ctx.insert("slug", json!("123"));
1121 assert!(!check(&opts, "organizations:test-feature", &ctx));
1122 }
1123
1124 #[test]
1125 fn test_in_bool_context_against_string_list_returns_false() {
1126 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
1127 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1128
1129 let mut ctx = FeatureContext::new();
1130 ctx.insert("active", json!(true));
1131 assert!(!check(&opts, "organizations:test-feature", &ctx));
1132 }
1133
1134 #[test]
1135 fn test_in_float_context_against_string_list_returns_false() {
1136 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1137 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1138
1139 let mut ctx = FeatureContext::new();
1140 ctx.insert("score", json!(0.5));
1141 assert!(!check(&opts, "organizations:test-feature", &ctx));
1142 }
1143
1144 #[test]
1145 fn test_not_in_int_context_against_string_list_returns_true() {
1146 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1148 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1149
1150 let mut ctx = FeatureContext::new();
1151 ctx.insert("org_id", json!(123));
1152 assert!(check(&opts, "organizations:test-feature", &ctx));
1153 }
1154
1155 #[test]
1156 fn test_not_in_string_context_against_int_list_returns_true() {
1157 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1159 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1160
1161 let mut ctx = FeatureContext::new();
1162 ctx.insert("slug", json!("123"));
1163 assert!(check(&opts, "organizations:test-feature", &ctx));
1164 }
1165
1166 #[test]
1167 fn test_condition_matches_literal() {
1168 let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1169 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1170
1171 let mut ctx = FeatureContext::new();
1172 ctx.insert("slug", json!("sentry"));
1173 assert!(check(&opts, "organizations:test-feature", &ctx));
1174
1175 let mut ctx2 = FeatureContext::new();
1176 ctx2.insert("slug", json!("getsentry"));
1177 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1178 }
1179
1180 #[test]
1181 fn test_condition_matches_prefix_wildcard() {
1182 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1183 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1184
1185 let mut ctx = FeatureContext::new();
1186 ctx.insert("slug", json!("jayonb73"));
1187 assert!(check(&opts, "organizations:test-feature", &ctx));
1188
1189 let mut ctx2 = FeatureContext::new();
1191 ctx2.insert("slug", json!("jayonb"));
1192 assert!(check(&opts, "organizations:test-feature", &ctx2));
1193
1194 let mut ctx3 = FeatureContext::new();
1195 ctx3.insert("slug", json!("dangoldonb1"));
1196 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1197 }
1198
1199 #[test]
1200 fn test_condition_matches_prefix_and_suffix_wildcard() {
1201 let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1202 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1203
1204 let mut ctx = FeatureContext::new();
1205 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1206 assert!(check(&opts, "organizations:test-feature", &ctx));
1207
1208 let mut ctx2 = FeatureContext::new();
1210 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1211 assert!(check(&opts, "organizations:test-feature", &ctx2));
1212
1213 let mut ctx3 = FeatureContext::new();
1214 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1215 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1216 }
1217
1218 #[test]
1219 fn test_condition_matches_suffix_wildcard() {
1220 let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1221 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1222
1223 let mut ctx = FeatureContext::new();
1224 ctx.insert("email", json!("user@sentry.io"));
1225 assert!(check(&opts, "organizations:test-feature", &ctx));
1226
1227 let mut ctx2 = FeatureContext::new();
1228 ctx2.insert("email", json!("user@example.com"));
1229 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1230 }
1231
1232 #[test]
1233 fn test_condition_matches_multi_segment_wildcard() {
1234 let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1235 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1236
1237 let mut ctx = FeatureContext::new();
1238 ctx.insert("name", json!("abc"));
1239 assert!(check(&opts, "organizations:test-feature", &ctx));
1240
1241 let mut ctx2 = FeatureContext::new();
1242 ctx2.insert("name", json!("aXbYc"));
1243 assert!(check(&opts, "organizations:test-feature", &ctx2));
1244
1245 let mut ctx3 = FeatureContext::new();
1246 ctx3.insert("name", json!("aXXbYYc"));
1247 assert!(check(&opts, "organizations:test-feature", &ctx3));
1248
1249 let mut ctx4 = FeatureContext::new();
1250 ctx4.insert("name", json!("aXXc"));
1251 assert!(!check(&opts, "organizations:test-feature", &ctx4));
1252 }
1253
1254 #[test]
1255 fn test_condition_matches_star_only_pattern() {
1256 let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1257 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1258
1259 let mut ctx = FeatureContext::new();
1260 ctx.insert("slug", json!("anything"));
1261 assert!(check(&opts, "organizations:test-feature", &ctx));
1262
1263 let mut ctx2 = FeatureContext::new();
1264 ctx2.insert("slug", json!(""));
1265 assert!(check(&opts, "organizations:test-feature", &ctx2));
1266 }
1267
1268 #[test]
1269 fn test_condition_matches_case_insensitive() {
1270 let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1271 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1272
1273 let mut ctx = FeatureContext::new();
1274 ctx.insert("slug", json!("jayonb73"));
1275 assert!(check(&opts, "organizations:test-feature", &ctx));
1276
1277 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1279 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1280
1281 let mut ctx2 = FeatureContext::new();
1282 ctx2.insert("slug", json!("JAYONB73"));
1283 assert!(check(&opts2, "organizations:test-feature", &ctx2));
1284 }
1285
1286 #[test]
1287 fn test_condition_matches_no_match() {
1288 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1289 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1290
1291 let mut ctx = FeatureContext::new();
1292 ctx.insert("slug", json!("dangoldonb1"));
1293 assert!(!check(&opts, "organizations:test-feature", &ctx));
1294 }
1295
1296 #[test]
1297 fn test_condition_matches_multiple_patterns() {
1298 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1299 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1300
1301 let slugs = [
1302 ("jayonb73", true),
1303 ("dangoldonb3", true),
1304 ("value-disc-7", true),
1305 ("other-org", false),
1306 ];
1307 for (slug, expected) in slugs {
1308 let mut ctx = FeatureContext::new();
1309 ctx.insert("slug", json!(slug));
1310 assert_eq!(
1311 check(&opts, "organizations:test-feature", &ctx),
1312 expected,
1313 "slug={slug}"
1314 );
1315 }
1316 }
1317
1318 #[test]
1319 fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1320 let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1322 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1323
1324 let mut ctx = FeatureContext::new();
1325 ctx.insert("slug", json!("a"));
1326 assert!(!check(&opts, "organizations:test-feature", &ctx));
1327
1328 let mut ctx2 = FeatureContext::new();
1329 ctx2.insert("slug", json!("aa"));
1330 assert!(check(&opts, "organizations:test-feature", &ctx2));
1331
1332 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1334 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1335
1336 let mut ctx3 = FeatureContext::new();
1337 ctx3.insert("slug", json!("ab"));
1338 assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1339
1340 let mut ctx4 = FeatureContext::new();
1341 ctx4.insert("slug", json!("abab"));
1342 assert!(check(&opts2, "organizations:test-feature", &ctx4));
1343 }
1344
1345 #[test]
1346 fn test_condition_matches_non_string_context_returns_false() {
1347 let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1349 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1350
1351 let mut ctx = FeatureContext::new();
1352 ctx.insert("org_id", json!(123));
1353 assert!(!check(&opts, "organizations:test-feature", &ctx));
1354 }
1355
1356 #[test]
1357 fn test_condition_not_matches_literal() {
1358 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1359 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1360
1361 let mut ctx = FeatureContext::new();
1363 ctx.insert("slug", json!("sentry"));
1364 assert!(!check(&opts, "organizations:test-feature", &ctx));
1365
1366 let mut ctx2 = FeatureContext::new();
1368 ctx2.insert("slug", json!("getsentry"));
1369 assert!(check(&opts, "organizations:test-feature", &ctx2));
1370 }
1371
1372 #[test]
1373 fn test_condition_not_matches_prefix_wildcard() {
1374 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1375 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1376
1377 let mut ctx = FeatureContext::new();
1378 ctx.insert("slug", json!("jayonb73"));
1379 assert!(!check(&opts, "organizations:test-feature", &ctx));
1380
1381 let mut ctx2 = FeatureContext::new();
1383 ctx2.insert("slug", json!("jayonb"));
1384 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1385
1386 let mut ctx3 = FeatureContext::new();
1387 ctx3.insert("slug", json!("dangoldonb1"));
1388 assert!(check(&opts, "organizations:test-feature", &ctx3));
1389 }
1390
1391 #[test]
1392 fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1393 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1394 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1395
1396 let mut ctx = FeatureContext::new();
1397 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1398 assert!(!check(&opts, "organizations:test-feature", &ctx));
1399
1400 let mut ctx2 = FeatureContext::new();
1401 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1402 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1403
1404 let mut ctx3 = FeatureContext::new();
1405 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1406 assert!(check(&opts, "organizations:test-feature", &ctx3));
1407 }
1408
1409 #[test]
1410 fn test_condition_not_matches_suffix_wildcard() {
1411 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1412 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1413
1414 let mut ctx = FeatureContext::new();
1415 ctx.insert("email", json!("user@sentry.io"));
1416 assert!(!check(&opts, "organizations:test-feature", &ctx));
1417
1418 let mut ctx2 = FeatureContext::new();
1419 ctx2.insert("email", json!("user@example.com"));
1420 assert!(check(&opts, "organizations:test-feature", &ctx2));
1421 }
1422
1423 #[test]
1424 fn test_condition_not_matches_multi_segment_wildcard() {
1425 let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1426 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1427
1428 let mut ctx = FeatureContext::new();
1429 ctx.insert("name", json!("abc"));
1430 assert!(!check(&opts, "organizations:test-feature", &ctx));
1431
1432 let mut ctx2 = FeatureContext::new();
1433 ctx2.insert("name", json!("aXbYc"));
1434 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1435
1436 let mut ctx3 = FeatureContext::new();
1437 ctx3.insert("name", json!("aXXbYYc"));
1438 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1439
1440 let mut ctx4 = FeatureContext::new();
1442 ctx4.insert("name", json!("aXXc"));
1443 assert!(check(&opts, "organizations:test-feature", &ctx4));
1444 }
1445
1446 #[test]
1447 fn test_condition_not_matches_star_only_pattern() {
1448 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1450 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1451
1452 let mut ctx = FeatureContext::new();
1453 ctx.insert("slug", json!("anything"));
1454 assert!(!check(&opts, "organizations:test-feature", &ctx));
1455
1456 let mut ctx2 = FeatureContext::new();
1457 ctx2.insert("slug", json!(""));
1458 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1459 }
1460
1461 #[test]
1462 fn test_condition_not_matches_case_insensitive() {
1463 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1464 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1465
1466 let mut ctx = FeatureContext::new();
1468 ctx.insert("slug", json!("jayonb73"));
1469 assert!(!check(&opts, "organizations:test-feature", &ctx));
1470
1471 let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1473 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1474
1475 let mut ctx2 = FeatureContext::new();
1476 ctx2.insert("slug", json!("JAYONB73"));
1477 assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1478 }
1479
1480 #[test]
1481 fn test_condition_not_matches_no_match() {
1482 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1483 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1484
1485 let mut ctx = FeatureContext::new();
1486 ctx.insert("slug", json!("dangoldonb1"));
1487 assert!(check(&opts, "organizations:test-feature", &ctx));
1488 }
1489
1490 #[test]
1491 fn test_condition_not_matches_multiple_patterns() {
1492 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1493 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1494
1495 let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1497 for slug in slugs_false {
1498 let mut ctx = FeatureContext::new();
1499 ctx.insert("slug", json!(slug));
1500 assert!(
1501 !check(&opts, "organizations:test-feature", &ctx),
1502 "slug={slug} should not match"
1503 );
1504 }
1505
1506 let mut ctx = FeatureContext::new();
1508 ctx.insert("slug", json!("other-org"));
1509 assert!(check(&opts, "organizations:test-feature", &ctx));
1510 }
1511
1512 #[test]
1513 fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1514 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1516 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1517
1518 let mut ctx = FeatureContext::new();
1519 ctx.insert("slug", json!("a"));
1520 assert!(check(&opts, "organizations:test-feature", &ctx));
1521
1522 let mut ctx2 = FeatureContext::new();
1523 ctx2.insert("slug", json!("aa"));
1524 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1525 }
1526
1527 #[test]
1528 fn test_condition_not_matches_non_string_context_returns_true() {
1529 let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1531 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1532
1533 let mut ctx = FeatureContext::new();
1534 ctx.insert("org_id", json!(123));
1535 assert!(check(&opts, "organizations:test-feature", &ctx));
1536 }
1537
1538 #[test]
1539 fn test_id_string_is_stable() {
1540 for (value, expected) in [
1541 (json!("sentry"), "sentry"),
1542 (json!(123), "123"),
1543 (json!(-7), "-7"),
1544 (json!(true), "True"),
1545 (json!(false), "False"),
1546 (json!(null), "None"),
1547 (json!(1.0), "1.0"),
1548 (json!(1.5), "1.5"),
1549 (json!(["a", "b"]), "[a, b]"),
1550 (json!([1, 2]), "[1, 2]"),
1551 (json!([]), "[]"),
1552 ] {
1553 assert_eq!(value_to_id_string(&value), expected, "for {value}");
1554 }
1555 }
1556
1557 fn condition_matches(operator: &str, value: &str, ctx: &FeatureContext) -> bool {
1558 let json = format!(r#"{{"property": "prop", "operator": "{operator}", "value": {value}}}"#);
1559 Condition::from_json(&serde_json::from_str(&json).unwrap())
1560 .expect("condition should parse")
1561 .matches(ctx)
1562 }
1563
1564 #[test]
1565 fn test_missing_property_matches_only_negated_conditions() {
1566 let ctx = FeatureContext::new();
1567 for (operator, value, expected) in [
1568 ("in", "[1]", false),
1569 ("not_in", "[1]", true),
1570 ("equals", "1", false),
1571 ("not_equals", "1", true),
1572 ("matches", r#"["a"]"#, false),
1573 ("not_matches", r#"["a"]"#, true),
1574 ("contains", r#""a""#, false),
1576 ("not_contains", r#""a""#, false),
1577 ] {
1578 assert_eq!(
1579 condition_matches(operator, value, &ctx),
1580 expected,
1581 "{operator}"
1582 );
1583 }
1584 }
1585
1586 #[test]
1587 fn test_unusable_property_type_matches_neither_form() {
1588 let mut map = FeatureContext::new();
1589 map.insert("prop", json!({"k": "v"}));
1590 let mut scalar = FeatureContext::new();
1591 scalar.insert("prop", json!("a"));
1592 let mut list = FeatureContext::new();
1593 list.insert("prop", json!(["b"]));
1594
1595 for (operator, value, ctx) in [
1596 ("in", "[1]", &map),
1597 ("not_in", "[1]", &map),
1598 ("contains", r#""a""#, &scalar),
1599 ("not_contains", r#""a""#, &scalar),
1600 ("matches", r#"["b"]"#, &list),
1601 ("not_matches", r#"["b"]"#, &list),
1602 ] {
1603 assert!(!condition_matches(operator, value, ctx), "{operator}");
1604 }
1605
1606 assert!(condition_matches("not_contains", r#""a""#, &list));
1608 }
1609
1610 fn checker(opts: Options, namespace: &str) -> FeatureChecker {
1612 FeatureChecker::new(namespace.to_string(), Box::leak(Box::new(opts)))
1613 }
1614
1615 fn setup_without_value() -> (Options, TempDir) {
1617 let temp = TempDir::new().unwrap();
1618 let schemas = temp.path().join("schemas");
1619 fs::create_dir_all(&schemas).unwrap();
1620 create_schema(&schemas, "test", FEATURE_SCHEMA);
1621 create_values(&temp.path().join("values"), "test", r#"{"options": {}}"#);
1622 (Options::from_directory(temp.path()).unwrap(), temp)
1623 }
1624
1625 #[test]
1626 fn test_try_has_evaluated() {
1627 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1628 let checker = checker(opts, "test");
1629
1630 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1631 assert_eq!(result.unwrap(), Some(true));
1632 }
1633
1634 #[test]
1635 fn test_try_has_evaluated_false_is_not_absent() {
1636 let (opts, _temp) = setup_feature_options(&feature_json(false, 100, ""));
1639 let checker = checker(opts, "test");
1640
1641 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1642 assert_eq!(result.unwrap(), Some(false));
1643 }
1644
1645 #[test]
1646 fn test_try_has_absent_when_no_value_set() {
1647 let (opts, _temp) = setup_without_value();
1648 let checker = checker(opts, "test");
1649
1650 let result = checker.try_has("organizations:test-feature", &FeatureContext::new());
1651 assert_eq!(result.unwrap(), None);
1652 }
1653
1654 #[test]
1655 fn test_try_has_absent_when_feature_not_in_schema() {
1656 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1657 let checker = checker(opts, "test");
1658
1659 let result = checker.try_has("organizations:never-declared", &FeatureContext::new());
1660 assert_eq!(result.unwrap(), None);
1661 }
1662
1663 #[test]
1664 fn test_try_has_errors_on_unknown_namespace() {
1665 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1666 let checker = checker(opts, "not-a-namespace");
1667
1668 assert!(matches!(
1669 checker.try_has("organizations:test-feature", &FeatureContext::new()),
1670 Err(FeatureError::Options(
1671 crate::OptionsError::UnknownNamespace(_)
1672 ))
1673 ));
1674 }
1675
1676 #[test]
1677 fn test_try_has_errors_when_uninitialized() {
1678 let checker = FeatureChecker {
1679 namespace: "test".to_string(),
1680 options: None,
1681 };
1682
1683 assert!(matches!(
1684 checker.try_has("organizations:test-feature", &FeatureContext::new()),
1685 Err(FeatureError::NotInitialized)
1686 ));
1687 }
1688
1689 #[test]
1690 fn test_has_still_collapses_every_failure_to_false() {
1691 let (opts, _temp) = setup_without_value();
1693 let absent = checker(opts, "test");
1694 assert!(!absent.has("organizations:test-feature", &FeatureContext::new()));
1695
1696 let (opts, _temp) = setup_feature_options(&feature_json(true, 100, ""));
1697 let unknown_ns = checker(opts, "not-a-namespace");
1698 assert!(!unknown_ns.has("organizations:test-feature", &FeatureContext::new()));
1699
1700 let uninitialized = FeatureChecker {
1701 namespace: "test".to_string(),
1702 options: None,
1703 };
1704 assert!(!uninitialized.has("organizations:test-feature", &FeatureContext::new()));
1705 }
1706}