1use chrono::{DateTime, Utc};
7use reqwest::blocking::Client;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11use super::{CloudError, DEFAULT_CLOUD_URL};
12
13const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
18
19pub struct CloudClient {
21 client: Client,
23 base_url: String,
25 api_key: Option<String>,
27}
28
29impl CloudClient {
30 pub fn new() -> Self {
32 Self {
33 client: Self::build_client(),
34 base_url: DEFAULT_CLOUD_URL.to_string(),
35 api_key: None,
36 }
37 }
38
39 pub fn with_url(base_url: &str) -> Self {
41 Self {
42 client: Self::build_client(),
43 base_url: base_url.trim_end_matches('/').to_string(),
44 api_key: None,
45 }
46 }
47
48 fn build_client() -> Client {
50 Client::builder()
51 .connect_timeout(CONNECT_TIMEOUT)
52 .timeout(REQUEST_TIMEOUT)
53 .build()
54 .expect("Failed to build HTTP client")
55 }
56
57 pub fn with_api_key(mut self, api_key: &str) -> Self {
59 self.api_key = Some(api_key.to_string());
60 self
61 }
62
63 #[allow(dead_code)]
65 pub fn base_url(&self) -> &str {
66 &self.base_url
67 }
68
69 pub fn status(&self) -> Result<SyncStatus, CloudError> {
74 let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
75
76 let url = format!("{}/api/sync/status", self.base_url);
77 let response = self
78 .client
79 .get(&url)
80 .header("Authorization", format!("Bearer {api_key}"))
81 .send()?;
82
83 if !response.status().is_success() {
84 let status = response.status().as_u16();
85 let message = response
86 .text()
87 .unwrap_or_else(|_| "Unknown error".to_string());
88 return Err(CloudError::ServerError { status, message });
89 }
90
91 let body: ApiResponse<SyncStatus> = response.json()?;
92 Ok(body.data)
93 }
94
95 pub fn push(&self, sessions: Vec<PushSession>) -> Result<PushResponse, CloudError> {
100 let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
101
102 let url = format!("{}/api/sync/push", self.base_url);
103 let payload = PushRequest { sessions };
104
105 let response = self
106 .client
107 .post(&url)
108 .header("Authorization", format!("Bearer {api_key}"))
109 .json(&payload)
110 .send()?;
111
112 if !response.status().is_success() {
113 let status = response.status().as_u16();
114 let message = response
115 .text()
116 .unwrap_or_else(|_| "Unknown error".to_string());
117 return Err(CloudError::ServerError { status, message });
118 }
119
120 let body: ApiResponse<PushResponse> = response.json()?;
121 Ok(body.data)
122 }
123
124 pub fn pull(&self, since: Option<DateTime<Utc>>) -> Result<PullResponse, CloudError> {
129 let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
130
131 let mut url = format!("{}/api/sync/pull", self.base_url);
132 if let Some(since) = since {
133 url = format!("{}?since={}", url, since.to_rfc3339());
134 }
135
136 let response = self
137 .client
138 .get(&url)
139 .header("Authorization", format!("Bearer {api_key}"))
140 .send()?;
141
142 if !response.status().is_success() {
143 let status = response.status().as_u16();
144 let message = response
145 .text()
146 .unwrap_or_else(|_| "Unknown error".to_string());
147 return Err(CloudError::ServerError { status, message });
148 }
149
150 let body: ApiResponse<PullResponse> = response.json()?;
151 Ok(body.data)
152 }
153
154 pub fn get_salt(&self) -> Result<Option<String>, CloudError> {
159 let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
160
161 let url = format!("{}/api/sync/salt", self.base_url);
162 let response = self
163 .client
164 .get(&url)
165 .header("Authorization", format!("Bearer {api_key}"))
166 .send()?;
167
168 let status = response.status();
169 if status.as_u16() == 404 {
170 return Ok(None);
171 }
172
173 if !status.is_success() {
174 let message = response
175 .text()
176 .unwrap_or_else(|_| "Unknown error".to_string());
177 return Err(CloudError::ServerError {
178 status: status.as_u16(),
179 message,
180 });
181 }
182
183 let body: ApiResponse<SaltResponse> = response.json()?;
184 Ok(body.data.salt)
185 }
186
187 pub fn set_salt(&self, salt: &str) -> Result<(), CloudError> {
192 let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
193
194 let url = format!("{}/api/sync/salt", self.base_url);
195 let response = self
196 .client
197 .put(&url)
198 .header("Authorization", format!("Bearer {api_key}"))
199 .json(&SaltRequest {
200 salt: salt.to_string(),
201 })
202 .send()?;
203
204 if !response.status().is_success() {
205 let status = response.status().as_u16();
206 let message = response
207 .text()
208 .unwrap_or_else(|_| "Unknown error".to_string());
209 return Err(CloudError::ServerError { status, message });
210 }
211
212 Ok(())
213 }
214}
215
216impl Default for CloudClient {
217 fn default() -> Self {
218 Self::new()
219 }
220}
221
222#[derive(Debug, Deserialize)]
226pub struct ApiResponse<T> {
227 pub data: T,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SyncStatus {
235 pub session_count: i64,
237
238 pub last_sync_at: Option<DateTime<Utc>>,
240
241 pub storage_used_bytes: i64,
243}
244
245#[derive(Debug, Serialize)]
247pub struct PushRequest {
248 pub sessions: Vec<PushSession>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct PushSession {
256 pub id: String,
258
259 pub machine_id: String,
261
262 pub encrypted_data: String,
264
265 pub metadata: SessionMetadata,
267
268 pub updated_at: DateTime<Utc>,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct SessionMetadata {
276 pub tool_name: String,
278
279 pub project_path: String,
281
282 pub started_at: DateTime<Utc>,
284
285 pub ended_at: Option<DateTime<Utc>>,
287
288 pub message_count: i32,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct PushResponse {
296 pub synced_count: i64,
298
299 pub server_time: DateTime<Utc>,
301}
302
303#[derive(Debug, Clone, Deserialize)]
305pub struct SaltResponse {
306 pub salt: Option<String>,
308}
309
310#[derive(Debug, Clone, Serialize)]
312pub struct SaltRequest {
313 pub salt: String,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct PullResponse {
321 pub sessions: Vec<PullSession>,
323
324 pub server_time: DateTime<Utc>,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct PullSession {
332 pub id: String,
334
335 pub machine_id: String,
337
338 pub encrypted_data: String,
340
341 pub metadata: SessionMetadata,
343
344 pub updated_at: DateTime<Utc>,
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351
352 #[test]
353 fn test_cloud_client_new() {
354 let client = CloudClient::new();
355 assert_eq!(client.base_url(), DEFAULT_CLOUD_URL);
356 }
357
358 #[test]
359 fn test_cloud_client_with_url() {
360 let client = CloudClient::with_url("https://custom.example.com/");
361 assert_eq!(client.base_url(), "https://custom.example.com");
362 }
363
364 #[test]
365 fn test_cloud_client_with_url_no_trailing_slash() {
366 let client = CloudClient::with_url("https://custom.example.com");
367 assert_eq!(client.base_url(), "https://custom.example.com");
368 }
369
370 #[test]
371 fn test_cloud_client_with_api_key() {
372 let client = CloudClient::new().with_api_key("test_key");
373 assert_eq!(client.api_key, Some("test_key".to_string()));
374 }
375
376 #[test]
377 fn test_sync_status_deserialize() {
378 let json = r#"{
379 "sessionCount": 42,
380 "lastSyncAt": "2024-01-01T00:00:00Z",
381 "storageUsedBytes": 1234567
382 }"#;
383
384 let status: SyncStatus = serde_json::from_str(json).unwrap();
385 assert_eq!(status.session_count, 42);
386 assert!(status.last_sync_at.is_some());
387 assert_eq!(status.storage_used_bytes, 1234567);
388 }
389
390 #[test]
391 fn test_sync_status_deserialize_null_last_sync() {
392 let json = r#"{
393 "sessionCount": 0,
394 "lastSyncAt": null,
395 "storageUsedBytes": 0
396 }"#;
397
398 let status: SyncStatus = serde_json::from_str(json).unwrap();
399 assert_eq!(status.session_count, 0);
400 assert!(status.last_sync_at.is_none());
401 }
402
403 #[test]
404 fn test_push_session_serialize() {
405 let session = PushSession {
406 id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
407 machine_id: "machine-uuid".to_string(),
408 encrypted_data: "base64encodeddata".to_string(),
409 metadata: SessionMetadata {
410 tool_name: "claude-code".to_string(),
411 project_path: "/path/to/project".to_string(),
412 started_at: Utc::now(),
413 ended_at: None,
414 message_count: 10,
415 },
416 updated_at: Utc::now(),
417 };
418
419 let json = serde_json::to_string(&session).unwrap();
420 assert!(json.contains("encryptedData"));
421 assert!(json.contains("toolName"));
422 assert!(json.contains("projectPath"));
423 }
424
425 #[test]
426 fn test_session_metadata_serialize() {
427 let metadata = SessionMetadata {
428 tool_name: "aider".to_string(),
429 project_path: "/home/user/project".to_string(),
430 started_at: DateTime::parse_from_rfc3339("2024-01-01T12:00:00Z")
431 .unwrap()
432 .with_timezone(&Utc),
433 ended_at: Some(
434 DateTime::parse_from_rfc3339("2024-01-01T13:00:00Z")
435 .unwrap()
436 .with_timezone(&Utc),
437 ),
438 message_count: 25,
439 };
440
441 let json = serde_json::to_string(&metadata).unwrap();
442 assert!(json.contains("\"toolName\":\"aider\""));
443 assert!(json.contains("\"messageCount\":25"));
444 }
445
446 #[test]
447 fn test_api_response_deserialize() {
448 let json = r#"{
449 "data": {
450 "sessionCount": 5,
451 "lastSyncAt": null,
452 "storageUsedBytes": 1000
453 }
454 }"#;
455
456 let response: ApiResponse<SyncStatus> = serde_json::from_str(json).unwrap();
457 assert_eq!(response.data.session_count, 5);
458 }
459
460 #[test]
461 fn test_cloud_client_uses_timeouts() {
462 assert_eq!(CONNECT_TIMEOUT.as_secs(), 30);
463 assert_eq!(REQUEST_TIMEOUT.as_secs(), 60);
464
465 let client = CloudClient::new();
466 assert_eq!(client.base_url(), DEFAULT_CLOUD_URL);
467
468 let client = CloudClient::with_url("https://example.com");
469 assert_eq!(client.base_url(), "https://example.com");
470 }
471}