telemetry_kit/sync/
config.rs

1//! Sync configuration
2
3use crate::error::{Result, TelemetryError};
4use uuid::Uuid;
5
6/// Default sync endpoint
7pub const DEFAULT_ENDPOINT: &str = "https://telemetry-kit.dev";
8
9/// Staging endpoint for testing
10pub const STAGING_ENDPOINT: &str = "https://staging.telemetry-kit.dev";
11
12/// Maximum batch size (per protocol spec)
13pub const MAX_BATCH_SIZE: usize = 1000;
14
15/// Default batch size
16pub const DEFAULT_BATCH_SIZE: usize = 100;
17
18/// Sync configuration
19#[derive(Debug, Clone)]
20pub struct SyncConfig {
21    /// API endpoint base URL
22    pub endpoint: String,
23
24    /// Organization ID
25    pub org_id: Uuid,
26
27    /// Application ID
28    pub app_id: Uuid,
29
30    /// API token
31    pub token: String,
32
33    /// API secret for HMAC signing
34    pub secret: String,
35
36    /// Batch size (1-1000)
37    pub batch_size: usize,
38
39    /// Maximum retry attempts
40    pub max_retries: u32,
41
42    /// Sync interval in seconds (0 = manual sync only)
43    pub sync_interval_secs: u64,
44
45    /// Enable DNT (Do Not Track) check
46    pub respect_dnt: bool,
47}
48
49impl SyncConfig {
50    /// Create a new sync configuration builder
51    pub fn builder() -> SyncConfigBuilder {
52        SyncConfigBuilder::new()
53    }
54
55    /// Get the full ingestion URL
56    pub fn ingestion_url(&self) -> String {
57        format!(
58            "{}/v1/ingest/{}/{}",
59            self.endpoint, self.org_id, self.app_id
60        )
61    }
62
63    /// Validate the configuration
64    pub fn validate(&self) -> Result<()> {
65        if self.token.is_empty() {
66            return Err(TelemetryError::invalid_config(
67                "token",
68                "Token cannot be empty. Generate one at telemetry-kit.dev/settings/tokens",
69            ));
70        }
71
72        if self.secret.is_empty() {
73            return Err(TelemetryError::invalid_config(
74                "secret",
75                "Secret cannot be empty. Copy it from telemetry-kit.dev/settings/tokens",
76            ));
77        }
78
79        if self.batch_size == 0 || self.batch_size > MAX_BATCH_SIZE {
80            return Err(TelemetryError::invalid_config(
81                "batch_size",
82                &format!(
83                    "Must be between 1 and {} (got {})",
84                    MAX_BATCH_SIZE, self.batch_size
85                ),
86            ));
87        }
88
89        Ok(())
90    }
91}
92
93/// Builder for sync configuration
94#[derive(Debug, Default)]
95pub struct SyncConfigBuilder {
96    endpoint: Option<String>,
97    org_id: Option<Uuid>,
98    app_id: Option<Uuid>,
99    token: Option<String>,
100    secret: Option<String>,
101    batch_size: Option<usize>,
102    max_retries: Option<u32>,
103    sync_interval_secs: Option<u64>,
104    respect_dnt: Option<bool>,
105}
106
107impl SyncConfigBuilder {
108    /// Create a new builder
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Set the API endpoint (default: production)
114    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
115        self.endpoint = Some(endpoint.into());
116        self
117    }
118
119    /// Use staging endpoint for testing
120    pub fn use_staging(mut self) -> Self {
121        self.endpoint = Some(STAGING_ENDPOINT.to_string());
122        self
123    }
124
125    /// Set organization ID
126    pub fn org_id(mut self, org_id: impl Into<String>) -> Result<Self> {
127        let org_id_str = org_id.into();
128        let uuid = Uuid::parse_str(&org_id_str)
129            .map_err(|_| TelemetryError::invalid_uuid("org_id", &org_id_str))?;
130        self.org_id = Some(uuid);
131        Ok(self)
132    }
133
134    /// Set organization ID from UUID
135    pub fn org_id_uuid(mut self, org_id: Uuid) -> Self {
136        self.org_id = Some(org_id);
137        self
138    }
139
140    /// Set application ID
141    pub fn app_id(mut self, app_id: impl Into<String>) -> Result<Self> {
142        let app_id_str = app_id.into();
143        let uuid = Uuid::parse_str(&app_id_str)
144            .map_err(|_| TelemetryError::invalid_uuid("app_id", &app_id_str))?;
145        self.app_id = Some(uuid);
146        Ok(self)
147    }
148
149    /// Set application ID from UUID
150    pub fn app_id_uuid(mut self, app_id: Uuid) -> Self {
151        self.app_id = Some(app_id);
152        self
153    }
154
155    /// Set API token
156    pub fn token(mut self, token: impl Into<String>) -> Self {
157        self.token = Some(token.into());
158        self
159    }
160
161    /// Set API secret
162    pub fn secret(mut self, secret: impl Into<String>) -> Self {
163        self.secret = Some(secret.into());
164        self
165    }
166
167    /// Set batch size (1-1000)
168    pub fn batch_size(mut self, batch_size: usize) -> Self {
169        self.batch_size = Some(batch_size);
170        self
171    }
172
173    /// Set maximum retry attempts
174    pub fn max_retries(mut self, max_retries: u32) -> Self {
175        self.max_retries = Some(max_retries);
176        self
177    }
178
179    /// Set sync interval in seconds (0 = manual only)
180    pub fn sync_interval_secs(mut self, interval: u64) -> Self {
181        self.sync_interval_secs = Some(interval);
182        self
183    }
184
185    /// Enable/disable DNT (Do Not Track) check
186    pub fn respect_dnt(mut self, respect: bool) -> Self {
187        self.respect_dnt = Some(respect);
188        self
189    }
190
191    /// Build the configuration
192    pub fn build(self) -> Result<SyncConfig> {
193        let config = SyncConfig {
194            endpoint: self
195                .endpoint
196                .unwrap_or_else(|| DEFAULT_ENDPOINT.to_string()),
197            org_id: self
198                .org_id
199                .ok_or_else(|| TelemetryError::missing_field("org_id"))?,
200            app_id: self
201                .app_id
202                .ok_or_else(|| TelemetryError::missing_field("app_id"))?,
203            token: self
204                .token
205                .ok_or_else(|| TelemetryError::missing_field("token"))?,
206            secret: self
207                .secret
208                .ok_or_else(|| TelemetryError::missing_field("secret"))?,
209            batch_size: self.batch_size.unwrap_or(DEFAULT_BATCH_SIZE),
210            max_retries: self.max_retries.unwrap_or(5),
211            sync_interval_secs: self.sync_interval_secs.unwrap_or(3600), // 1 hour default
212            respect_dnt: self.respect_dnt.unwrap_or(true),
213        };
214
215        config.validate()?;
216        Ok(config)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_config_builder() {
226        let config = SyncConfig::builder()
227            .org_id("550e8400-e29b-41d4-a716-446655440000")
228            .unwrap()
229            .app_id("7c9e6679-7425-40de-944b-e07fc1f90ae7")
230            .unwrap()
231            .token("tk_test_token")
232            .secret("test_secret")
233            .build()
234            .unwrap();
235
236        assert_eq!(config.endpoint, DEFAULT_ENDPOINT);
237        assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
238        assert!(config.respect_dnt);
239    }
240
241    #[test]
242    fn test_staging_endpoint() {
243        let config = SyncConfig::builder()
244            .use_staging()
245            .org_id("550e8400-e29b-41d4-a716-446655440000")
246            .unwrap()
247            .app_id("7c9e6679-7425-40de-944b-e07fc1f90ae7")
248            .unwrap()
249            .token("tk_test_token")
250            .secret("test_secret")
251            .build()
252            .unwrap();
253
254        assert_eq!(config.endpoint, STAGING_ENDPOINT);
255    }
256
257    #[test]
258    fn test_ingestion_url() {
259        let config = SyncConfig::builder()
260            .org_id("550e8400-e29b-41d4-a716-446655440000")
261            .unwrap()
262            .app_id("7c9e6679-7425-40de-944b-e07fc1f90ae7")
263            .unwrap()
264            .token("tk_test_token")
265            .secret("test_secret")
266            .build()
267            .unwrap();
268
269        let url = config.ingestion_url();
270        assert!(url.contains("/v1/ingest/"));
271        assert!(url.contains("550e8400-e29b-41d4-a716-446655440000"));
272        assert!(url.contains("7c9e6679-7425-40de-944b-e07fc1f90ae7"));
273    }
274
275    #[test]
276    fn test_validation_empty_token() {
277        let result = SyncConfig::builder()
278            .org_id("550e8400-e29b-41d4-a716-446655440000")
279            .unwrap()
280            .app_id("7c9e6679-7425-40de-944b-e07fc1f90ae7")
281            .unwrap()
282            .token("")
283            .secret("test_secret")
284            .build();
285
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_validation_invalid_batch_size() {
291        let result = SyncConfig::builder()
292            .org_id("550e8400-e29b-41d4-a716-446655440000")
293            .unwrap()
294            .app_id("7c9e6679-7425-40de-944b-e07fc1f90ae7")
295            .unwrap()
296            .token("tk_test_token")
297            .secret("test_secret")
298            .batch_size(2000) // Exceeds MAX_BATCH_SIZE
299            .build();
300
301        assert!(result.is_err());
302    }
303}