statsig_client/
user.rs

1use bon::bon;
2use hex;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use validator::Validate;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Validate, Default)]
9pub struct User {
10    #[serde(skip_serializing_if = "Option::is_none")]
11    #[validate(length(
12        min = 1,
13        max = 100,
14        message = "userID must be between 1 and 100 characters"
15    ))]
16    #[serde(rename = "userID")]
17    pub user_id: Option<String>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    #[validate(email(message = "Invalid email format"))]
21    pub email: Option<String>,
22
23    #[serde(skip_serializing_if = "Option::is_none")]
24    #[validate(length(min = 7, max = 45, message = "Invalid IP address format"))]
25    pub ip: Option<String>,
26
27    #[serde(skip_serializing_if = "Option::is_none")]
28    #[serde(rename = "userAgent")]
29    pub user_agent: Option<String>,
30
31    #[serde(skip_serializing_if = "Option::is_none")]
32    #[validate(length(min = 2, max = 2, message = "Country must be a 2-letter ISO code"))]
33    pub country: Option<String>,
34
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub locale: Option<String>,
37
38    #[serde(skip_serializing_if = "Option::is_none")]
39    #[serde(rename = "appVersion")]
40    pub app_version: Option<String>,
41
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub custom: Option<HashMap<String, serde_json::Value>>,
44
45    #[serde(skip_serializing_if = "Option::is_none")]
46    #[serde(rename = "privateAttributes")]
47    pub private_attributes: Option<HashMap<String, serde_json::Value>>,
48
49    #[serde(skip_serializing_if = "Option::is_none")]
50    #[serde(rename = "customIDs")]
51    pub custom_ids: Option<HashMap<String, String>>,
52
53    #[serde(skip_serializing_if = "Option::is_none")]
54    #[serde(rename = "statsigEnvironment")]
55    pub statsig_environment: Option<StatsigEnvironment>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
59pub struct StatsigEnvironment {
60    #[serde(rename = "tier")]
61    #[validate(custom(function = "validate_tier"))]
62    pub tier: EnvironmentTier,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum EnvironmentTier {
68    Production,
69    Staging,
70    Development,
71}
72
73fn validate_tier(tier: &EnvironmentTier) -> Result<(), validator::ValidationError> {
74    match tier {
75        EnvironmentTier::Production | EnvironmentTier::Staging | EnvironmentTier::Development => {
76            Ok(())
77        }
78    }
79}
80
81#[bon]
82impl User {
83    #[builder]
84    pub fn new(
85        #[builder(into)] user_id: Option<String>,
86        #[builder(into)] email: Option<String>,
87        #[builder(into)] ip: Option<String>,
88        #[builder(into)] user_agent: Option<String>,
89        #[builder(into)] country: Option<String>,
90        #[builder(into)] locale: Option<String>,
91        #[builder(into)] app_version: Option<String>,
92        #[builder(with = |iter: impl IntoIterator<Item = (impl Into<String>, serde_json::Value)>| {
93            iter.into_iter().map(|(k, v)| (k.into(), v)).collect()
94        })]
95        custom: Option<HashMap<String, serde_json::Value>>,
96        #[builder(with = |iter: impl IntoIterator<Item = (impl Into<String>, serde_json::Value)>| {
97            iter.into_iter().map(|(k, v)| (k.into(), v)).collect()
98        })]
99        private_attributes: Option<HashMap<String, serde_json::Value>>,
100        #[builder(with = |iter: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>| {
101            iter.into_iter().map(|(k, v)| (k.into(), v.into())).collect()
102        })]
103        custom_ids: Option<HashMap<String, String>>,
104        statsig_environment: Option<StatsigEnvironment>,
105    ) -> crate::error::Result<Self> {
106        let user = Self {
107            user_id,
108            email,
109            ip,
110            user_agent,
111            country,
112            locale,
113            app_version,
114            custom,
115            private_attributes,
116            custom_ids,
117            statsig_environment,
118        };
119
120        user.validate().map_err(|e| {
121            crate::error::StatsigError::user_validation(format!("User validation failed: {e}"))
122        })?;
123
124        Ok(user)
125    }
126}
127
128impl User {
129    pub fn validate_user(&self) -> crate::error::Result<()> {
130        self.validate().map_err(|e| {
131            crate::error::StatsigError::user_validation(format!("User validation failed: {e}"))
132        })
133    }
134
135    /// Creates a new user with just a user ID
136    ///
137    /// This is a convenience method for the most common use case
138    pub fn with_user_id(user_id: impl Into<String>) -> UserBuilder<user_builder::SetUserId> {
139        Self::builder().user_id(user_id)
140    }
141
142    pub fn get_primary_id(&self) -> Option<&str> {
143        self.user_id
144            .as_deref()
145            .or(self.email.as_deref())
146            .or_else(|| {
147                self.custom_ids
148                    .as_ref()
149                    .and_then(|ids| ids.values().next().map(|s| s.as_str()))
150            })
151    }
152
153    /// Get user ID (alias for userID for consistency)
154    pub fn user_id(&self) -> Option<&str> {
155        self.user_id.as_deref()
156    }
157
158    /// Generates a consistent hash for the user used in cache keys and batch grouping
159    ///
160    /// This hash includes all user identifiers to ensure consistent cache behavior
161    /// across different user representations with the same logical identity.
162    pub fn hash_for_cache(&self) -> String {
163        let mut hasher = Sha256::new();
164
165        if let Some(user_id) = &self.user_id {
166            hasher.update(user_id.as_bytes());
167        }
168        if let Some(email) = &self.email {
169            hasher.update(email.as_bytes());
170        }
171        if let Some(custom_ids) = &self.custom_ids {
172            for (key, value) in custom_ids {
173                hasher.update(key.as_bytes());
174                hasher.update(value.as_bytes());
175            }
176        }
177
178        hex::encode(hasher.finalize())
179    }
180}
181
182impl Default for StatsigEnvironment {
183    fn default() -> Self {
184        Self {
185            tier: EnvironmentTier::Development,
186        }
187    }
188}