• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Unleash / unleash-types-rs / #311

04 Feb 2026 10:18AM UTC coverage: 81.953% (-0.1%) from 82.059%
#311

push

277 of 338 relevant lines covered (81.95%)

2.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

73.84
/src/client_features.rs
1
#[cfg(feature = "hashes")]
2
use base64::Engine;
3
use std::collections::HashMap;
4
use std::hash::{Hash, Hasher};
5
use std::{cmp::Ordering, collections::BTreeMap};
6
#[cfg(feature = "openapi")]
7
use utoipa::{IntoParams, ToSchema};
8

9
use chrono::{DateTime, Utc};
10
use serde::{Deserialize, Deserializer, Serialize, Serializer};
11
use serde_json::{Map, Value};
12
#[cfg(feature = "hashes")]
13
use xxhash_rust::xxh3::xxh3_128;
14

15
use crate::{Deduplicate, Merge, Upsert};
16

17
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
18
#[cfg_attr(feature = "openapi", derive(ToSchema, IntoParams))]
19
#[serde(rename_all = "camelCase")]
20
pub struct Query {
21
    #[serde(skip_serializing_if = "Option::is_none")]
22
    pub tags: Option<Vec<Vec<String>>>,
23
    #[serde(skip_serializing_if = "Option::is_none")]
24
    pub projects: Option<Vec<String>>,
25
    #[serde(skip_serializing_if = "Option::is_none")]
26
    pub name_prefix: Option<String>,
27
    #[serde(skip_serializing_if = "Option::is_none")]
28
    pub environment: Option<String>,
29
    #[serde(skip_serializing_if = "Option::is_none")]
30
    pub inline_segment_constraints: Option<bool>,
31
}
32

33
#[derive(Serialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
34
#[cfg_attr(feature = "openapi", derive(ToSchema))]
35
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
36
pub enum Operator {
37
    NotIn,
38
    In,
39
    StrEndsWith,
40
    StrStartsWith,
41
    StrContains,
42
    NumEq,
43
    NumGt,
44
    NumGte,
45
    NumLt,
46
    NumLte,
47
    DateAfter,
48
    DateBefore,
49
    SemverEq,
50
    SemverLt,
51
    SemverGt,
52
    Unknown(String),
53
}
54

55
#[derive(Serialize, Debug, Clone)]
56
#[cfg_attr(feature = "openapi", derive(ToSchema, IntoParams))]
57
#[cfg_attr(feature = "openapi", into_params(style = Form, parameter_in = Query))]
58
#[serde(rename_all = "camelCase")]
59
pub struct Context {
60
    pub user_id: Option<String>,
61
    pub session_id: Option<String>,
62
    pub environment: Option<String>,
63
    pub app_name: Option<String>,
64
    pub current_time: Option<String>,
65
    pub remote_address: Option<String>,
66
    #[cfg_attr(feature = "openapi", param(style = Form, explode = false, value_type = Object))]
67
    pub properties: Option<HashMap<String, String>>,
68
}
69

70
impl<'de> Deserialize<'de> for Context {
71
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3✔
72
    where
73
        D: Deserializer<'de>,
74
    {
75
        let mut raw: Map<String, Value> = Deserialize::deserialize(deserializer)?;
3✔
76

77
        if let Some(context_val) = raw.remove("context") {
6✔
78
            if let Value::Object(inner) = context_val {
×
79
                return Context::from_map(inner);
×
80
            } else {
81
                return Err(serde::de::Error::custom(
×
82
                    "Expected 'context' to be an object",
×
83
                ));
84
            }
85
        }
86

87
        Context::from_map(raw)
3✔
88
    }
89
}
90

91
impl Context {
92
    fn from_map<E: serde::de::Error>(mut raw: Map<String, Value>) -> Result<Self, E> {
3✔
93
        fn parse_value(v: Value) -> Option<String> {
1✔
94
            match v {
2✔
95
                Value::String(s) => Some(s),
1✔
96
                Value::Number(n) => Some(n.to_string()),
2✔
97
                Value::Bool(b) => Some(b.to_string()),
2✔
98
                _ => None,
1✔
99
            }
100
        }
101

102
        fn extract_property(
1✔
103
            raw: &mut Map<String, Value>,
104
            props: &mut HashMap<String, String>,
105
            key: &str,
106
        ) -> Option<String> {
107
            raw.remove(key)
1✔
108
                .or_else(|| props.remove(key).map(Value::String))
3✔
109
                .and_then(parse_value)
1✔
110
        }
111

112
        let mut props: HashMap<String, String> = raw
3✔
113
            .remove("properties")
114
            .and_then(|v| v.as_object().cloned())
9✔
115
            .unwrap_or_default()
116
            .into_iter()
117
            .filter_map(|(k, v)| parse_value(v).map(|s| (k, s)))
13✔
118
            .collect();
119

120
        let user_id = extract_property(&mut raw, &mut props, "userId");
4✔
121
        let session_id = extract_property(&mut raw, &mut props, "sessionId");
5✔
122
        let environment = extract_property(&mut raw, &mut props, "environment");
6✔
123
        let app_name = extract_property(&mut raw, &mut props, "appName");
6✔
124
        let current_time = extract_property(&mut raw, &mut props, "currentTime");
6✔
125
        let remote_address = extract_property(&mut raw, &mut props, "remoteAddress");
6✔
126

127
        // Whatever's left in `raw` now is junk, it can get moved to properties
128
        for (k, v) in raw.into_iter() {
9✔
129
            if let Some(s) = v.as_str() {
×
130
                props.insert(k, s.to_string());
×
131
            }
132
        }
133

134
        Ok(Context {
3✔
135
            user_id,
3✔
136
            session_id,
3✔
137
            environment,
3✔
138
            app_name,
3✔
139
            current_time,
3✔
140
            remote_address,
3✔
141
            properties: if props.is_empty() { None } else { Some(props) },
6✔
142
        })
143
    }
144
}
145

146
/// We need this to ensure that ClientFeatures gets a deterministic serialization.
147
fn optional_ordered_map<S>(
2✔
148
    value: &Option<HashMap<String, String>>,
149
    serializer: S,
150
) -> Result<S::Ok, S::Error>
151
where
152
    S: Serializer,
153
{
154
    match value {
1✔
155
        Some(m) => {
1✔
156
            let ordered: BTreeMap<_, _> = m.iter().collect();
2✔
157
            ordered.serialize(serializer)
2✔
158
        }
159
        None => serializer.serialize_none(),
×
160
    }
161
}
162

163
impl Default for Context {
164
    fn default() -> Self {
×
165
        Self {
166
            user_id: None,
167
            session_id: None,
168
            environment: None,
169
            current_time: None,
170
            app_name: None,
171
            remote_address: None,
172
            properties: Some(HashMap::new()),
×
173
        }
174
    }
175
}
176

177
impl<'de> Deserialize<'de> for Operator {
178
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4✔
179
    where
180
        D: Deserializer<'de>,
181
    {
182
        let s = String::deserialize(deserializer)?;
10✔
183
        Ok(match s.as_str() {
14✔
184
            "NOT_IN" => Operator::NotIn,
7✔
185
            "IN" => Operator::In,
14✔
186
            "STR_ENDS_WITH" => Operator::StrEndsWith,
8✔
187
            "STR_STARTS_WITH" => Operator::StrStartsWith,
9✔
188
            "STR_CONTAINS" => Operator::StrContains,
10✔
189
            "NUM_EQ" => Operator::NumEq,
8✔
190
            "NUM_GT" => Operator::NumGt,
9✔
191
            "NUM_GTE" => Operator::NumGte,
10✔
192
            "NUM_LT" => Operator::NumLt,
10✔
193
            "NUM_LTE" => Operator::NumLte,
10✔
194
            "DATE_AFTER" => Operator::DateAfter,
11✔
195
            "DATE_BEFORE" => Operator::DateBefore,
14✔
196
            "SEMVER_EQ" => Operator::SemverEq,
13✔
197
            "SEMVER_LT" => Operator::SemverLt,
10✔
198
            "SEMVER_GT" => Operator::SemverGt,
9✔
199
            _ => Operator::Unknown(s),
2✔
200
        })
201
    }
202
}
203

204
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
205
#[cfg_attr(feature = "openapi", derive(ToSchema))]
206
#[serde(rename_all = "camelCase")]
207
pub struct Constraint {
208
    pub context_name: String,
209
    pub operator: Operator,
210
    #[serde(default)]
211
    pub case_insensitive: bool,
212
    #[serde(default)]
213
    pub inverted: bool,
214
    #[serde(skip_serializing_if = "Option::is_none")]
215
    pub values: Option<Vec<String>>,
216
    #[serde(skip_serializing_if = "Option::is_none")]
217
    pub value: Option<String>,
218
}
219

220
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
221
#[cfg_attr(feature = "openapi", derive(ToSchema))]
222
#[serde(rename_all = "camelCase")]
223
pub enum WeightType {
224
    Fix,
225
    Variable,
226
}
227

228
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
229
#[cfg_attr(feature = "openapi", derive(ToSchema))]
230
#[serde(rename_all = "camelCase")]
231
pub struct Strategy {
232
    pub name: String,
233
    #[serde(skip_serializing_if = "Option::is_none")]
234
    pub sort_order: Option<i32>,
235
    #[serde(skip_serializing_if = "Option::is_none")]
236
    pub segments: Option<Vec<i32>>,
237
    #[serde(skip_serializing_if = "Option::is_none")]
238
    pub constraints: Option<Vec<Constraint>>,
239
    #[serde(
240
        serialize_with = "optional_ordered_map",
241
        skip_serializing_if = "Option::is_none"
242
    )]
243
    pub parameters: Option<HashMap<String, String>>,
244
    #[serde(serialize_with = "serialize_option_vec")]
245
    pub variants: Option<Vec<StrategyVariant>>,
246
}
247

248
fn serialize_option_vec<S, T>(value: &Option<Vec<T>>, serializer: S) -> Result<S::Ok, S::Error>
1✔
249
where
250
    S: Serializer,
251
    T: Serialize,
252
{
253
    match value {
1✔
254
        Some(ref v) => v.serialize(serializer),
2✔
255
        None => Vec::<T>::new().serialize(serializer),
2✔
256
    }
257
}
258

259
impl PartialEq for Strategy {
260
    fn eq(&self, other: &Self) -> bool {
×
261
        self.name == other.name
×
262
            && self.sort_order == other.sort_order
×
263
            && self.segments == other.segments
×
264
            && self.constraints == other.constraints
×
265
            && self.parameters == other.parameters
×
266
    }
267
}
268
impl PartialOrd for Strategy {
269
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
×
270
        Some(self.cmp(other))
×
271
    }
272
}
273
impl Ord for Strategy {
274
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
×
275
        match self.sort_order.cmp(&other.sort_order) {
×
276
            Ordering::Equal => self.name.cmp(&other.name),
×
277
            ord => ord,
×
278
        }
279
    }
280
}
281

282
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
283
#[cfg_attr(feature = "openapi", derive(ToSchema))]
284
#[serde(rename_all = "camelCase")]
285
pub struct Override {
286
    pub context_name: String,
287
    pub values: Vec<String>,
288
}
289

290
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
291
#[cfg_attr(feature = "openapi", derive(ToSchema))]
292
pub struct Payload {
293
    #[serde(rename = "type")]
294
    pub payload_type: String,
295
    pub value: String,
296
}
297
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
298
#[cfg_attr(feature = "openapi", derive(ToSchema))]
299
#[serde(rename_all = "camelCase")]
300
pub struct Variant {
301
    pub name: String,
302
    pub weight: i32,
303
    #[serde(skip_serializing_if = "Option::is_none")]
304
    pub weight_type: Option<WeightType>,
305
    #[serde(skip_serializing_if = "Option::is_none")]
306
    pub stickiness: Option<String>,
307
    #[serde(skip_serializing_if = "Option::is_none")]
308
    pub payload: Option<Payload>,
309
    #[serde(skip_serializing_if = "Option::is_none")]
310
    pub overrides: Option<Vec<Override>>,
311
}
312

313
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
314
#[cfg_attr(feature = "openapi", derive(ToSchema))]
315
#[serde(rename_all = "camelCase")]
316
pub struct StrategyVariant {
317
    pub name: String,
318
    pub weight: i32,
319
    #[serde(skip_serializing_if = "Option::is_none")]
320
    pub payload: Option<Payload>,
321
    #[serde(skip_serializing_if = "Option::is_none")]
322
    pub stickiness: Option<String>,
323
}
324

325
impl PartialOrd for Variant {
326
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
×
327
        Some(self.cmp(other))
×
328
    }
329
}
330
impl Ord for Variant {
331
    fn cmp(&self, other: &Self) -> Ordering {
×
332
        self.name.cmp(&other.name)
×
333
    }
334
}
335

336
#[derive(Serialize, Deserialize, Debug, Clone, Eq)]
337
#[cfg_attr(feature = "openapi", derive(ToSchema))]
338
#[serde(rename_all = "camelCase")]
339
pub struct Segment {
340
    pub id: i32,
341
    pub constraints: Vec<Constraint>,
342
}
343

344
impl PartialEq for Segment {
345
    fn eq(&self, other: &Self) -> bool {
1✔
346
        self.id == other.id
1✔
347
    }
348
}
349

350
impl PartialOrd for Segment {
351
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
352
        Some(self.cmp(other))
1✔
353
    }
354
}
355

356
impl Ord for Segment {
357
    fn cmp(&self, other: &Self) -> Ordering {
1✔
358
        self.id.cmp(&other.id)
1✔
359
    }
360
}
361

362
impl Hash for Segment {
363
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
364
        self.id.hash(state);
1✔
365
    }
366
}
367

368
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
369
#[cfg_attr(feature = "openapi", derive(ToSchema))]
370
#[serde(rename_all = "camelCase")]
371
pub struct FeatureDependency {
372
    pub feature: String,
373
    #[serde(skip_serializing_if = "Option::is_none")]
374
    pub enabled: Option<bool>,
375
    #[serde(skip_serializing_if = "Option::is_none")]
376
    pub variants: Option<Vec<String>>,
377
}
378

379
#[derive(Serialize, Deserialize, Debug, Clone, Eq, Default)]
380
#[cfg_attr(feature = "openapi", derive(ToSchema))]
381
#[serde(rename_all = "camelCase")]
382
pub struct ClientFeature {
383
    pub name: String,
384
    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
385
    pub feature_type: Option<String>,
386
    #[serde(skip_serializing_if = "Option::is_none")]
387
    pub description: Option<String>,
388
    #[serde(skip_serializing_if = "Option::is_none")]
389
    pub created_at: Option<DateTime<Utc>>,
390
    #[serde(skip_serializing_if = "Option::is_none")]
391
    pub last_seen_at: Option<DateTime<Utc>>,
392
    pub enabled: bool,
393
    #[serde(skip_serializing_if = "Option::is_none")]
394
    pub stale: Option<bool>,
395
    #[serde(skip_serializing_if = "Option::is_none")]
396
    pub impression_data: Option<bool>,
397
    #[serde(skip_serializing_if = "Option::is_none")]
398
    pub project: Option<String>,
399
    #[serde(skip_serializing_if = "Option::is_none")]
400
    pub strategies: Option<Vec<Strategy>>,
401
    #[serde(skip_serializing_if = "Option::is_none")]
402
    pub variants: Option<Vec<Variant>>,
403
    #[serde(skip_serializing_if = "Option::is_none")]
404
    pub dependencies: Option<Vec<FeatureDependency>>,
405
}
406

407
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)]
408
#[cfg_attr(feature = "openapi", derive(ToSchema))]
409
#[serde(rename_all = "camelCase")]
410
pub struct Meta {
411
    pub etag: Option<String>,
412
    pub revision_id: Option<usize>,
413
    pub query_hash: Option<String>,
414
}
415

416
impl Merge for ClientFeatures {
417
    fn merge(self, other: Self) -> Self {
1✔
418
        let mut features = self.features.merge(other.features);
2✔
419
        features.sort();
2✔
420
        let segments = match (self.segments, other.segments) {
1✔
421
            (Some(mut s), Some(o)) => {
×
422
                s.extend(o);
×
423
                Some(s.deduplicate())
×
424
            }
425
            (Some(s), None) => Some(s),
×
426
            (None, Some(o)) => Some(o),
×
427
            (None, None) => None,
1✔
428
        };
429
        ClientFeatures {
430
            version: self.version.max(other.version),
1✔
431
            features,
432
            segments: segments.map(|mut s| {
1✔
433
                s.sort();
434
                s
435
            }),
436
            query: self.query.or(other.query),
1✔
437
            meta: other.meta.or(self.meta),
1✔
438
        }
439
    }
440
}
441

442
impl Upsert for ClientFeatures {
443
    fn upsert(self, other: Self) -> Self {
1✔
444
        let mut features = self.features.upsert(other.features);
3✔
445
        features.sort();
4✔
446
        let segments = match (self.segments, other.segments) {
2✔
447
            (Some(s), Some(mut o)) => {
1✔
448
                o.extend(s);
1✔
449
                Some(o.deduplicate())
1✔
450
            }
451
            (Some(s), None) => Some(s),
×
452
            (None, Some(o)) => Some(o),
×
453
            (None, None) => None,
1✔
454
        };
455
        ClientFeatures {
456
            version: self.version.max(other.version),
1✔
457
            features,
458
            segments: segments.map(|mut s| {
2✔
459
                s.sort();
460
                s
461
            }),
462
            query: self.query.or(other.query),
1✔
463
            meta: other.meta.or(self.meta),
1✔
464
        }
465
    }
466
}
467

468
impl PartialOrd for ClientFeature {
469
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
1✔
470
        Some(self.cmp(other))
1✔
471
    }
472
}
473

474
impl Ord for ClientFeature {
475
    fn cmp(&self, other: &Self) -> Ordering {
1✔
476
        self.name.cmp(&other.name)
1✔
477
    }
478
}
479

480
impl PartialEq for ClientFeature {
481
    fn eq(&self, other: &Self) -> bool {
1✔
482
        self.name == other.name
1✔
483
    }
484
}
485

486
impl Hash for ClientFeature {
487
    fn hash<H: Hasher>(&self, state: &mut H) {
1✔
488
        self.name.hash(state);
1✔
489
    }
490
}
491

492
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
493
#[cfg_attr(feature = "openapi", derive(ToSchema))]
494
pub struct ClientFeatures {
495
    pub version: u32,
496
    pub features: Vec<ClientFeature>,
497
    #[serde(skip_serializing_if = "Option::is_none")]
498
    pub segments: Option<Vec<Segment>>,
499
    pub query: Option<Query>,
500
    #[serde(skip_serializing_if = "Option::is_none")]
501
    pub meta: Option<Meta>,
502
}
503

504
#[cfg(feature = "hashes")]
505
impl ClientFeatures {
506
    ///
507
    /// Returns a base64 encoded xx3_128 hash for the json representation of ClientFeatures
508
    ///
509
    pub fn xx3_hash(&self) -> Result<String, serde_json::Error> {
1✔
510
        serde_json::to_string(self)
1✔
511
            .map(|s| xxh3_128(s.as_bytes()))
3✔
512
            .map(|xxh_hash| base64::prelude::BASE64_URL_SAFE.encode(xxh_hash.to_le_bytes()))
3✔
513
    }
514
}
515

516
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
517
#[serde(tag = "type", rename_all = "kebab-case")]
518
#[cfg_attr(feature = "openapi", derive(ToSchema))]
519
pub enum DeltaEvent {
520
    /// Event for a feature update.
521
    FeatureUpdated {
522
        #[serde(rename = "eventId")]
523
        event_id: u32,
524
        feature: ClientFeature,
525
    },
526
    /// Event for a feature removal.
527
    #[serde(rename_all = "camelCase")]
528
    FeatureRemoved {
529
        event_id: u32,
530
        feature_name: String,
531
        project: String,
532
    },
533
    /// Event for a segment update.
534
    SegmentUpdated {
535
        #[serde(rename = "eventId")]
536
        event_id: u32,
537
        segment: Segment,
538
    },
539
    /// Event for a segment removal.
540
    #[serde(rename_all = "camelCase")]
541
    SegmentRemoved { event_id: u32, segment_id: i32 },
542
    /// Hydration event for features and segments.
543
    Hydration {
544
        #[serde(rename = "eventId")]
545
        event_id: u32,
546
        features: Vec<ClientFeature>,
547
        segments: Vec<Segment>,
548
    },
549
}
550

551
impl DeltaEvent {
552
    pub fn get_event_id(&self) -> u32 {
×
553
        match self {
×
554
            DeltaEvent::FeatureUpdated { event_id, .. }
×
555
            | DeltaEvent::FeatureRemoved { event_id, .. }
556
            | DeltaEvent::SegmentUpdated { event_id, .. }
557
            | DeltaEvent::SegmentRemoved { event_id, .. }
558
            | DeltaEvent::Hydration { event_id, .. } => *event_id,
559
        }
560
    }
561
}
562

563
/// Schema for delta updates of feature configurations.
564
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
565
#[serde(rename_all = "camelCase")]
566
#[cfg_attr(feature = "openapi", derive(ToSchema))]
567
pub struct ClientFeaturesDelta {
568
    /// A list of delta events.
569
    pub events: Vec<DeltaEvent>,
570
}
571

572
impl ClientFeatures {
573
    /// Modifies the current ClientFeatures instance by applying the events.
574
    pub fn apply_delta(&mut self, delta: &ClientFeaturesDelta) {
1✔
575
        self.apply_delta_events(delta);
1✔
576
    }
577

578
    /// Returns a new ClientFeatures instance with the events applied.
579
    pub fn create_from_delta(delta: &ClientFeaturesDelta) -> ClientFeatures {
1✔
580
        let mut client_features = ClientFeatures::default();
1✔
581
        client_features.apply_delta_events(delta);
1✔
582
        client_features
1✔
583
    }
584

585
    fn apply_delta_events(&mut self, delta: &ClientFeaturesDelta) {
1✔
586
        let segments = &mut self.segments;
1✔
587
        let features = &mut self.features;
1✔
588
        for event in &delta.events {
2✔
589
            match event {
1✔
590
                DeltaEvent::FeatureUpdated { feature, .. } => {
1✔
591
                    if let Some(existing) = features.iter_mut().find(|f| f.name == feature.name) {
5✔
592
                        *existing = feature.clone();
1✔
593
                    } else {
594
                        features.push(feature.clone());
1✔
595
                    }
596
                }
597
                DeltaEvent::FeatureRemoved { feature_name, .. } => {
1✔
598
                    features.retain(|f| f.name != *feature_name);
3✔
599
                }
600
                DeltaEvent::SegmentUpdated { segment, .. } => {
1✔
601
                    let segments_list = segments.get_or_insert_with(Vec::new);
1✔
602
                    if let Some(existing) = segments_list.iter_mut().find(|s| s.id == segment.id) {
5✔
603
                        *existing = segment.clone();
1✔
604
                    } else {
605
                        segments_list.push(segment.clone());
1✔
606
                    }
607
                }
608
                DeltaEvent::SegmentRemoved { segment_id, .. } => {
×
609
                    if let Some(segments_list) = segments {
×
610
                        segments_list.retain(|s| s.id != *segment_id);
×
611
                    }
612
                }
613
                DeltaEvent::Hydration {
×
614
                    features: new_features,
615
                    segments: new_segments,
616
                    ..
617
                } => {
618
                    *features = new_features.clone();
×
619
                    *segments = Some(new_segments.clone());
×
620
                }
621
            }
622
        }
623

624
        features.sort();
1✔
625
        if let Some(s) = segments.as_mut() {
626
            s.sort();
627
        }
628
    }
629
}
1✔
630

631
impl Default for ClientFeatures {
632
    fn default() -> Self {
1✔
633
        Self {
634
            version: 2,
635
            features: vec![],
636
            segments: None,
637
            query: None,
638
            meta: None,
639
        }
640
    }
641
}
×
642

×
643
impl From<ClientFeaturesDelta> for ClientFeatures {
644
    fn from(value: ClientFeaturesDelta) -> Self {
645
        ClientFeatures::create_from_delta(&value)
646
    }
647
}
×
648

×
649
impl From<&ClientFeaturesDelta> for ClientFeatures {
650
    fn from(value: &ClientFeaturesDelta) -> Self {
651
        ClientFeatures::create_from_delta(value)
652
    }
653
}
654

655
#[cfg(test)]
656
mod tests {
657
    use crate::{
658
        client_features::{ClientFeature, ClientFeaturesDelta},
659
        Merge, Upsert,
660
    };
661
    use serde_json::{from_reader, to_string};
662
    use serde_qs::Config;
663
    use std::{fs::File, io::BufReader, path::PathBuf};
664

665
    use super::{ClientFeatures, Constraint, DeltaEvent, Operator, Segment, Strategy};
666
    use crate::client_features::Context;
667
    use test_case::test_case;
668

669
    #[derive(Debug)]
670
    pub enum EdgeError {
671
        SomethingWentWrong,
672
    }
673
    #[test]
674
    pub fn can_deserialize_numbers_to_strings() {
675
        let json = serde_json::json!({
676
            "context": {
677
                "userId": 123123,
678
                "sessionId": false,
679
                "environment": {
680
                    "aKey": "aValue",
681
                },
682
                "appName": "name",
683
                "currentTime": null,
684
                "properties": {
685
                    "someValue": 123,
686
                    "otherValue": null,
687
                    "anotherValue": {
688
                        "someKey": 123,
689
                    },
690
                    "boolProp": true,
691
                }
692
            },
693
        });
694
        let context: Context = serde_json::from_value(json["context"].clone()).unwrap();
695
        assert_eq!(context.user_id.unwrap(), "123123");
696
        assert_eq!(context.session_id.unwrap(), "false");
697
        assert_eq!(context.app_name.unwrap(), "name");
698
        assert!(context.current_time.is_none());
699
        assert!(context.environment.is_none());
700
        assert!(context.remote_address.is_none());
701
        assert_eq!(
702
            context
703
                .properties
704
                .clone()
705
                .unwrap()
706
                .get("someValue")
707
                .unwrap(),
708
            "123"
709
        );
710
        assert_eq!(
711
            context.properties.clone().unwrap().get("boolProp").unwrap(),
712
            "true"
713
        );
714
        assert!(!context
715
            .properties
716
            .clone()
717
            .unwrap()
718
            .contains_key("otherValue"));
719
        assert!(!context
720
            .properties
721
            .clone()
722
            .unwrap()
723
            .contains_key("anotherValue"));
724
    }
725

726
    #[test]
727
    fn base_level_properties_in_properties_map_are_moved_to_base_level() {
728
        let json = serde_json::json!({
729
            "properties": {
730
                "userId": "promote-me",
731
                "someOtherProp": "stay-in-properties"
732
            },
733
            "appName": "edge-client"
734
        });
735

736
        let context: Context = serde_json::from_value(json).unwrap();
737

738
        assert_eq!(context.user_id.as_deref(), Some("promote-me"));
739
        assert_eq!(context.app_name.as_deref(), Some("edge-client"));
740

741
        let props = context.properties.unwrap();
742
        assert_eq!(props.get("someOtherProp").unwrap(), "stay-in-properties");
743
        assert!(!props.contains_key("userId"));
744
    }
745

746
    #[test]
747
    pub fn ordering_is_stable_for_constraints() {
748
        let c1 = Constraint {
749
            context_name: "acontext".into(),
750
            operator: super::Operator::DateAfter,
751
            case_insensitive: true,
752
            inverted: false,
753
            values: Some(vec![]),
754
            value: None,
755
        };
756
        let c2 = Constraint {
757
            context_name: "acontext".into(),
758
            operator: super::Operator::DateBefore,
759
            case_insensitive: false,
760
            inverted: false,
761
            values: None,
762
            value: Some("value".into()),
763
        };
764
        let c3 = Constraint {
765
            context_name: "bcontext".into(),
766
            operator: super::Operator::NotIn,
767
            case_insensitive: false,
768
            inverted: false,
769
            values: None,
770
            value: None,
771
        };
772
        let mut v = vec![c3.clone(), c1.clone(), c2.clone()];
773
        v.sort();
774
        assert_eq!(v, vec![c1, c2, c3]);
775
    }
776

777
    fn read_file(path: PathBuf) -> Result<BufReader<File>, EdgeError> {
778
        File::open(path)
779
            .map_err(|_| EdgeError::SomethingWentWrong)
780
            .map(BufReader::new)
781
    }
782

783
    #[test_case("./examples/features_with_variantType.json".into() ; "features with variantType")]
784
    #[test_case("./examples/15-global-constraints.json".into(); "global-constraints")]
785
    pub fn client_features_parsing_is_stable(path: PathBuf) {
786
        let client_features: ClientFeatures =
787
            serde_json::from_reader(read_file(path).unwrap()).unwrap();
788

789
        let to_string = serde_json::to_string(&client_features).unwrap();
790
        let reparsed_to_string: ClientFeatures = serde_json::from_str(to_string.as_str()).unwrap();
791
        assert_eq!(client_features, reparsed_to_string);
792
    }
793

794
    #[cfg(feature = "hashes")]
795
    #[test_case("./examples/features_with_variantType.json".into() ; "features with variantType")]
796
    #[cfg(feature = "hashes")]
797
    #[test_case("./examples/15-global-constraints.json".into(); "global-constraints")]
798
    pub fn client_features_hashing_is_stable(path: PathBuf) {
799
        let client_features: ClientFeatures =
800
            serde_json::from_reader(read_file(path.clone()).unwrap()).unwrap();
801

802
        let second_read: ClientFeatures =
803
            serde_json::from_reader(read_file(path).unwrap()).unwrap();
804

805
        let first_hash = client_features.xx3_hash().unwrap();
806
        let second_hash = client_features.xx3_hash().unwrap();
807
        assert_eq!(first_hash, second_hash);
808

809
        let first_hash_from_second_read = second_read.xx3_hash().unwrap();
810
        assert_eq!(first_hash, first_hash_from_second_read);
811
    }
812

813
    #[test]
814
    fn merging_two_client_features_takes_both_feature_sets() {
815
        let client_features_one = ClientFeatures {
816
            version: 2,
817
            features: vec![
818
                ClientFeature {
819
                    name: "feature1".into(),
820
                    ..ClientFeature::default()
821
                },
822
                ClientFeature {
823
                    name: "feature2".into(),
824
                    ..ClientFeature::default()
825
                },
826
            ],
827
            segments: None,
828
            query: None,
829
            meta: None,
830
        };
831

832
        let client_features_two = ClientFeatures {
833
            version: 2,
834
            features: vec![ClientFeature {
835
                name: "feature3".into(),
836
                ..ClientFeature::default()
837
            }],
838
            segments: None,
839
            query: None,
840
            meta: None,
841
        };
842

843
        let merged = client_features_one.merge(client_features_two);
844
        assert_eq!(merged.features.len(), 3);
845
    }
846

847
    #[test]
848
    fn upserting_client_features_prioritizes_new_data_but_keeps_uniques() {
849
        let client_features_one = ClientFeatures {
850
            version: 2,
851
            features: vec![
852
                ClientFeature {
853
                    name: "feature1".into(),
854
                    ..ClientFeature::default()
855
                },
856
                ClientFeature {
857
                    name: "feature2".into(),
858
                    ..ClientFeature::default()
859
                },
860
            ],
861
            segments: None,
862
            query: None,
863
            meta: None,
864
        };
865
        let mut updated_strategies = client_features_one.clone();
866
        let updated_feature_one_with_strategy = ClientFeature {
867
            name: "feature1".into(),
868
            strategies: Some(vec![Strategy {
869
                name: "default".into(),
870
                sort_order: Some(124),
871
                segments: None,
872
                constraints: None,
873
                parameters: None,
874
                variants: None,
875
            }]),
876
            ..ClientFeature::default()
877
        };
878
        let feature_the_third = ClientFeature {
879
            name: "feature3".into(),
880
            strategies: Some(vec![Strategy {
881
                name: "default".into(),
882
                sort_order: Some(124),
883
                segments: None,
884
                constraints: None,
885
                parameters: None,
886
                variants: None,
887
            }]),
888
            ..ClientFeature::default()
889
        };
890
        updated_strategies.features = vec![updated_feature_one_with_strategy, feature_the_third];
891
        let updated_features = client_features_one.upsert(updated_strategies);
892
        let client_features = updated_features.features;
893
        assert_eq!(client_features.len(), 3);
894
        let updated_feature_one = client_features
895
            .iter()
896
            .find(|f| f.name == "feature1")
897
            .unwrap();
898
        assert_eq!(updated_feature_one.strategies.as_ref().unwrap().len(), 1);
899
        assert!(client_features.iter().any(|f| f.name == "feature3"));
900
        assert!(client_features.iter().any(|f| f.name == "feature2"));
901
    }
902

903
    #[test]
904
    pub fn can_parse_properties_map_from_get_query_string() {
905
        let config = Config::new(5, false);
906
        let query_string =
907
            "userId=123123&properties[email]=test@test.com&properties%5BcompanyId%5D=bricks&properties%5Bhello%5D=world";
908
        let context: Context = config
909
            .deserialize_str(query_string)
910
            .expect("Could not parse query string");
911
        assert_eq!(context.user_id, Some("123123".to_string()));
912
        let prop_map = context.properties.unwrap();
913
        assert_eq!(prop_map.len(), 3);
914
        assert!(prop_map.contains_key("companyId"));
915
        assert!(prop_map.contains_key("hello"));
916
        assert!(prop_map.contains_key("email"));
917
    }
918

919
    #[test_case("./examples/delta_base.json".into(), "./examples/delta_patch.json".into(); "Base and delta")]
920
    pub fn can_take_delta_updates(base: PathBuf, delta: PathBuf) {
921
        let base_delta: ClientFeaturesDelta = from_reader(read_file(base).unwrap()).unwrap();
922
        let mut features = ClientFeatures {
923
            version: 2,
924
            features: vec![],
925
            segments: None,
926
            query: None,
927
            meta: None,
928
        };
929
        features.apply_delta(&base_delta);
930
        assert_eq!(features.features.len(), 3);
931
        let delta: ClientFeaturesDelta = from_reader(read_file(delta).unwrap()).unwrap();
932
        features.apply_delta(&delta);
933
        assert_eq!(features.features.len(), 2);
934
    }
935

936
    #[test_case("./examples/delta_base.json".into(), "./examples/delta_patch.json".into(); "Base and delta")]
937
    pub fn validate_delta_updates(base_path: PathBuf, delta_path: PathBuf) {
938
        let base_delta: ClientFeaturesDelta = from_reader(read_file(base_path).unwrap()).unwrap();
939

940
        let mut updated_features = ClientFeatures::create_from_delta(&base_delta);
941
        let expected_feature_count = base_delta
942
            .events
943
            .iter()
944
            .filter(|event| matches!(event, DeltaEvent::FeatureUpdated { .. }))
945
            .count();
946
        assert_eq!(updated_features.features.len(), expected_feature_count);
947

948
        let delta_update: ClientFeaturesDelta =
949
            from_reader(read_file(delta_path).unwrap()).unwrap();
950
        updated_features.apply_delta(&delta_update);
951

952
        let mut sorted_delta_features: Vec<ClientFeature> = delta_update
953
            .events
954
            .iter()
955
            .filter_map(|event| {
956
                if let DeltaEvent::FeatureUpdated { feature, .. } = event {
957
                    Some(feature.clone())
958
                } else {
959
                    None
960
                }
961
            })
962
            .collect();
963
        sorted_delta_features.sort();
964

965
        let serialized_delta_updates = to_string(&sorted_delta_features).unwrap();
966
        let serialized_final_features = to_string(&updated_features.features).unwrap();
967

968
        assert_eq!(serialized_delta_updates, serialized_final_features);
969
    }
970

971
    #[test]
972
    pub fn apply_delta_sorts_segments() {
973
        let delta = ClientFeaturesDelta {
974
            events: vec![
975
                DeltaEvent::SegmentUpdated {
976
                    event_id: 2,
977
                    segment: Segment {
978
                        id: 2,
979
                        constraints: vec![],
980
                    },
981
                },
982
                DeltaEvent::SegmentUpdated {
983
                    event_id: 1,
984
                    segment: Segment {
985
                        id: 1,
986
                        constraints: vec![],
987
                    },
988
                },
989
            ],
990
        };
991

992
        let mut client_features = ClientFeatures::default();
993
        client_features.apply_delta(&delta);
994

995
        let segments = client_features
996
            .segments
997
            .expect("segments should be present");
998
        assert_eq!(segments.len(), 2);
999
        assert_eq!(segments[0].id, 1);
1000
        assert_eq!(segments[1].id, 2);
1001
    }
1002

1003
    #[test]
1004
    pub fn apply_delta_sorts_existing_segments_after_update() {
1005
        let mut client_features = ClientFeatures {
1006
            version: 2,
1007
            features: vec![],
1008
            segments: Some(vec![
1009
                Segment {
1010
                    id: 3,
1011
                    constraints: vec![],
1012
                },
1013
                Segment {
1014
                    id: 1,
1015
                    constraints: vec![],
1016
                },
1017
            ]),
1018
            query: None,
1019
            meta: None,
1020
        };
1021

1022
        let delta = ClientFeaturesDelta {
1023
            events: vec![DeltaEvent::SegmentUpdated {
1024
                event_id: 1,
1025
                segment: Segment {
1026
                    id: 2,
1027
                    constraints: vec![],
1028
                },
1029
            }],
1030
        };
1031

1032
        client_features.apply_delta(&delta);
1033

1034
        let segments = client_features
1035
            .segments
1036
            .expect("segments should be present");
1037
        assert_eq!(segments.len(), 3);
1038
        assert_eq!(segments[0].id, 1);
1039
        assert_eq!(segments[1].id, 2);
1040
        assert_eq!(segments[2].id, 3);
1041
    }
1042

1043
    #[test]
1044
    pub fn when_strategy_variants_is_none_default_to_empty_vec() {
1045
        let client_features = ClientFeatures {
1046
            version: 2,
1047
            features: vec![ClientFeature {
1048
                name: "feature1".into(),
1049
                strategies: Some(vec![Strategy {
1050
                    name: "default".into(),
1051
                    sort_order: Some(124),
1052
                    segments: None,
1053
                    constraints: None,
1054
                    parameters: None,
1055
                    variants: None,
1056
                }]),
1057
                ..ClientFeature::default()
1058
            }],
1059
            segments: None,
1060
            query: None,
1061
            meta: None,
1062
        };
1063
        let client_features_json = serde_json::to_string(&client_features).unwrap();
1064
        let client_features_parsed: ClientFeatures =
1065
            serde_json::from_str(&client_features_json).unwrap();
1066
        let strategy = client_features_parsed
1067
            .features
1068
            .first()
1069
            .unwrap()
1070
            .strategies
1071
            .as_ref()
1072
            .unwrap()
1073
            .first()
1074
            .unwrap();
1075
        assert_eq!(strategy.variants.as_ref().unwrap().len(), 0);
1076
    }
1077

1078
    #[test]
1079
    pub fn upserting_features_with_segments_overrides_constraints_on_segments_with_same_id_but_keeps_non_overlapping_segments(
1080
    ) {
1081
        let client_features_one = ClientFeatures {
1082
            version: 2,
1083
            features: vec![],
1084
            segments: Some(vec![
1085
                Segment {
1086
                    constraints: vec![Constraint {
1087
                        case_insensitive: false,
1088
                        values: None,
1089
                        context_name: "location".into(),
1090
                        inverted: false,
1091
                        operator: Operator::In,
1092
                        value: Some("places".into()),
1093
                    }],
1094
                    id: 1,
1095
                },
1096
                Segment {
1097
                    constraints: vec![Constraint {
1098
                        case_insensitive: false,
1099
                        values: None,
1100
                        context_name: "hometown".into(),
1101
                        inverted: false,
1102
                        operator: Operator::In,
1103
                        value: Some("somewhere_nice".into()),
1104
                    }],
1105
                    id: 2,
1106
                },
1107
            ]),
1108
            query: None,
1109
            meta: None,
1110
        };
1111
        let client_features_two = ClientFeatures {
1112
            version: 2,
1113
            features: vec![],
1114
            segments: Some(vec![
1115
                Segment {
1116
                    constraints: vec![Constraint {
1117
                        case_insensitive: false,
1118
                        values: None,
1119
                        context_name: "location".into(),
1120
                        inverted: false,
1121
                        operator: Operator::In,
1122
                        value: Some("other-places".into()),
1123
                    }],
1124
                    id: 1,
1125
                },
1126
                Segment {
1127
                    constraints: vec![Constraint {
1128
                        case_insensitive: false,
1129
                        values: None,
1130
                        context_name: "origin".into(),
1131
                        inverted: false,
1132
                        operator: Operator::In,
1133
                        value: Some("africa".into()),
1134
                    }],
1135
                    id: 3,
1136
                },
1137
            ]),
1138
            query: None,
1139
            meta: None,
1140
        };
1141

1142
        let expected = vec![
1143
            Constraint {
1144
                case_insensitive: false,
1145
                values: None,
1146
                context_name: "hometown".into(),
1147
                inverted: false,
1148
                operator: Operator::In,
1149
                value: Some("somewhere_nice".into()),
1150
            },
1151
            Constraint {
1152
                case_insensitive: false,
1153
                values: None,
1154
                context_name: "location".into(),
1155
                inverted: false,
1156
                operator: Operator::In,
1157
                value: Some("other-places".into()),
1158
            },
1159
            Constraint {
1160
                case_insensitive: false,
1161
                values: None,
1162
                context_name: "origin".into(),
1163
                inverted: false,
1164
                operator: Operator::In,
1165
                value: Some("africa".into()),
1166
            },
1167
        ];
1168

1169
        let upserted = client_features_one
1170
            .clone()
1171
            .upsert(client_features_two.clone());
1172
        let mut new_constraints = upserted
1173
            .segments
1174
            .unwrap()
1175
            .iter()
1176
            .flat_map(|segment| segment.constraints.clone())
1177
            .collect::<Vec<Constraint>>();
1178
        new_constraints.sort_by(|a, b| a.context_name.cmp(&b.context_name));
1179

1180
        assert_eq!(new_constraints, expected);
1181
    }
1182

1183
    #[test]
1184
    pub fn when_meta_is_in_client_features_it_is_serialized() {
1185
        let client_features = ClientFeatures {
1186
            version: 2,
1187
            features: vec![],
1188
            segments: None,
1189
            query: None,
1190
            meta: Some(super::Meta {
1191
                etag: Some("123:wqeqwe".into()),
1192
                revision_id: Some(123),
1193
                query_hash: Some("wqeqwe".into()),
1194
            }),
1195
        };
1196
        let serialized = serde_json::to_string(&client_features).unwrap();
1197
        assert!(serialized.contains("meta"));
1198
    }
1199

1200
    #[test_case("./examples/nuno-response.json".into() ; "features with meta tag")]
1201
    pub fn can_parse_meta_from_upstream(path: PathBuf) {
1202
        let features: ClientFeatures = serde_json::from_reader(read_file(path).unwrap()).unwrap();
1203
        assert!(features.meta.is_some());
1204
        let meta = features.meta.unwrap();
1205
        assert_eq!(meta.etag, Some("\"537b2ba0:3726\"".into()));
1206
        assert_eq!(meta.revision_id, Some(3726));
1207
        assert_eq!(meta.query_hash, Some("537b2ba0".into()));
1208
    }
1209
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc