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}
156
157#[derive(Debug)]
158struct Condition {
159 property: String,
160 operator: OperatorKind,
161 value: Value,
162}
163
164#[derive(Debug)]
165struct Segment {
166 rollout: u64,
167 conditions: Vec<Condition>,
168}
169
170#[derive(Debug)]
171struct Feature {
172 enabled: bool,
173 segments: Vec<Segment>,
174}
175
176impl Feature {
177 fn from_json(value: &Value) -> Option<Self> {
178 let enabled = value
180 .get("enabled")
181 .and_then(|v| v.as_bool())
182 .unwrap_or(true);
183 let segments = value
184 .get("segments")?
185 .as_array()?
186 .iter()
187 .filter_map(Segment::from_json)
188 .collect();
189 Some(Feature { enabled, segments })
190 }
191
192 fn matches(&self, context: &FeatureContext) -> bool {
193 if !self.enabled {
194 return false;
195 }
196 for segment in &self.segments {
197 if segment.conditions_match(context) {
198 return segment.in_rollout(context);
199 }
200 }
201 false
202 }
203}
204
205impl Segment {
206 fn from_json(value: &Value) -> Option<Self> {
207 let rollout = value.get("rollout").and_then(|v| v.as_u64()).unwrap_or(100);
208 let conditions = value
209 .get("conditions")
210 .and_then(|v| v.as_array())
211 .map(|arr| arr.iter().filter_map(Condition::from_json).collect())
212 .unwrap_or_default();
213 Some(Segment {
214 rollout,
215 conditions,
216 })
217 }
218
219 fn conditions_match(&self, context: &FeatureContext) -> bool {
220 self.conditions.iter().all(|c| c.matches(context))
221 }
222
223 fn in_rollout(&self, context: &FeatureContext) -> bool {
224 if self.rollout == 0 {
225 return false;
226 }
227 if self.rollout >= 100 {
228 return true;
229 }
230 context.id() % 100 < self.rollout
231 }
232}
233
234impl Condition {
235 fn from_json(value: &Value) -> Option<Self> {
236 let property = value.get("property")?.as_str()?.to_string();
237 let operator = match value.get("operator")?.as_str()? {
238 "in" => OperatorKind::In,
239 "not_in" => OperatorKind::NotIn,
240 "contains" => OperatorKind::Contains,
241 "not_contains" => OperatorKind::NotContains,
242 "equals" => OperatorKind::Equals,
243 "not_equals" => OperatorKind::NotEquals,
244 _ => return None,
245 };
246 let value = value.get("value")?.clone();
247 Some(Condition {
248 property,
249 operator,
250 value,
251 })
252 }
253
254 fn matches(&self, context: &FeatureContext) -> bool {
255 let Some(ctx_val) = context.get(&self.property) else {
256 return false;
257 };
258 match &self.operator {
259 OperatorKind::In => eval_in(ctx_val, &self.value),
260 OperatorKind::NotIn => !eval_in(ctx_val, &self.value),
261 OperatorKind::Contains => eval_contains(ctx_val, &self.value),
262 OperatorKind::NotContains => !eval_contains(ctx_val, &self.value),
263 OperatorKind::Equals => eval_equals(ctx_val, &self.value),
264 OperatorKind::NotEquals => !eval_equals(ctx_val, &self.value),
265 }
266 }
267}
268
269fn eval_in(ctx_val: &Value, condition_val: &Value) -> bool {
272 let Some(arr) = condition_val.as_array() else {
273 return false;
274 };
275 match ctx_val {
276 Value::String(s) => {
277 let s_lower = s.to_lowercase();
278 arr.iter()
279 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
280 }
281 Value::Number(n) => {
282 if let Some(i) = n.as_i64() {
283 arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
284 } else if let Some(f) = n.as_f64() {
285 arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
286 } else {
287 false
288 }
289 }
290 Value::Bool(b) => arr.iter().any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
291 _ => false,
292 }
293}
294
295fn eval_contains(ctx_val: &Value, condition_val: &Value) -> bool {
298 let Some(ctx_arr) = ctx_val.as_array() else {
299 return false;
300 };
301 match condition_val {
302 Value::String(s) => {
303 let s_lower = s.to_lowercase();
304 ctx_arr
305 .iter()
306 .any(|v| v.as_str().is_some_and(|cv| cv.to_lowercase() == s_lower))
307 }
308 Value::Number(n) => {
309 if let Some(i) = n.as_i64() {
310 ctx_arr.iter().any(|v| v.as_i64().is_some_and(|cv| cv == i))
311 } else if let Some(f) = n.as_f64() {
312 ctx_arr.iter().any(|v| v.as_f64().is_some_and(|cv| cv == f))
313 } else {
314 false
315 }
316 }
317 Value::Bool(b) => ctx_arr
318 .iter()
319 .any(|v| v.as_bool().is_some_and(|cv| cv == *b)),
320 _ => false,
321 }
322}
323
324fn eval_equals(ctx_val: &Value, condition_val: &Value) -> bool {
328 match (ctx_val, condition_val) {
329 (Value::String(a), Value::String(b)) => a.to_lowercase() == b.to_lowercase(),
330 (Value::Number(a), Value::Number(b)) => {
331 if let (Some(ai), Some(bi)) = (a.as_i64(), b.as_i64()) {
333 ai == bi
334 } else if let (Some(af), Some(bf)) = (a.as_f64(), b.as_f64()) {
335 af == bf
336 } else {
337 false
338 }
339 }
340 (Value::Bool(a), Value::Bool(b)) => a == b,
341 (Value::Array(a), Value::Array(b)) => {
342 a.len() == b.len() && a.iter().zip(b.iter()).all(|(av, bv)| eval_equals(av, bv))
343 }
344 _ => false,
345 }
346}
347
348#[derive(Debug, PartialEq)]
349enum DebugLogLevel {
350 None,
351 Parse,
352 Match,
353 All,
354}
355
356static DEBUG_LOG_LEVEL: OnceLock<DebugLogLevel> = OnceLock::new();
357static DEBUG_MATCH_SAMPLE_RATE: OnceLock<u64> = OnceLock::new();
358
359fn debug_log_level() -> &'static DebugLogLevel {
360 DEBUG_LOG_LEVEL.get_or_init(|| {
361 match std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG")
362 .as_deref()
363 .unwrap_or("")
364 {
365 "all" => DebugLogLevel::All,
366 "parse" => DebugLogLevel::Parse,
367 "match" => DebugLogLevel::Match,
368 _ => DebugLogLevel::None,
369 }
370 })
371}
372
373fn debug_match_sample_rate() -> u64 {
374 *DEBUG_MATCH_SAMPLE_RATE.get_or_init(|| {
375 std::env::var("SENTRY_OPTIONS_FEATURE_DEBUG_LOG_SAMPLE_RATE")
376 .ok()
377 .and_then(|v| v.parse::<f64>().ok())
378 .map(|r| (r.clamp(0.0, 1.0) * 1000.0) as u64)
379 .unwrap_or(1000)
380 })
381}
382
383fn debug_log_parse(msg: &str) {
384 match debug_log_level() {
385 DebugLogLevel::Parse | DebugLogLevel::All => eprintln!("[sentry-options/parse] {msg}"),
386 _ => {}
387 }
388}
389
390fn debug_log_match(feature: &str, result: bool, context_id: u64) {
391 match debug_log_level() {
392 DebugLogLevel::Match | DebugLogLevel::All => {
393 if context_id % 1000 < debug_match_sample_rate() {
394 eprintln!(
395 "[sentry-options/match] feature='{feature}' result={result} context_id={context_id}"
396 );
397 }
398 }
399 _ => {}
400 }
401}
402
403pub struct FeatureChecker {
405 namespace: String,
406 options: Option<&'static crate::Options>,
407}
408
409impl FeatureChecker {
410 pub fn new(namespace: String, options: &'static crate::Options) -> Self {
411 Self {
412 namespace,
413 options: Some(options),
414 }
415 }
416
417 pub fn has(&self, feature_name: &str, context: &FeatureContext) -> bool {
422 let Some(opts) = self.options else {
423 return false;
424 };
425 let key = format!("feature.{feature_name}");
426
427 let feature_val = match opts.get(&self.namespace, &key) {
428 Ok(v) => v,
429 Err(e) => {
430 debug_log_parse(&format!("Failed to get feature '{key}': {e}"));
431 return false;
432 }
433 };
434
435 let feature = match Feature::from_json(&feature_val) {
436 Some(f) => {
437 debug_log_parse(&format!("Parsed feature '{key}'"));
438 f
439 }
440 None => {
441 debug_log_parse(&format!("Failed to parse feature '{key}'"));
442 return false;
443 }
444 };
445
446 let result = feature.matches(context);
447 debug_log_match(feature_name, result, context.id());
448 result
449 }
450}
451
452pub fn features(namespace: &str) -> FeatureChecker {
456 FeatureChecker {
457 namespace: namespace.to_string(),
458 options: crate::GLOBAL_OPTIONS.get(),
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use crate::Options;
466 use serde_json::json;
467 use std::fs;
468 use std::path::Path;
469 use tempfile::TempDir;
470
471 fn create_schema(dir: &Path, namespace: &str, schema: &str) {
472 let schema_dir = dir.join(namespace);
473 fs::create_dir_all(&schema_dir).unwrap();
474 fs::write(schema_dir.join("schema.json"), schema).unwrap();
475 }
476
477 fn create_values(dir: &Path, namespace: &str, values: &str) {
478 let ns_dir = dir.join(namespace);
479 fs::create_dir_all(&ns_dir).unwrap();
480 fs::write(ns_dir.join("values.json"), values).unwrap();
481 }
482
483 const FEATURE_SCHEMA: &str = r##"{
484 "version": "1.0",
485 "type": "object",
486 "properties": {
487 "feature.organizations:test-feature": {
488 "$ref": "#/definitions/Feature"
489 }
490 }
491 }"##;
492
493 fn setup_feature_options(feature_json: &str) -> (Options, TempDir) {
494 let temp = TempDir::new().unwrap();
495 let schemas = temp.path().join("schemas");
496 fs::create_dir_all(&schemas).unwrap();
497 create_schema(&schemas, "test", FEATURE_SCHEMA);
498
499 let values = temp.path().join("values");
500 let values_json = format!(
501 r#"{{"options": {{"feature.organizations:test-feature": {}}}}}"#,
502 feature_json
503 );
504 create_values(&values, "test", &values_json);
505
506 let opts = Options::from_directory(temp.path()).unwrap();
507 (opts, temp)
508 }
509
510 fn feature_json(enabled: bool, rollout: u64, conditions: &str) -> String {
511 format!(
512 r#"{{
513 "name": "test-feature",
514 "enabled": {enabled},
515 "owner": {{"team": "test-team"}},
516 "created_at": "2024-01-01",
517 "segments": [{{
518 "name": "test-segment",
519 "rollout": {rollout},
520 "conditions": [{conditions}]
521 }}]
522 }}"#
523 )
524 }
525
526 fn in_condition(property: &str, values: &str) -> String {
527 format!(r#"{{"property": "{property}", "operator": "in", "value": [{values}]}}"#)
528 }
529
530 fn check(opts: &Options, feature: &str, ctx: &FeatureContext) -> bool {
531 let key = format!("feature.{feature}");
532 let Ok(val) = opts.get("test", &key) else {
533 return false;
534 };
535 Feature::from_json(&val).is_some_and(|f| f.matches(ctx))
536 }
537
538 #[test]
539 fn test_feature_context_insert_and_get() {
540 let mut ctx = FeatureContext::new();
541 ctx.insert("org_id", json!(123));
542 ctx.insert("name", json!("sentry"));
543 ctx.insert("active", json!(true));
544
545 assert!(ctx.has("org_id"));
546 assert!(!ctx.has("missing"));
547 assert_eq!(ctx.get("org_id"), Some(&json!(123)));
548 assert_eq!(ctx.get("name"), Some(&json!("sentry")));
549 }
550
551 #[test]
552 fn test_feature_context_id_is_cached() {
553 let mut ctx = FeatureContext::new();
554 ctx.identity_fields(vec!["user_id"]);
555 ctx.insert("user_id", json!(42));
556
557 let id1 = ctx.id();
558 let id2 = ctx.id();
559 assert_eq!(id1, id2, "ID should be cached and consistent");
560 }
561
562 #[test]
563 fn test_feature_context_id_resets_on_identity_change() {
564 let mut ctx = FeatureContext::new();
565 ctx.insert("user_id", json!(1));
566 ctx.insert("org_id", json!(2));
567
568 ctx.identity_fields(vec!["user_id"]);
569 let id_user = ctx.id();
570
571 ctx.identity_fields(vec!["org_id"]);
572 let id_org = ctx.id();
573
574 assert_ne!(
575 id_user, id_org,
576 "Different identity fields should produce different IDs"
577 );
578 }
579
580 #[test]
581 fn test_feature_context_id_deterministic() {
582 let make_ctx = || {
583 let mut ctx = FeatureContext::new();
584 ctx.identity_fields(vec!["user_id", "org_id"]);
585 ctx.insert("user_id", json!(456));
586 ctx.insert("org_id", json!(123));
587 ctx
588 };
589
590 assert_eq!(make_ctx().id(), make_ctx().id());
591
592 let mut other_ctx = FeatureContext::new();
593 other_ctx.identity_fields(vec!["user_id", "org_id"]);
594 other_ctx.insert("user_id", json!(789));
595 other_ctx.insert("org_id", json!(123));
596
597 assert_ne!(make_ctx().id(), other_ctx.id());
598 }
599
600 #[test]
601 fn test_feature_context_id_value_align_with_python() {
602 let ctx = FeatureContext::new();
606 assert_eq!(ctx.id() % 100, 5, "should match with python implementation");
607
608 let mut ctx = FeatureContext::new();
609 ctx.insert("foo", json!("bar"));
610 ctx.insert("baz", json!("barfoo"));
611 ctx.identity_fields(vec!["foo"]);
612 assert_eq!(ctx.id() % 100, 62);
613
614 let mut ctx = FeatureContext::new();
616 ctx.insert("foo", json!("bar"));
617 ctx.insert("baz", json!("barfoo"));
618 ctx.identity_fields(vec!["foo", "whoops"]);
619 assert_eq!(ctx.id() % 100, 62);
620
621 let mut ctx = FeatureContext::new();
622 ctx.insert("foo", json!("bar"));
623 ctx.insert("baz", json!("barfoo"));
624 ctx.identity_fields(vec!["foo", "baz"]);
625 assert_eq!(ctx.id() % 100, 1);
626
627 let mut ctx = FeatureContext::new();
628 ctx.insert("foo", json!("bar"));
629 ctx.insert("baz", json!("barfoo"));
630 ctx.identity_fields(vec!["whoops", "nope"]);
633 assert_eq!(ctx.id() % 100, 1);
634 }
635
636 #[test]
637 fn test_feature_prefix_is_added() {
638 let cond = in_condition("organization_id", "123");
639 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
640
641 let mut ctx = FeatureContext::new();
642 ctx.insert("organization_id", json!(123));
643
644 assert!(check(&opts, "organizations:test-feature", &ctx));
645 }
646
647 #[test]
648 fn test_undefined_feature_returns_false() {
649 let cond = in_condition("organization_id", "123");
650 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
651
652 let ctx = FeatureContext::new();
653 assert!(!check(&opts, "nonexistent", &ctx));
654 }
655
656 #[test]
657 fn test_missing_context_field_returns_false() {
658 let cond = in_condition("organization_id", "123");
659 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
660
661 let ctx = FeatureContext::new();
662 assert!(!check(&opts, "organizations:test-feature", &ctx));
663 }
664
665 #[test]
666 fn test_matching_context_returns_true() {
667 let cond = in_condition("organization_id", "123, 456");
668 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
669
670 let mut ctx = FeatureContext::new();
671 ctx.insert("organization_id", json!(123));
672
673 assert!(check(&opts, "organizations:test-feature", &ctx));
674 }
675
676 #[test]
677 fn test_non_matching_context_returns_false() {
678 let cond = in_condition("organization_id", "123, 456");
679 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
680
681 let mut ctx = FeatureContext::new();
682 ctx.insert("organization_id", json!(999));
683
684 assert!(!check(&opts, "organizations:test-feature", &ctx));
685 }
686
687 #[test]
688 fn test_disabled_feature_returns_false() {
689 let cond = in_condition("organization_id", "123");
690 let (opts, _t) = setup_feature_options(&feature_json(false, 100, &cond));
691
692 let mut ctx = FeatureContext::new();
693 ctx.insert("organization_id", json!(123));
694
695 assert!(!check(&opts, "organizations:test-feature", &ctx));
696 }
697
698 #[test]
699 fn test_rollout_zero_returns_false() {
700 let cond = in_condition("organization_id", "123");
701 let (opts, _t) = setup_feature_options(&feature_json(true, 0, &cond));
702
703 let mut ctx = FeatureContext::new();
704 ctx.insert("organization_id", json!(123));
705
706 assert!(!check(&opts, "organizations:test-feature", &ctx));
707 }
708
709 #[test]
710 fn test_rollout_100_returns_true() {
711 let cond = in_condition("organization_id", "123");
712 let (opts, _t) = setup_feature_options(&feature_json(true, 100, &cond));
713
714 let mut ctx = FeatureContext::new();
715 ctx.insert("organization_id", json!(123));
716
717 assert!(check(&opts, "organizations:test-feature", &ctx));
718 }
719
720 #[test]
721 fn test_rollout_is_deterministic() {
722 let mut ctx = FeatureContext::new();
723 ctx.identity_fields(vec!["user_id"]);
724 ctx.insert("user_id", json!(42));
725 ctx.insert("organization_id", json!(123));
726
727 let id_mod = (ctx.id() % 100) + 1;
729 let cond = in_condition("organization_id", "123");
730
731 let (opts_at, _t1) = setup_feature_options(&feature_json(true, id_mod, &cond));
732 assert!(check(&opts_at, "organizations:test-feature", &ctx));
733 }
734
735 #[test]
736 fn test_condition_in_string_case_insensitive() {
737 let cond = r#"{"property": "slug", "operator": "in", "value": ["Sentry", "ACME"]}"#;
738 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
739
740 let mut ctx = FeatureContext::new();
741 ctx.insert("slug", json!("sentry"));
742 assert!(check(&opts, "organizations:test-feature", &ctx));
743 }
744
745 #[test]
746 fn test_condition_not_in() {
747 let cond = r#"{"property": "organization_id", "operator": "not_in", "value": [999]}"#;
748 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
749
750 let mut ctx = FeatureContext::new();
751 ctx.insert("organization_id", json!(123));
752 assert!(check(&opts, "organizations:test-feature", &ctx));
753
754 let mut ctx2 = FeatureContext::new();
755 ctx2.insert("organization_id", json!(999));
756 assert!(!check(&opts, "organizations:test-feature", &ctx2));
757 }
758
759 #[test]
760 fn test_condition_contains() {
761 let cond = r#"{"property": "tags", "operator": "contains", "value": "beta"}"#;
762 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
763
764 let mut ctx = FeatureContext::new();
765 ctx.insert("tags", json!(["alpha", "beta"]));
766 assert!(check(&opts, "organizations:test-feature", &ctx));
767
768 let mut ctx2 = FeatureContext::new();
769 ctx2.insert("tags", json!(["alpha"]));
770 assert!(!check(&opts, "organizations:test-feature", &ctx2));
771 }
772
773 #[test]
774 fn test_condition_equals() {
775 let cond = r#"{"property": "plan", "operator": "equals", "value": "enterprise"}"#;
776 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
777
778 let mut ctx = FeatureContext::new();
779 ctx.insert("plan", json!("Enterprise"));
780 assert!(check(&opts, "organizations:test-feature", &ctx));
781
782 let mut ctx2 = FeatureContext::new();
783 ctx2.insert("plan", json!("free"));
784 assert!(!check(&opts, "organizations:test-feature", &ctx2));
785 }
786
787 #[test]
788 fn test_condition_equals_bool() {
789 let cond = r#"{"property": "is_free", "operator": "equals", "value": true}"#;
790 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
791
792 let mut ctx = FeatureContext::new();
793 ctx.insert("is_free", json!(true));
794 assert!(check(&opts, "organizations:test-feature", &ctx));
795
796 let mut ctx2 = FeatureContext::new();
797 ctx2.insert("is_free", json!(false));
798 assert!(!check(&opts, "organizations:test-feature", &ctx2));
799 }
800
801 #[test]
802 fn test_segment_with_no_conditions_always_matches() {
803 let feature = r#"{
804 "name": "test-feature",
805 "enabled": true,
806 "owner": {"team": "test-team"},
807 "created_at": "2024-01-01",
808 "segments": [{"name": "open", "rollout": 100, "conditions": []}]
809 }"#;
810 let (opts, _t) = setup_feature_options(feature);
811
812 let ctx = FeatureContext::new();
813 assert!(check(&opts, "organizations:test-feature", &ctx));
814 }
815
816 #[test]
817 fn test_feature_enabled_and_rollout_default_values() {
818 let feature = r#"{
820 "name": "test-feature",
821 "owner": {"team": "test-team"},
822 "created_at": "2024-01-01",
823 "segments": [
824 {
825 "name": "first",
826 "conditions": [{"property": "org_id", "operator": "in", "value":[1]}]
827 }
828 ]
829 }"#;
830 let (opts, _t) = setup_feature_options(feature);
831
832 let mut ctx = FeatureContext::new();
833 ctx.insert("org_id", 1);
834 ctx.identity_fields(vec!["org_id"]);
835 assert!(check(&opts, "organizations:test-feature", &ctx));
836 }
837
838 #[test]
839 fn test_feature_with_no_segments_returns_false() {
840 let feature = r#"{
841 "name": "test-feature",
842 "enabled": true,
843 "owner": {"team": "test-team"},
844 "created_at": "2024-01-01",
845 "segments": []
846 }"#;
847 let (opts, _t) = setup_feature_options(feature);
848
849 let ctx = FeatureContext::new();
850 assert!(!check(&opts, "organizations:test-feature", &ctx));
851 }
852
853 #[test]
854 fn test_multiple_segments_or_logic() {
855 let feature = r#"{
856 "name": "test-feature",
857 "enabled": true,
858 "owner": {"team": "test-team"},
859 "created_at": "2024-01-01",
860 "segments": [
861 {
862 "name": "segment-a",
863 "rollout": 100,
864 "conditions": [{"property": "org_id", "operator": "in", "value": [1]}]
865 },
866 {
867 "name": "segment-b",
868 "rollout": 100,
869 "conditions": [{"property": "org_id", "operator": "in", "value": [2]}]
870 }
871 ]
872 }"#;
873 let (opts, _t) = setup_feature_options(feature);
874
875 let mut ctx1 = FeatureContext::new();
876 ctx1.insert("org_id", json!(1));
877 assert!(check(&opts, "organizations:test-feature", &ctx1));
878
879 let mut ctx2 = FeatureContext::new();
880 ctx2.insert("org_id", json!(2));
881 assert!(check(&opts, "organizations:test-feature", &ctx2));
882
883 let mut ctx3 = FeatureContext::new();
884 ctx3.insert("org_id", json!(3));
885 assert!(!check(&opts, "organizations:test-feature", &ctx3));
886 }
887
888 #[test]
889 fn test_multiple_conditions_and_logic() {
890 let conds = r#"
891 {"property": "org_id", "operator": "in", "value": [123]},
892 {"property": "user_email", "operator": "in", "value": ["admin@example.com"]}
893 "#;
894 let (opts, _t) = setup_feature_options(&feature_json(true, 100, conds));
895
896 let mut ctx = FeatureContext::new();
897 ctx.insert("org_id", json!(123));
898 ctx.insert("user_email", json!("admin@example.com"));
899 assert!(check(&opts, "organizations:test-feature", &ctx));
900
901 let mut ctx2 = FeatureContext::new();
902 ctx2.insert("org_id", json!(123));
903 ctx2.insert("user_email", json!("other@example.com"));
904 assert!(!check(&opts, "organizations:test-feature", &ctx2));
905 }
906
907 #[test]
908 fn test_in_int_context_against_string_list_returns_false() {
909 let cond = r#"{"property": "org_id", "operator": "in", "value": ["123", "456"]}"#;
910 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
911
912 let mut ctx = FeatureContext::new();
913 ctx.insert("org_id", json!(123));
914 assert!(!check(&opts, "organizations:test-feature", &ctx));
915 }
916
917 #[test]
918 fn test_in_string_context_against_int_list_returns_false() {
919 let cond = r#"{"property": "slug", "operator": "in", "value": [123, 456]}"#;
920 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
921
922 let mut ctx = FeatureContext::new();
923 ctx.insert("slug", json!("123"));
924 assert!(!check(&opts, "organizations:test-feature", &ctx));
925 }
926
927 #[test]
928 fn test_in_bool_context_against_string_list_returns_false() {
929 let cond = r#"{"property": "active", "operator": "in", "value": ["true", "false"]}"#;
930 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
931
932 let mut ctx = FeatureContext::new();
933 ctx.insert("active", json!(true));
934 assert!(!check(&opts, "organizations:test-feature", &ctx));
935 }
936
937 #[test]
938 fn test_in_float_context_against_string_list_returns_false() {
939 let cond = r#"{"property": "score", "operator": "in", "value": ["0.5", "1.0"]}"#;
940 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
941
942 let mut ctx = FeatureContext::new();
943 ctx.insert("score", json!(0.5));
944 assert!(!check(&opts, "organizations:test-feature", &ctx));
945 }
946
947 #[test]
948 fn test_not_in_int_context_against_string_list_returns_true() {
949 let cond = r#"{"property": "org_id", "operator": "not_in", "value": ["123", "456"]}"#;
951 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
952
953 let mut ctx = FeatureContext::new();
954 ctx.insert("org_id", json!(123));
955 assert!(check(&opts, "organizations:test-feature", &ctx));
956 }
957
958 #[test]
959 fn test_not_in_string_context_against_int_list_returns_true() {
960 let cond = r#"{"property": "slug", "operator": "not_in", "value": [123, 456]}"#;
962 let (opts, _t) = setup_feature_options(&feature_json(true, 100, cond));
963
964 let mut ctx = FeatureContext::new();
965 ctx.insert("slug", json!("123"));
966 assert!(check(&opts, "organizations:test-feature", &ctx));
967 }
968}