1use num::bigint::{BigInt, Sign};
7use std::cell::Cell;
8use std::collections::HashMap;
9use std::sync::OnceLock;
10
11use serde_json::Value;
12use sha1::{Digest, Sha1};
13
14fn value_to_id_string(value: &Value) -> String {
16 match value {
17 Value::String(s) => s.clone(),
18 Value::Number(n) => {
19 if let Some(i) = n.as_i64() {
20 i.to_string()
21 } else if let Some(f) = n.as_f64() {
22 if f.fract() == 0.0 {
24 format!("{f:.1}")
25 } else {
26 f.to_string()
27 }
28 } else {
29 n.to_string()
30 }
31 }
32 Value::Bool(b) => if *b { "True" } else { "False" }.to_string(),
33 Value::Array(arr) => {
34 let items: Vec<String> = arr.iter().map(value_to_id_string).collect();
35 format!("[{}]", items.join(", "))
36 }
37 Value::Null => "None".to_string(),
38 Value::Object(_) => value.to_string(),
39 }
40}
41
42pub struct FeatureContext {
47 data: HashMap<String, Value>,
48 identity_fields: Vec<String>,
49 cached_id: Cell<Option<u64>>,
50}
51
52impl FeatureContext {
53 pub fn new() -> Self {
54 Self {
55 data: HashMap::new(),
56 identity_fields: Vec::new(),
57 cached_id: Cell::new(None),
58 }
59 }
60
61 pub fn identity_fields(&mut self, fields: Vec<&str>) {
66 self.identity_fields = fields.into_iter().map(|s| s.to_string()).collect();
67 self.cached_id.set(None);
68 }
69
70 pub fn insert(&mut self, key: &str, value: impl Into<Value>) {
72 self.data.insert(key.to_string(), value.into());
73 self.cached_id.set(None);
74 }
75
76 pub fn get(&self, key: &str) -> Option<&Value> {
78 self.data.get(key)
79 }
80
81 pub fn has(&self, key: &str) -> bool {
83 self.data.contains_key(key)
84 }
85
86 pub fn id(&self) -> u64 {
93 if let Some(id) = self.cached_id.get() {
94 return id;
95 }
96 let id = self.compute_id();
97 self.cached_id.set(Some(id));
98 id
99 }
100
101 fn compute_id(&self) -> u64 {
110 let mut identity_fields: Vec<&String> = self
111 .identity_fields
112 .iter()
113 .filter(|f| self.data.contains_key(f.as_str()))
114 .collect();
115 if identity_fields.is_empty() {
116 identity_fields = self.data.keys().collect();
117 }
118 identity_fields.sort();
119
120 let mut parts: Vec<String> = Vec::with_capacity(identity_fields.len() * 2);
121 for key in identity_fields {
122 parts.push(key.clone());
123 parts.push(value_to_id_string(&self.data[key.as_str()]));
124 }
125 let mut hasher = Sha1::new();
126 hasher.update(parts.join(":").as_bytes());
127 let digest = hasher.finalize();
128
129 let bigint = BigInt::from_bytes_be(Sign::Plus, digest.as_slice());
131
132 let small: BigInt = bigint % 1000000000;
136 let id_parts = small.to_u64_digits().1;
137 if id_parts.is_empty() { 0 } else { id_parts[0] }
138 }
139}
140
141impl Default for FeatureContext {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147#[derive(Debug)]
148enum OperatorKind {
149 In,
150 NotIn,
151 Contains,
152 NotContains,
153 Equals,
154 NotEquals,
155 Matches,
156 NotMatches,
157}
158
159#[derive(Debug)]
160struct Condition {
161 property: String,
162 operator: OperatorKind,
163 value: Value,
164}
165
166#[derive(Debug)]
167struct Segment {
168 rollout: u64,
169 conditions: Vec<Condition>,
170}
171
172#[derive(Debug)]
173struct Feature {
174 enabled: bool,
175 segments: Vec<Segment>,
176}
177
178impl Feature {
179 fn from_json(value: &Value) -> Option<Self> {
180 let enabled = value
182 .get("enabled")
183 .and_then(|v| v.as_bool())
184 .unwrap_or(true);
185 let segments = value
186 .get("segments")?
187 .as_array()?
188 .iter()
189 .filter_map(Segment::from_json)
190 .collect();
191 Some(Feature { enabled, segments })
192 }
193
194 fn matches(&self, context: &FeatureContext) -> bool {
195 if !self.enabled {
196 return false;
197 }
198 for segment in &self.segments {
199 if segment.conditions_match(context) {
200 return segment.in_rollout(context);
201 }
202 }
203 false
204 }
205}
206
207impl Segment {
208 fn from_json(value: &Value) -> Option<Self> {
209 let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
210 let conditions = value
211 .get("conditions")
212 .and_then(|v| v.as_array())
213 .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
214 .unwrap_or_default();
215 Some(Segment {
216 rollout,
217 conditions,
218 })
219 }
220
221 fn conditions_match(&self, context: &FeatureContext) -> bool {
222 self.conditions.iter().all(|c| c.matches(context))
223 }
224
225 fn in_rollout(&self, context: &FeatureContext) -> bool {
226 if self.rollout == 0 {
227 return false;
228 }
229 if self.rollout >= 100 {
230 return true;
231 }
232 context.id() % 100 < self.rollout
233 }
234}
235
236impl Condition {
237 fn from_json(value: &Value) -> Option<Self> {
238 let property = value.get("property")?.as_str()?.to_string();
239 let operator = match value.get("operator")?.as_str()? {
240 "in" => OperatorKind::In,
241 "not_in" => OperatorKind::NotIn,
242 "contains" => OperatorKind::Contains,
243 "not_contains" => OperatorKind::NotContains,
244 "equals" => OperatorKind::Equals,
245 "not_equals" => OperatorKind::NotEquals,
246 "matches" => OperatorKind::Matches,
247 "not_matches" => OperatorKind::NotMatches,
248 _ => return None,
249 };
250 let value = value.get("value")?.clone();
251 Some(Condition {
252 property,
253 operator,
254 value,
255 })
256 }
257
258 fn matches(&self, context: &FeatureContext) -> bool {
259 let Some(ctx_val) = context.get(&self.property) else {
260 return false;
261 };
262 match &self.operator {
263 OperatorKind::In => eval_in(ctx_val, &self.value),
264 OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
265 OperatorKind::Contains => eval_contains(ctx_val, &self.value),
266 OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
267 OperatorKind::Equals => eval_equals(ctx_val, &self.value),
268 OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
269 OperatorKind::Matches => eval_matches(ctx_val, &self.value),
270 OperatorKind::NotMatches => !eval_matches(ctx_val, &self.value),
271 }
272 }
273}
274
275fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
278 let Some(arr) = condition_val.as_array() else {
279 return false;
280 };
281 match ctx_val {
282 Value::String(s) => {
283 let s_lower = s.to_lowercase();
284 arr.iter()
285 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
286 }
287 Value::Number(n) => {
288 if let Some(i) = n.as_i64() {
289 arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
290 } else if let Some(f) = n.as_f64() {
291 arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
292 } else {
293 false
294 }
295 }
296 Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
297 _ => false,
298 }
299}
300
301fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
304 let Some(ctx_arr) = ctx_val.as_array() else {
305 return false;
306 };
307 match condition_val {
308 Value::String(s) => {
309 let s_lower = s.to_lowercase();
310 ctx_arr
311 .iter()
312 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
313 }
314 Value::Number(n) => {
315 if let Some(i) = n.as_i64() {
316 ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
317 } else if let Some(f) = n.as_f64() {
318 ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
319 } else {
320 false
321 }
322 }
323 Value::Bool(b) => ctx_arr
324 .iter()
325 .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
326 _ => false,
327 }
328}
329
330fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
334 match (ctx_val, condition_val) {
335 (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
336 (Value::Number(a), Value::Number(b)) => {
337 if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
339 ai == bi
340 } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
341 af == bf
342 } else {
343 false
344 }
345 }
346 (Value::Bool(a), Value::Bool(b)) => a == b,
347 (Value::Array(a), Value::Array(b)) => {
348 a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
349 }
350 _ => false,
351 }
352}
353
354fn glob_star_match(pattern: &str, value: &str) -> bool {
358 let pattern = pattern.to_lowercase();
359 let value = value.to_lowercase();
360 let parts: Vec<&str> = pattern.split('*').collect();
361 if parts.len() == 1 {
363 return value == pattern;
364 }
365 if !value.starts_with(parts[0]) {
367 return false;
368 }
369 if !parts[parts.len() - 1].is_empty() && !value.ends_with(parts[parts.len() - 1]) {
371 return false;
372 }
373 let end = if parts[parts.len() - 1].is_empty() {
375 value.len()
376 } else {
377 value.len() - parts[parts.len() - 1].len()
378 };
379 let mut start = parts[0].len();
380 if start > end {
383 return false;
384 }
385 for part in &parts[1..parts.len() - 1] {
387 if part.is_empty() {
388 continue;
390 }
391 match value[start..end].find(*part) {
392 Some(idx) => start += idx + part.len(),
393 None => return false,
394 }
395 }
396 true
397}
398
399fn eval_matches(ctx_val: &Value, condition_val: &Value) -> bool {
403 let Some(s) = ctx_val.as_str() else {
404 return false;
405 };
406 let Some(arr) = condition_val.as_array() else {
407 return false;
408 };
409 arr.iter().any(|v| {
410 v.as_str()
411 .is_some_and(|pattern| glob_star_match(pattern, s))
412 })
413}
414
415#[derive(Debug, PartialEq)]
416enum DebugLogLevel {
417 None,
418 Parse,
419 Match,
420 All,
421}
422
423static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
424static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
425
426fn debug_log_level() -> &'static DebugLogLevel {
427 DEBUG_LOG_LEVEL.get_or_init(|| {
428 match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
429 .as_deref()
430 .unwrap_or("")
431 {
432 "all" => DebugLogLevel::All,
433 "parse" => DebugLogLevel::Parse,
434 "match" => DebugLogLevel::Match,
435 _ => DebugLogLevel::None,
436 }
437 })
438}
439
440fn debug_match_sample_rate() -> u64 {
441 *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
442 std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
443 .ok()
444 .and_then(|v| v.parse::<f64>().ok())
445 .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
446 .unwrap_or(1000)
447 })
448}
449
450fn debug_log_parse(msg: &str) {
451 match debug_log_level() {
452 DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
453 _ => {}
454 }
455}
456
457fn debug_log_match(feature: &str, result: bool, context_id: u64) {
458 match debug_log_level() {
459 DebugLogLevel::Match | DebugLogLevel::All
460 if context_id % 1000 < debug_match_sample_rate() =>
461 {
462 eprintln!(
463 "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
464 );
465 }
466 _ => {}
467 }
468}
469
470pub struct FeatureChecker {
472 namespace: String,
473 options: Option<&'static crate::Options>,
474}
475
476impl FeatureChecker {
477 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
478 Self {
479 namespace,
480 options: Some(options),
481 }
482 }
483
484 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
489 let Some(opts) = self.options else {
490 return false;
491 };
492 let key = format!("feature.{feature_name}");
493
494 let feature_val = match opts.get(&self.namespace, &key) {
495 Ok(v) => v,
496 Err(e) => {
497 debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
498 return false;
499 }
500 };
501
502 let feature = match Feature::from_json(&feature_val) {
503 Some(f) => {
504 debug_log_parse(&format!("Parsed feature '{key}'"));
505 f
506 }
507 None => {
508 debug_log_parse(&format!("Failed to parse feature '{key}'"));
509 return false;
510 }
511 };
512
513 let result = feature.matches(context);
514 debug_log_match(feature_name, result, context.id());
515 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_contains() {
828 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
829 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
830
831 let mut ctx = FeatureContext::new();
832 ctx.insert("tags", json!(["alpha", "beta"]));
833 assert!(check(&opts, "organizations:test-feature", &ctx));
834
835 let mut ctx2 = FeatureContext::new();
836 ctx2.insert("tags", json!(["alpha"]));
837 assert!(!check(&opts, "organizations:test-feature", &ctx2));
838 }
839
840 #[test]
841 fn test_condition_equals() {
842 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
843 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
844
845 let mut ctx = FeatureContext::new();
846 ctx.insert("plan", json!("Enterprise"));
847 assert!(check(&opts, "organizations:test-feature", &ctx));
848
849 let mut ctx2 = FeatureContext::new();
850 ctx2.insert("plan", json!("free"));
851 assert!(!check(&opts, "organizations:test-feature", &ctx2));
852 }
853
854 #[test]
855 fn test_condition_equals_bool() {
856 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
857 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
858
859 let mut ctx = FeatureContext::new();
860 ctx.insert("is_free", json!(true));
861 assert!(check(&opts, "organizations:test-feature", &ctx));
862
863 let mut ctx2 = FeatureContext::new();
864 ctx2.insert("is_free", json!(false));
865 assert!(!check(&opts, "organizations:test-feature", &ctx2));
866 }
867
868 #[test]
869 fn test_segment_with_no_conditions_always_matches() {
870 let feature = r#"{
871 "name": "test-feature",
872 "enabled": true,
873 "owner": {"team": "test-team"},
874 "created_at": "2024-01-01",
875 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
876 }"#;
877 let (opts, _t) = setup_feature_options(feature);
878
879 let ctx = FeatureContext::new();
880 assert!(check(&opts, "organizations:test-feature", &ctx));
881 }
882
883 #[test]
884 fn test_feature_enabled_and_rollout_default_values() {
885 let feature = r#"{
887 "name": "test-feature",
888 "owner": {"team": "test-team"},
889 "created_at": "2024-01-01",
890 "segments": [
891 {
892 "name": "first",
893 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
894 }
895 ]
896 }"#;
897 let (opts, _t) = setup_feature_options(feature);
898
899 let mut ctx = FeatureContext::new();
900 ctx.insert("org_id", 1);
901 ctx.identity_fields(vec!["org_id"]);
902 assert!(check(&opts, "organizations:test-feature", &ctx));
903 }
904
905 #[test]
906 fn test_feature_with_no_segments_returns_false() {
907 let feature = r#"{
908 "name": "test-feature",
909 "enabled": true,
910 "owner": {"team": "test-team"},
911 "created_at": "2024-01-01",
912 "segments": []
913 }"#;
914 let (opts, _t) = setup_feature_options(feature);
915
916 let ctx = FeatureContext::new();
917 assert!(!check(&opts, "organizations:test-feature", &ctx));
918 }
919
920 #[test]
921 fn test_multiple_segments_or_logic() {
922 let feature = r#"{
923 "name": "test-feature",
924 "enabled": true,
925 "owner": {"team": "test-team"},
926 "created_at": "2024-01-01",
927 "segments": [
928 {
929 "name": "segment-a",
930 "rollout": 100,
931 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
932 },
933 {
934 "name": "segment-b",
935 "rollout": 100,
936 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
937 }
938 ]
939 }"#;
940 let (opts, _t) = setup_feature_options(feature);
941
942 let mut ctx1 = FeatureContext::new();
943 ctx1.insert("org_id", json!(1));
944 assert!(check(&opts, "organizations:test-feature", &ctx1));
945
946 let mut ctx2 = FeatureContext::new();
947 ctx2.insert("org_id", json!(2));
948 assert!(check(&opts, "organizations:test-feature", &ctx2));
949
950 let mut ctx3 = FeatureContext::new();
951 ctx3.insert("org_id", json!(3));
952 assert!(!check(&opts, "organizations:test-feature", &ctx3));
953 }
954
955 #[test]
956 fn test_multiple_conditions_and_logic() {
957 let conds = r#"
958 {"property": "org_id", "operator": "in", "value": [123]},
959 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
960 "#;
961 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
962
963 let mut ctx = FeatureContext::new();
964 ctx.insert("org_id", json!(123));
965 ctx.insert("user_email", json!("admin@example.com"));
966 assert!(check(&opts, "organizations:test-feature", &ctx));
967
968 let mut ctx2 = FeatureContext::new();
969 ctx2.insert("org_id", json!(123));
970 ctx2.insert("user_email", json!("other@example.com"));
971 assert!(!check(&opts, "organizations:test-feature", &ctx2));
972 }
973
974 #[test]
975 fn test_in_int_context_against_string_list_returns_false() {
976 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
977 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
978
979 let mut ctx = FeatureContext::new();
980 ctx.insert("org_id", json!(123));
981 assert!(!check(&opts, "organizations:test-feature", &ctx));
982 }
983
984 #[test]
985 fn test_in_string_context_against_int_list_returns_false() {
986 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
987 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
988
989 let mut ctx = FeatureContext::new();
990 ctx.insert("slug", json!("123"));
991 assert!(!check(&opts, "organizations:test-feature", &ctx));
992 }
993
994 #[test]
995 fn test_in_bool_context_against_string_list_returns_false() {
996 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
997 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
998
999 let mut ctx = FeatureContext::new();
1000 ctx.insert("active", json!(true));
1001 assert!(!check(&opts, "organizations:test-feature", &ctx));
1002 }
1003
1004 #[test]
1005 fn test_in_float_context_against_string_list_returns_false() {
1006 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
1007 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1008
1009 let mut ctx = FeatureContext::new();
1010 ctx.insert("score", json!(0.5));
1011 assert!(!check(&opts, "organizations:test-feature", &ctx));
1012 }
1013
1014 #[test]
1015 fn test_not_in_int_context_against_string_list_returns_true() {
1016 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
1018 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1019
1020 let mut ctx = FeatureContext::new();
1021 ctx.insert("org_id", json!(123));
1022 assert!(check(&opts, "organizations:test-feature", &ctx));
1023 }
1024
1025 #[test]
1026 fn test_not_in_string_context_against_int_list_returns_true() {
1027 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
1029 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1030
1031 let mut ctx = FeatureContext::new();
1032 ctx.insert("slug", json!("123"));
1033 assert!(check(&opts, "organizations:test-feature", &ctx));
1034 }
1035
1036 #[test]
1037 fn test_condition_matches_literal() {
1038 let cond = r#"{"property": "slug", "operator": "matches", "value": ["sentry"]}"#;
1039 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1040
1041 let mut ctx = FeatureContext::new();
1042 ctx.insert("slug", json!("sentry"));
1043 assert!(check(&opts, "organizations:test-feature", &ctx));
1044
1045 let mut ctx2 = FeatureContext::new();
1046 ctx2.insert("slug", json!("getsentry"));
1047 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1048 }
1049
1050 #[test]
1051 fn test_condition_matches_prefix_wildcard() {
1052 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1053 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1054
1055 let mut ctx = FeatureContext::new();
1056 ctx.insert("slug", json!("jayonb73"));
1057 assert!(check(&opts, "organizations:test-feature", &ctx));
1058
1059 let mut ctx2 = FeatureContext::new();
1061 ctx2.insert("slug", json!("jayonb"));
1062 assert!(check(&opts, "organizations:test-feature", &ctx2));
1063
1064 let mut ctx3 = FeatureContext::new();
1065 ctx3.insert("slug", json!("dangoldonb1"));
1066 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1067 }
1068
1069 #[test]
1070 fn test_condition_matches_prefix_and_suffix_wildcard() {
1071 let cond = r#"{"property": "email", "operator": "matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1072 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1073
1074 let mut ctx = FeatureContext::new();
1075 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1076 assert!(check(&opts, "organizations:test-feature", &ctx));
1077
1078 let mut ctx2 = FeatureContext::new();
1080 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1081 assert!(check(&opts, "organizations:test-feature", &ctx2));
1082
1083 let mut ctx3 = FeatureContext::new();
1084 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1085 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1086 }
1087
1088 #[test]
1089 fn test_condition_matches_suffix_wildcard() {
1090 let cond = r#"{"property": "email", "operator": "matches", "value": ["*@sentry.io"]}"#;
1091 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1092
1093 let mut ctx = FeatureContext::new();
1094 ctx.insert("email", json!("user@sentry.io"));
1095 assert!(check(&opts, "organizations:test-feature", &ctx));
1096
1097 let mut ctx2 = FeatureContext::new();
1098 ctx2.insert("email", json!("user@example.com"));
1099 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1100 }
1101
1102 #[test]
1103 fn test_condition_matches_multi_segment_wildcard() {
1104 let cond = r#"{"property": "name", "operator": "matches", "value": ["a*b*c"]}"#;
1105 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1106
1107 let mut ctx = FeatureContext::new();
1108 ctx.insert("name", json!("abc"));
1109 assert!(check(&opts, "organizations:test-feature", &ctx));
1110
1111 let mut ctx2 = FeatureContext::new();
1112 ctx2.insert("name", json!("aXbYc"));
1113 assert!(check(&opts, "organizations:test-feature", &ctx2));
1114
1115 let mut ctx3 = FeatureContext::new();
1116 ctx3.insert("name", json!("aXXbYYc"));
1117 assert!(check(&opts, "organizations:test-feature", &ctx3));
1118
1119 let mut ctx4 = FeatureContext::new();
1120 ctx4.insert("name", json!("aXXc"));
1121 assert!(!check(&opts, "organizations:test-feature", &ctx4));
1122 }
1123
1124 #[test]
1125 fn test_condition_matches_star_only_pattern() {
1126 let cond = r#"{"property": "slug", "operator": "matches", "value": ["*"]}"#;
1127 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1128
1129 let mut ctx = FeatureContext::new();
1130 ctx.insert("slug", json!("anything"));
1131 assert!(check(&opts, "organizations:test-feature", &ctx));
1132
1133 let mut ctx2 = FeatureContext::new();
1134 ctx2.insert("slug", json!(""));
1135 assert!(check(&opts, "organizations:test-feature", &ctx2));
1136 }
1137
1138 #[test]
1139 fn test_condition_matches_case_insensitive() {
1140 let cond = r#"{"property": "slug", "operator": "matches", "value": ["JAYONB*"]}"#;
1141 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1142
1143 let mut ctx = FeatureContext::new();
1144 ctx.insert("slug", json!("jayonb73"));
1145 assert!(check(&opts, "organizations:test-feature", &ctx));
1146
1147 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1149 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1150
1151 let mut ctx2 = FeatureContext::new();
1152 ctx2.insert("slug", json!("JAYONB73"));
1153 assert!(check(&opts2, "organizations:test-feature", &ctx2));
1154 }
1155
1156 #[test]
1157 fn test_condition_matches_no_match() {
1158 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*"]}"#;
1159 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1160
1161 let mut ctx = FeatureContext::new();
1162 ctx.insert("slug", json!("dangoldonb1"));
1163 assert!(!check(&opts, "organizations:test-feature", &ctx));
1164 }
1165
1166 #[test]
1167 fn test_condition_matches_multiple_patterns() {
1168 let cond = r#"{"property": "slug", "operator": "matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1169 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1170
1171 let slugs = [
1172 ("jayonb73", true),
1173 ("dangoldonb3", true),
1174 ("value-disc-7", true),
1175 ("other-org", false),
1176 ];
1177 for (slug, expected) in slugs {
1178 let mut ctx = FeatureContext::new();
1179 ctx.insert("slug", json!(slug));
1180 assert_eq!(
1181 check(&opts, "organizations:test-feature", &ctx),
1182 expected,
1183 "slug={slug}"
1184 );
1185 }
1186 }
1187
1188 #[test]
1189 fn test_condition_matches_overlapping_prefix_suffix_anchors() {
1190 let cond = r#"{"property": "slug", "operator": "matches", "value": ["a*a"]}"#;
1192 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1193
1194 let mut ctx = FeatureContext::new();
1195 ctx.insert("slug", json!("a"));
1196 assert!(!check(&opts, "organizations:test-feature", &ctx));
1197
1198 let mut ctx2 = FeatureContext::new();
1199 ctx2.insert("slug", json!("aa"));
1200 assert!(check(&opts, "organizations:test-feature", &ctx2));
1201
1202 let cond2 = r#"{"property": "slug", "operator": "matches", "value": ["ab*ab"]}"#;
1204 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1205
1206 let mut ctx3 = FeatureContext::new();
1207 ctx3.insert("slug", json!("ab"));
1208 assert!(!check(&opts2, "organizations:test-feature", &ctx3));
1209
1210 let mut ctx4 = FeatureContext::new();
1211 ctx4.insert("slug", json!("abab"));
1212 assert!(check(&opts2, "organizations:test-feature", &ctx4));
1213 }
1214
1215 #[test]
1216 fn test_condition_matches_non_string_context_returns_false() {
1217 let cond = r#"{"property": "org_id", "operator": "matches", "value": ["123*"]}"#;
1219 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1220
1221 let mut ctx = FeatureContext::new();
1222 ctx.insert("org_id", json!(123));
1223 assert!(!check(&opts, "organizations:test-feature", &ctx));
1224 }
1225
1226 #[test]
1227 fn test_condition_not_matches_literal() {
1228 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["sentry"]}"#;
1229 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1230
1231 let mut ctx = FeatureContext::new();
1233 ctx.insert("slug", json!("sentry"));
1234 assert!(!check(&opts, "organizations:test-feature", &ctx));
1235
1236 let mut ctx2 = FeatureContext::new();
1238 ctx2.insert("slug", json!("getsentry"));
1239 assert!(check(&opts, "organizations:test-feature", &ctx2));
1240 }
1241
1242 #[test]
1243 fn test_condition_not_matches_prefix_wildcard() {
1244 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1245 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1246
1247 let mut ctx = FeatureContext::new();
1248 ctx.insert("slug", json!("jayonb73"));
1249 assert!(!check(&opts, "organizations:test-feature", &ctx));
1250
1251 let mut ctx2 = FeatureContext::new();
1253 ctx2.insert("slug", json!("jayonb"));
1254 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1255
1256 let mut ctx3 = FeatureContext::new();
1257 ctx3.insert("slug", json!("dangoldonb1"));
1258 assert!(check(&opts, "organizations:test-feature", &ctx3));
1259 }
1260
1261 #[test]
1262 fn test_condition_not_matches_prefix_and_suffix_wildcard() {
1263 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["jay.goss+onboarding*@sentry.io"]}"#;
1264 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1265
1266 let mut ctx = FeatureContext::new();
1267 ctx.insert("email", json!("jay.goss+onboarding70@sentry.io"));
1268 assert!(!check(&opts, "organizations:test-feature", &ctx));
1269
1270 let mut ctx2 = FeatureContext::new();
1271 ctx2.insert("email", json!("jay.goss+onboarding@sentry.io"));
1272 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1273
1274 let mut ctx3 = FeatureContext::new();
1275 ctx3.insert("email", json!("jay.goss+onboarding70@example.com"));
1276 assert!(check(&opts, "organizations:test-feature", &ctx3));
1277 }
1278
1279 #[test]
1280 fn test_condition_not_matches_suffix_wildcard() {
1281 let cond = r#"{"property": "email", "operator": "not_matches", "value": ["*@sentry.io"]}"#;
1282 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1283
1284 let mut ctx = FeatureContext::new();
1285 ctx.insert("email", json!("user@sentry.io"));
1286 assert!(!check(&opts, "organizations:test-feature", &ctx));
1287
1288 let mut ctx2 = FeatureContext::new();
1289 ctx2.insert("email", json!("user@example.com"));
1290 assert!(check(&opts, "organizations:test-feature", &ctx2));
1291 }
1292
1293 #[test]
1294 fn test_condition_not_matches_multi_segment_wildcard() {
1295 let cond = r#"{"property": "name", "operator": "not_matches", "value": ["a*b*c"]}"#;
1296 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1297
1298 let mut ctx = FeatureContext::new();
1299 ctx.insert("name", json!("abc"));
1300 assert!(!check(&opts, "organizations:test-feature", &ctx));
1301
1302 let mut ctx2 = FeatureContext::new();
1303 ctx2.insert("name", json!("aXbYc"));
1304 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1305
1306 let mut ctx3 = FeatureContext::new();
1307 ctx3.insert("name", json!("aXXbYYc"));
1308 assert!(!check(&opts, "organizations:test-feature", &ctx3));
1309
1310 let mut ctx4 = FeatureContext::new();
1312 ctx4.insert("name", json!("aXXc"));
1313 assert!(check(&opts, "organizations:test-feature", &ctx4));
1314 }
1315
1316 #[test]
1317 fn test_condition_not_matches_star_only_pattern() {
1318 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["*"]}"#;
1320 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1321
1322 let mut ctx = FeatureContext::new();
1323 ctx.insert("slug", json!("anything"));
1324 assert!(!check(&opts, "organizations:test-feature", &ctx));
1325
1326 let mut ctx2 = FeatureContext::new();
1327 ctx2.insert("slug", json!(""));
1328 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1329 }
1330
1331 #[test]
1332 fn test_condition_not_matches_case_insensitive() {
1333 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["JAYONB*"]}"#;
1334 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1335
1336 let mut ctx = FeatureContext::new();
1338 ctx.insert("slug", json!("jayonb73"));
1339 assert!(!check(&opts, "organizations:test-feature", &ctx));
1340
1341 let cond2 = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1343 let (opts2, _t2) = setup_feature_options(&feature_json(true, 100, cond2));
1344
1345 let mut ctx2 = FeatureContext::new();
1346 ctx2.insert("slug", json!("JAYONB73"));
1347 assert!(!check(&opts2, "organizations:test-feature", &ctx2));
1348 }
1349
1350 #[test]
1351 fn test_condition_not_matches_no_match() {
1352 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*"]}"#;
1353 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1354
1355 let mut ctx = FeatureContext::new();
1356 ctx.insert("slug", json!("dangoldonb1"));
1357 assert!(check(&opts, "organizations:test-feature", &ctx));
1358 }
1359
1360 #[test]
1361 fn test_condition_not_matches_multiple_patterns() {
1362 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["jayonb*", "dangoldonb*", "value-disc-*"]}"#;
1363 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1364
1365 let slugs_false = ["jayonb73", "dangoldonb3", "value-disc-7"];
1367 for slug in slugs_false {
1368 let mut ctx = FeatureContext::new();
1369 ctx.insert("slug", json!(slug));
1370 assert!(
1371 !check(&opts, "organizations:test-feature", &ctx),
1372 "slug={slug} should not match"
1373 );
1374 }
1375
1376 let mut ctx = FeatureContext::new();
1378 ctx.insert("slug", json!("other-org"));
1379 assert!(check(&opts, "organizations:test-feature", &ctx));
1380 }
1381
1382 #[test]
1383 fn test_condition_not_matches_overlapping_prefix_suffix_anchors() {
1384 let cond = r#"{"property": "slug", "operator": "not_matches", "value": ["a*a"]}"#;
1386 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1387
1388 let mut ctx = FeatureContext::new();
1389 ctx.insert("slug", json!("a"));
1390 assert!(check(&opts, "organizations:test-feature", &ctx));
1391
1392 let mut ctx2 = FeatureContext::new();
1393 ctx2.insert("slug", json!("aa"));
1394 assert!(!check(&opts, "organizations:test-feature", &ctx2));
1395 }
1396
1397 #[test]
1398 fn test_condition_not_matches_non_string_context_returns_true() {
1399 let cond = r#"{"property": "org_id", "operator": "not_matches", "value": ["123*"]}"#;
1401 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
1402
1403 let mut ctx = FeatureContext::new();
1404 ctx.insert("org_id", json!(123));
1405 assert!(check(&opts, "organizations:test-feature", &ctx));
1406 }
1407}