use chrono::{DateTime, Utc};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use super::{CloudError, DEFAULT_CLOUD_URL};
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub struct CloudClient {
client: Client,
base_url: String,
api_key: Option<String>,
}
impl CloudClient {
pub fn new() -> Self {
Self {
client: Self::build_client(),
base_url: DEFAULT_CLOUD_URL.to_string(),
api_key: None,
}
}
pub fn with_url(base_url: &str) -> Self {
Self {
client: Self::build_client(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: None,
}
}
fn build_client() -> Client {
Client::builder()
.connect_timeout(CONNECT_TIMEOUT)
.timeout(REQUEST_TIMEOUT)
.build()
.expect("Failed to build HTTP client")
}
pub fn with_api_key(mut self, api_key: &str) -> Self {
self.api_key = Some(api_key.to_string());
self
}
#[allow(dead_code)]
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn status(&self) -> Result<SyncStatus, CloudError> {
let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
let url = format!("{}/api/sync/status", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(CloudError::ServerError { status, message });
}
let body: ApiResponse<SyncStatus> = response.json()?;
Ok(body.data)
}
pub fn push(&self, sessions: Vec<PushSession>) -> Result<PushResponse, CloudError> {
let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
let url = format!("{}/api/sync/push", self.base_url);
let payload = PushRequest { sessions };
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&payload)
.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(CloudError::ServerError { status, message });
}
let body: ApiResponse<PushResponse> = response.json()?;
Ok(body.data)
}
pub fn pull(&self, since: Option<DateTime<Utc>>) -> Result<PullResponse, CloudError> {
let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
let mut url = format!("{}/api/sync/pull", self.base_url);
if let Some(since) = since {
url = format!("{}?since={}", url, since.to_rfc3339());
}
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(CloudError::ServerError { status, message });
}
let body: ApiResponse<PullResponse> = response.json()?;
Ok(body.data)
}
pub fn get_salt(&self) -> Result<Option<String>, CloudError> {
let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
let url = format!("{}/api/sync/salt", self.base_url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {api_key}"))
.send()?;
let status = response.status();
if status.as_u16() == 404 {
return Ok(None);
}
if !status.is_success() {
let message = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(CloudError::ServerError {
status: status.as_u16(),
message,
});
}
let body: ApiResponse<SaltResponse> = response.json()?;
Ok(body.data.salt)
}
pub fn set_salt(&self, salt: &str) -> Result<(), CloudError> {
let api_key = self.api_key.as_ref().ok_or(CloudError::NotLoggedIn)?;
let url = format!("{}/api/sync/salt", self.base_url);
let response = self
.client
.put(&url)
.header("Authorization", format!("Bearer {api_key}"))
.json(&SaltRequest {
salt: salt.to_string(),
})
.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(CloudError::ServerError { status, message });
}
Ok(())
}
}
impl Default for CloudClient {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize)]
pub struct ApiResponse<T> {
pub data: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncStatus {
pub session_count: i64,
pub last_sync_at: Option<DateTime<Utc>>,
pub storage_used_bytes: i64,
}
#[derive(Debug, Serialize)]
pub struct PushRequest {
pub sessions: Vec<PushSession>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushSession {
pub id: String,
pub machine_id: String,
pub encrypted_data: String,
pub metadata: SessionMetadata,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionMetadata {
pub tool_name: String,
pub project_path: String,
pub started_at: DateTime<Utc>,
pub ended_at: Option<DateTime<Utc>>,
pub message_count: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PushResponse {
pub synced_count: i64,
pub server_time: DateTime<Utc>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SaltResponse {
pub salt: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SaltRequest {
pub salt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PullResponse {
pub sessions: Vec<PullSession>,
pub server_time: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PullSession {
pub id: String,
pub machine_id: String,
pub encrypted_data: String,
pub metadata: SessionMetadata,
pub updated_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cloud_client_new() {
let client = CloudClient::new();
assert_eq!(client.base_url(), DEFAULT_CLOUD_URL);
}
#[test]
fn test_cloud_client_with_url() {
let client = CloudClient::with_url("https://custom.example.com/");
assert_eq!(client.base_url(), "https://custom.example.com");
}
#[test]
fn test_cloud_client_with_url_no_trailing_slash() {
let client = CloudClient::with_url("https://custom.example.com");
assert_eq!(client.base_url(), "https://custom.example.com");
}
#[test]
fn test_cloud_client_with_api_key() {
let client = CloudClient::new().with_api_key("test_key");
assert_eq!(client.api_key, Some("test_key".to_string()));
}
#[test]
fn test_sync_status_deserialize() {
let json = r#"{
"sessionCount": 42,
"lastSyncAt": "2024-01-01T00:00:00Z",
"storageUsedBytes": 1234567
}"#;
let status: SyncStatus = serde_json::from_str(json).unwrap();
assert_eq!(status.session_count, 42);
assert!(status.last_sync_at.is_some());
assert_eq!(status.storage_used_bytes, 1234567);
}
#[test]
fn test_sync_status_deserialize_null_last_sync() {
let json = r#"{
"sessionCount": 0,
"lastSyncAt": null,
"storageUsedBytes": 0
}"#;
let status: SyncStatus = serde_json::from_str(json).unwrap();
assert_eq!(status.session_count, 0);
assert!(status.last_sync_at.is_none());
}
#[test]
fn test_push_session_serialize() {
let session = PushSession {
id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
machine_id: "machine-uuid".to_string(),
encrypted_data: "base64encodeddata".to_string(),
metadata: SessionMetadata {
tool_name: "claude-code".to_string(),
project_path: "/path/to/project".to_string(),
started_at: Utc::now(),
ended_at: None,
message_count: 10,
},
updated_at: Utc::now(),
};
let json = serde_json::to_string(&session).unwrap();
assert!(json.contains("encryptedData"));
assert!(json.contains("toolName"));
assert!(json.contains("projectPath"));
}
#[test]
fn test_session_metadata_serialize() {
let metadata = SessionMetadata {
tool_name: "aider".to_string(),
project_path: "/home/user/project".to_string(),
started_at: DateTime::parse_from_rfc3339("2024-01-01T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
ended_at: Some(
DateTime::parse_from_rfc3339("2024-01-01T13:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
message_count: 25,
};
let json = serde_json::to_string(&metadata).unwrap();
assert!(json.contains("\"toolName\":\"aider\""));
assert!(json.contains("\"messageCount\":25"));
}
#[test]
fn test_api_response_deserialize() {
let json = r#"{
"data": {
"sessionCount": 5,
"lastSyncAt": null,
"storageUsedBytes": 1000
}
}"#;
let response: ApiResponse<SyncStatus> = serde_json::from_str(json).unwrap();
assert_eq!(response.data.session_count, 5);
}
#[test]
fn test_cloud_client_uses_timeouts() {
assert_eq!(CONNECT_TIMEOUT.as_secs(), 30);
assert_eq!(REQUEST_TIMEOUT.as_secs(), 60);
let client = CloudClient::new();
assert_eq!(client.base_url(), DEFAULT_CLOUD_URL);
let client = CloudClient::with_url("https://example.com");
assert_eq!(client.base_url(), "https://example.com");
}
}