Skip to main content

lore_cli/cloud/
client.rs

1//! HTTP client for cloud API communication.
2//!
3//! Provides the `CloudClient` for interacting with the Lore cloud service,
4//! including sync operations (push/pull) and status queries.
5
6use chrono::{DateTime, Utc};
7use reqwest::blocking::Client;
8use serde::{Deserialize, Serialize};
9use std::time::Duration;
10
11use super::{CloudError, DEFAULT_CLOUD_URL};
12
13/// Timeout for establishing a connection (30 seconds).
14const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
15
16/// Timeout for the entire request including response (60 seconds).
17const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
18
19/// Cloud API client for sync operations.
20pub struct CloudClient {
21    /// HTTP client instance.
22    client: Client,
23    /// Base URL of the cloud service.
24    base_url: String,
25    /// API key for authentication (if logged in).
26    api_key: Option<String>,
27}
28
29impl CloudClient {
30    /// Creates a new cloud client with the default URL.
31    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    /// Creates a new cloud client with a custom URL.
40    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    /// Builds the HTTP client with configured timeouts.
49    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    /// Sets the API key for authentication.
58    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    /// Returns the configured base URL.
64    #[allow(dead_code)]
65    pub fn base_url(&self) -> &str {
66        &self.base_url
67    }
68
69    /// Gets the sync status from the cloud service.
70    ///
71    /// Returns information about the user's sync state including session count,
72    /// storage usage, and last sync time.
73    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    /// Pushes sessions to the cloud service.
96    ///
97    /// Uploads encrypted session data to the cloud. Session metadata is stored
98    /// unencrypted for display purposes, while message content is encrypted.
99    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    /// Pulls sessions from the cloud service.
125    ///
126    /// Downloads sessions that have been modified since the given timestamp.
127    /// Session message content is encrypted and must be decrypted by the caller.
128    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    /// Gets the encryption salt from the cloud service.
155    ///
156    /// Returns the base64-encoded salt if set, or None if not yet configured.
157    /// Returns Ok(None) for 404 responses (salt not set), Err for other failures.
158    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    /// Sets the encryption salt on the cloud service.
188    ///
189    /// This should only be called once during initial setup. The server will
190    /// reject attempts to overwrite an existing salt.
191    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// ==================== API Types ====================
223
224/// Generic API response wrapper.
225#[derive(Debug, Deserialize)]
226pub struct ApiResponse<T> {
227    /// The response data.
228    pub data: T,
229}
230
231/// Sync status response from the cloud service.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct SyncStatus {
235    /// Number of sessions stored in the cloud.
236    pub session_count: i64,
237
238    /// Timestamp of the last sync operation.
239    pub last_sync_at: Option<DateTime<Utc>>,
240
241    /// Storage used in bytes.
242    pub storage_used_bytes: i64,
243}
244
245/// Request payload for pushing sessions.
246#[derive(Debug, Serialize)]
247pub struct PushRequest {
248    /// Sessions to push.
249    pub sessions: Vec<PushSession>,
250}
251
252/// A session prepared for pushing to the cloud.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct PushSession {
256    /// Session UUID.
257    pub id: String,
258
259    /// Machine UUID that created this session.
260    pub machine_id: String,
261
262    /// Base64-encoded encrypted message data.
263    pub encrypted_data: String,
264
265    /// Unencrypted session metadata.
266    pub metadata: SessionMetadata,
267
268    /// When this session was last updated locally.
269    pub updated_at: DateTime<Utc>,
270}
271
272/// Unencrypted session metadata for cloud display.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct SessionMetadata {
276    /// Tool that created this session (e.g., "claude-code").
277    pub tool_name: String,
278
279    /// Working directory path.
280    pub project_path: String,
281
282    /// When the session started.
283    pub started_at: DateTime<Utc>,
284
285    /// When the session ended (if completed).
286    pub ended_at: Option<DateTime<Utc>>,
287
288    /// Number of messages in the session.
289    pub message_count: i32,
290}
291
292/// Response from pushing sessions.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(rename_all = "camelCase")]
295pub struct PushResponse {
296    /// Number of sessions successfully synced.
297    pub synced_count: i64,
298
299    /// Server timestamp for recording sync time.
300    pub server_time: DateTime<Utc>,
301}
302
303/// Response from getting the encryption salt.
304#[derive(Debug, Clone, Deserialize)]
305pub struct SaltResponse {
306    /// The base64-encoded encryption salt, or None if not set.
307    pub salt: Option<String>,
308}
309
310/// Request for setting the encryption salt.
311#[derive(Debug, Clone, Serialize)]
312pub struct SaltRequest {
313    /// The base64-encoded encryption salt.
314    pub salt: String,
315}
316
317/// Response from pulling sessions.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "camelCase")]
320pub struct PullResponse {
321    /// Sessions to import.
322    pub sessions: Vec<PullSession>,
323
324    /// Server timestamp for recording sync time.
325    pub server_time: DateTime<Utc>,
326}
327
328/// A session returned from the cloud for pulling.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[serde(rename_all = "camelCase")]
331pub struct PullSession {
332    /// Session UUID.
333    pub id: String,
334
335    /// Machine UUID that created this session.
336    pub machine_id: String,
337
338    /// Base64-encoded encrypted message data.
339    pub encrypted_data: String,
340
341    /// Unencrypted session metadata.
342    pub metadata: SessionMetadata,
343
344    /// When this session was last updated on the server.
345    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}