Skip to main content

stateset_core/models/
segment.rs

1//! Customer segment domain models
2//!
3//! Supports static (manually curated) and dynamic (rule-based) customer segments
4//! for targeted marketing, pricing, and analytics.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use stateset_primitives::{CustomerId, SegmentId};
9use strum::{Display, EnumString};
10
11/// Segment type
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Display, EnumString,
14)]
15#[serde(rename_all = "snake_case")]
16#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
17#[non_exhaustive]
18pub enum SegmentType {
19    /// Manually curated membership list
20    #[default]
21    Static,
22    /// Membership determined by rules evaluated at query time
23    Dynamic,
24}
25
26/// Rule operator for dynamic segment evaluation
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]
28#[serde(rename_all = "snake_case")]
29#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
30#[non_exhaustive]
31pub enum SegmentOperator {
32    /// Exact equality
33    Eq,
34    /// Not equal
35    Neq,
36    /// Greater than
37    Gt,
38    /// Greater than or equal
39    Gte,
40    /// Less than
41    Lt,
42    /// Less than or equal
43    Lte,
44    /// String contains
45    Contains,
46    /// Value is in a set
47    In,
48    /// Value is between two bounds (inclusive)
49    Between,
50    /// String starts with
51    StartsWith,
52    /// String ends with
53    EndsWith,
54}
55
56/// A single rule in a dynamic segment
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SegmentRule {
59    /// The customer field to evaluate (e.g., "`total_orders`", "city", "tags")
60    pub field: String,
61    /// The comparison operator
62    pub operator: SegmentOperator,
63    /// The value(s) to compare against (JSON-encoded for flexibility)
64    pub value: String,
65}
66
67/// A customer segment
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Segment {
70    /// Unique segment ID
71    pub id: SegmentId,
72    /// Human-readable segment name
73    pub name: String,
74    /// Description of the segment's purpose
75    pub description: Option<String>,
76    /// Whether this is a static or dynamic segment
77    pub segment_type: SegmentType,
78    /// Rules for dynamic segments (empty for static)
79    pub rules: Vec<SegmentRule>,
80    /// Current member count (cached for dynamic segments)
81    pub member_count: u64,
82    /// When the segment was created
83    pub created_at: DateTime<Utc>,
84    /// When the segment was last updated
85    pub updated_at: DateTime<Utc>,
86}
87
88/// Segment membership record (for static segments or cached dynamic results)
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SegmentMembership {
91    /// The segment
92    pub segment_id: SegmentId,
93    /// The customer
94    pub customer_id: CustomerId,
95    /// When the customer joined this segment
96    pub joined_at: DateTime<Utc>,
97}
98
99/// Input for creating a new segment
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CreateSegment {
102    /// Segment name
103    pub name: String,
104    /// Description
105    pub description: Option<String>,
106    /// Segment type
107    pub segment_type: SegmentType,
108    /// Rules (required for dynamic segments)
109    pub rules: Vec<SegmentRule>,
110}
111
112/// Input for updating a segment
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct UpdateSegment {
115    /// Updated name
116    pub name: Option<String>,
117    /// Updated description
118    pub description: Option<Option<String>>,
119    /// Updated rules (replaces all existing rules)
120    pub rules: Option<Vec<SegmentRule>>,
121}
122
123/// Filter for listing segments
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
125pub struct SegmentFilter {
126    /// Filter by type
127    pub segment_type: Option<SegmentType>,
128    /// Search by name
129    pub name: Option<String>,
130    /// Maximum results
131    pub limit: Option<u32>,
132    /// Offset for pagination
133    pub offset: Option<u32>,
134}
135
136impl Segment {
137    /// Whether this is a dynamic (rule-based) segment
138    #[must_use]
139    pub fn is_dynamic(&self) -> bool {
140        self.segment_type == SegmentType::Dynamic
141    }
142
143    /// Whether this segment has any rules defined
144    #[must_use]
145    pub fn has_rules(&self) -> bool {
146        !self.rules.is_empty()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use chrono::Utc;
154    use stateset_primitives::SegmentId;
155
156    fn make_segment(segment_type: SegmentType, rules: Vec<SegmentRule>) -> Segment {
157        Segment {
158            id: SegmentId::new(),
159            name: "Test Segment".to_string(),
160            description: None,
161            segment_type,
162            rules,
163            member_count: 0,
164            created_at: Utc::now(),
165            updated_at: Utc::now(),
166        }
167    }
168
169    fn make_rule(operator: SegmentOperator) -> SegmentRule {
170        SegmentRule { field: "total_orders".to_string(), operator, value: "5".to_string() }
171    }
172
173    // ---- is_dynamic ----
174
175    #[test]
176    fn is_dynamic_returns_true_for_dynamic_segment() {
177        let segment = make_segment(SegmentType::Dynamic, vec![make_rule(SegmentOperator::Gt)]);
178        assert!(segment.is_dynamic());
179    }
180
181    #[test]
182    fn is_dynamic_returns_false_for_static_segment() {
183        let segment = make_segment(SegmentType::Static, vec![]);
184        assert!(!segment.is_dynamic());
185    }
186
187    // ---- has_rules ----
188
189    #[test]
190    fn has_rules_returns_true_when_rules_exist() {
191        let segment = make_segment(SegmentType::Dynamic, vec![make_rule(SegmentOperator::Eq)]);
192        assert!(segment.has_rules());
193    }
194
195    #[test]
196    fn has_rules_returns_false_when_no_rules() {
197        let segment = make_segment(SegmentType::Static, vec![]);
198        assert!(!segment.has_rules());
199    }
200
201    // ---- enum Display / FromStr round-trips ----
202
203    #[test]
204    fn segment_type_display_fromstr_roundtrip() {
205        for seg_type in [SegmentType::Static, SegmentType::Dynamic] {
206            let s = seg_type.to_string();
207            let parsed: SegmentType = s.parse().unwrap();
208            assert_eq!(parsed, seg_type, "round-trip failed for {s}");
209        }
210    }
211
212    #[test]
213    fn segment_operator_key_variants_display_fromstr_roundtrip() {
214        for op in [
215            SegmentOperator::Eq,
216            SegmentOperator::Gt,
217            SegmentOperator::Contains,
218            SegmentOperator::Between,
219            SegmentOperator::Neq,
220            SegmentOperator::Gte,
221            SegmentOperator::Lt,
222            SegmentOperator::Lte,
223            SegmentOperator::In,
224            SegmentOperator::StartsWith,
225            SegmentOperator::EndsWith,
226        ] {
227            let s = op.to_string();
228            let parsed: SegmentOperator = s.parse().unwrap();
229            assert_eq!(parsed, op, "round-trip failed for {s}");
230        }
231    }
232
233    // ---- Defaults ----
234
235    #[test]
236    fn segment_type_default_is_static() {
237        assert_eq!(SegmentType::default(), SegmentType::Static);
238    }
239}