Skip to main content

chasm_cli/
cloud_sync.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Cloud Sync Service Integration
4//!
5//! This module provides integration with cloud storage services for session backup
6//! and cross-device synchronization.
7
8use anyhow::{anyhow, Result};
9use serde::{Deserialize, Serialize};
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13// =============================================================================
14// Cloud Provider Types
15// =============================================================================
16
17/// Supported cloud storage providers
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum CloudProvider {
21    /// Local file system (no cloud)
22    Local,
23    /// Amazon S3 compatible storage
24    S3,
25    /// Azure Blob Storage
26    AzureBlob,
27    /// Google Cloud Storage
28    Gcs,
29    /// Dropbox
30    Dropbox,
31    /// iCloud Drive
32    ICloud,
33    /// OneDrive
34    OneDrive,
35    /// Self-hosted WebDAV
36    WebDav,
37}
38
39impl CloudProvider {
40    /// Get display name
41    pub fn display_name(&self) -> &'static str {
42        match self {
43            Self::Local => "Local Storage",
44            Self::S3 => "Amazon S3",
45            Self::AzureBlob => "Azure Blob Storage",
46            Self::Gcs => "Google Cloud Storage",
47            Self::Dropbox => "Dropbox",
48            Self::ICloud => "iCloud Drive",
49            Self::OneDrive => "OneDrive",
50            Self::WebDav => "WebDAV",
51        }
52    }
53}
54
55// =============================================================================
56// Cloud Sync Configuration
57// =============================================================================
58
59/// Cloud sync configuration
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct CloudSyncConfig {
62    /// Whether cloud sync is enabled
63    pub enabled: bool,
64    /// Cloud provider
65    pub provider: CloudProvider,
66    /// Provider-specific configuration
67    pub provider_config: ProviderSpecificConfig,
68    /// Sync frequency in seconds (0 = manual only)
69    pub sync_frequency_seconds: u64,
70    /// Whether to sync automatically on session save
71    pub auto_sync: bool,
72    /// Whether to encrypt data before uploading
73    pub encrypt_before_upload: bool,
74    /// Conflict resolution strategy
75    pub conflict_resolution: ConflictResolution,
76}
77
78impl Default for CloudSyncConfig {
79    fn default() -> Self {
80        Self {
81            enabled: false,
82            provider: CloudProvider::Local,
83            provider_config: ProviderSpecificConfig::Local(LocalConfig::default()),
84            sync_frequency_seconds: 300, // 5 minutes
85            auto_sync: true,
86            encrypt_before_upload: true,
87            conflict_resolution: ConflictResolution::LastWriteWins,
88        }
89    }
90}
91
92/// Conflict resolution strategies
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum ConflictResolution {
96    /// Last write wins (by timestamp)
97    LastWriteWins,
98    /// Local version wins
99    LocalWins,
100    /// Remote version wins
101    RemoteWins,
102    /// Keep both versions
103    KeepBoth,
104    /// Manual resolution required
105    Manual,
106}
107
108/// Provider-specific configuration
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(tag = "type", rename_all = "lowercase")]
111pub enum ProviderSpecificConfig {
112    Local(LocalConfig),
113    S3(S3Config),
114    AzureBlob(AzureBlobConfig),
115    Gcs(GcsConfig),
116    Dropbox(DropboxConfig),
117    ICloud(ICloudConfig),
118    OneDrive(OneDriveConfig),
119    WebDav(WebDavConfig),
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
123pub struct LocalConfig {
124    pub sync_directory: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct S3Config {
129    pub bucket: String,
130    pub region: String,
131    pub prefix: Option<String>,
132    pub access_key_id: Option<String>,
133    pub secret_access_key: Option<String>,
134    pub endpoint: Option<String>, // For S3-compatible services
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct AzureBlobConfig {
139    pub container: String,
140    pub connection_string: Option<String>,
141    pub account_name: Option<String>,
142    pub account_key: Option<String>,
143    pub prefix: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct GcsConfig {
148    pub bucket: String,
149    pub project_id: String,
150    pub prefix: Option<String>,
151    pub credentials_file: Option<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct DropboxConfig {
156    pub access_token: Option<String>,
157    pub refresh_token: Option<String>,
158    pub folder_path: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ICloudConfig {
163    pub container_id: Option<String>,
164    pub folder_path: Option<String>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct OneDriveConfig {
169    pub access_token: Option<String>,
170    pub refresh_token: Option<String>,
171    pub folder_path: Option<String>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct WebDavConfig {
176    pub url: String,
177    pub username: Option<String>,
178    pub password: Option<String>,
179    pub folder_path: Option<String>,
180}
181
182// =============================================================================
183// Sync State Tracking
184// =============================================================================
185
186/// Sync state for a single session
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SessionSyncState {
189    /// Session ID
190    pub session_id: String,
191    /// Local modification timestamp
192    pub local_modified: i64,
193    /// Remote modification timestamp (if known)
194    pub remote_modified: Option<i64>,
195    /// Local content hash
196    pub local_hash: String,
197    /// Remote content hash (if known)
198    pub remote_hash: Option<String>,
199    /// Sync status
200    pub status: SyncStatus,
201    /// Last sync attempt timestamp
202    pub last_sync_attempt: Option<i64>,
203    /// Last successful sync timestamp
204    pub last_sync_success: Option<i64>,
205    /// Error message from last failed sync
206    pub last_error: Option<String>,
207}
208
209/// Sync status for a session
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum SyncStatus {
213    /// In sync with remote
214    Synced,
215    /// Local changes pending upload
216    PendingUpload,
217    /// Remote changes pending download
218    PendingDownload,
219    /// Conflict detected
220    Conflict,
221    /// Currently syncing
222    Syncing,
223    /// Sync error
224    Error,
225    /// Never synced
226    NeverSynced,
227}
228
229/// Overall sync state
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct SyncState {
232    /// Last full sync timestamp
233    pub last_full_sync: Option<i64>,
234    /// Per-session sync states
235    pub sessions: Vec<SessionSyncState>,
236    /// Pending operations count
237    pub pending_uploads: u32,
238    pub pending_downloads: u32,
239    pub conflicts: u32,
240}
241
242impl SyncState {
243    pub fn new() -> Self {
244        Self {
245            last_full_sync: None,
246            sessions: Vec::new(),
247            pending_uploads: 0,
248            pending_downloads: 0,
249            conflicts: 0,
250        }
251    }
252}
253
254impl Default for SyncState {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260// =============================================================================
261// Cloud Sync Service
262// =============================================================================
263
264/// Cloud sync service trait
265#[async_trait::async_trait]
266pub trait CloudSyncService: Send + Sync {
267    /// Get provider type
268    fn provider(&self) -> CloudProvider;
269
270    /// Test connection
271    async fn test_connection(&self) -> Result<bool>;
272
273    /// List remote sessions
274    async fn list_remote_sessions(&self) -> Result<Vec<RemoteSessionInfo>>;
275
276    /// Upload a session
277    async fn upload_session(&self, session_id: &str, data: &[u8]) -> Result<UploadResult>;
278
279    /// Download a session
280    async fn download_session(&self, session_id: &str) -> Result<Vec<u8>>;
281
282    /// Delete a remote session
283    async fn delete_remote_session(&self, session_id: &str) -> Result<()>;
284
285    /// Get remote session metadata
286    async fn get_remote_metadata(&self, session_id: &str) -> Result<Option<RemoteSessionInfo>>;
287}
288
289/// Remote session information
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct RemoteSessionInfo {
292    pub session_id: String,
293    pub modified_at: i64,
294    pub size_bytes: u64,
295    pub content_hash: String,
296    pub metadata: Option<serde_json::Value>,
297}
298
299/// Upload result
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct UploadResult {
302    pub success: bool,
303    pub remote_path: String,
304    pub content_hash: String,
305    pub uploaded_at: i64,
306}
307
308// =============================================================================
309// Local Sync Implementation (File-based)
310// =============================================================================
311
312/// Local file-based sync (for network drives, etc.)
313pub struct LocalSyncService {
314    sync_dir: PathBuf,
315}
316
317impl LocalSyncService {
318    pub fn new(sync_dir: PathBuf) -> Self {
319        Self { sync_dir }
320    }
321
322    fn session_path(&self, session_id: &str) -> PathBuf {
323        self.sync_dir.join(format!("{}.json", session_id))
324    }
325}
326
327#[async_trait::async_trait]
328impl CloudSyncService for LocalSyncService {
329    fn provider(&self) -> CloudProvider {
330        CloudProvider::Local
331    }
332
333    async fn test_connection(&self) -> Result<bool> {
334        Ok(self.sync_dir.exists() || std::fs::create_dir_all(&self.sync_dir).is_ok())
335    }
336
337    async fn list_remote_sessions(&self) -> Result<Vec<RemoteSessionInfo>> {
338        let mut sessions = Vec::new();
339
340        if !self.sync_dir.exists() {
341            return Ok(sessions);
342        }
343
344        for entry in std::fs::read_dir(&self.sync_dir)? {
345            let entry = entry?;
346            let path = entry.path();
347
348            if path.extension().and_then(|s| s.to_str()) == Some("json") {
349                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
350                    let metadata = entry.metadata()?;
351                    let modified = metadata
352                        .modified()?
353                        .duration_since(UNIX_EPOCH)
354                        .unwrap_or_default()
355                        .as_secs() as i64;
356
357                    // Simple hash based on size and modified time
358                    let hash = format!("{}-{}", metadata.len(), modified);
359
360                    sessions.push(RemoteSessionInfo {
361                        session_id: stem.to_string(),
362                        modified_at: modified,
363                        size_bytes: metadata.len(),
364                        content_hash: hash,
365                        metadata: None,
366                    });
367                }
368            }
369        }
370
371        Ok(sessions)
372    }
373
374    async fn upload_session(&self, session_id: &str, data: &[u8]) -> Result<UploadResult> {
375        std::fs::create_dir_all(&self.sync_dir)?;
376
377        let path = self.session_path(session_id);
378        std::fs::write(&path, data)?;
379
380        let now = SystemTime::now()
381            .duration_since(UNIX_EPOCH)
382            .unwrap_or_default()
383            .as_secs() as i64;
384
385        // Simple hash
386        let hash = format!("{}-{}", data.len(), now);
387
388        Ok(UploadResult {
389            success: true,
390            remote_path: path.to_string_lossy().to_string(),
391            content_hash: hash,
392            uploaded_at: now,
393        })
394    }
395
396    async fn download_session(&self, session_id: &str) -> Result<Vec<u8>> {
397        let path = self.session_path(session_id);
398        std::fs::read(&path).map_err(|e| anyhow!("Failed to read session: {}", e))
399    }
400
401    async fn delete_remote_session(&self, session_id: &str) -> Result<()> {
402        let path = self.session_path(session_id);
403        if path.exists() {
404            std::fs::remove_file(&path)?;
405        }
406        Ok(())
407    }
408
409    async fn get_remote_metadata(&self, session_id: &str) -> Result<Option<RemoteSessionInfo>> {
410        let path = self.session_path(session_id);
411
412        if !path.exists() {
413            return Ok(None);
414        }
415
416        let metadata = std::fs::metadata(&path)?;
417        let modified = metadata
418            .modified()?
419            .duration_since(UNIX_EPOCH)
420            .unwrap_or_default()
421            .as_secs() as i64;
422
423        let hash = format!("{}-{}", metadata.len(), modified);
424
425        Ok(Some(RemoteSessionInfo {
426            session_id: session_id.to_string(),
427            modified_at: modified,
428            size_bytes: metadata.len(),
429            content_hash: hash,
430            metadata: None,
431        }))
432    }
433}
434
435// =============================================================================
436// Sync Manager
437// =============================================================================
438
439/// Main sync manager that coordinates synchronization
440pub struct SyncManager {
441    config: CloudSyncConfig,
442    state: SyncState,
443    service: Option<Box<dyn CloudSyncService>>,
444}
445
446impl SyncManager {
447    pub fn new(config: CloudSyncConfig) -> Self {
448        Self {
449            config,
450            state: SyncState::new(),
451            service: None,
452        }
453    }
454
455    /// Initialize the sync service based on configuration
456    pub fn initialize(&mut self) -> Result<()> {
457        if !self.config.enabled {
458            return Ok(());
459        }
460
461        match &self.config.provider_config {
462            ProviderSpecificConfig::Local(local_config) => {
463                let sync_dir = local_config
464                    .sync_directory
465                    .as_ref()
466                    .map(PathBuf::from)
467                    .unwrap_or_else(|| {
468                        dirs::data_local_dir()
469                            .unwrap_or_else(|| PathBuf::from("."))
470                            .join("csm")
471                            .join("sync")
472                    });
473                self.service = Some(Box::new(LocalSyncService::new(sync_dir)));
474            }
475            _ => {
476                return Err(anyhow!(
477                    "Cloud provider {:?} not yet implemented",
478                    self.config.provider
479                ));
480            }
481        }
482
483        Ok(())
484    }
485
486    /// Test connection to cloud service
487    pub async fn test_connection(&self) -> Result<bool> {
488        match &self.service {
489            Some(service) => service.test_connection().await,
490            None => Err(anyhow!("Sync service not initialized")),
491        }
492    }
493
494    /// Get current sync state
495    pub fn get_state(&self) -> &SyncState {
496        &self.state
497    }
498
499    /// Sync all sessions
500    pub async fn sync_all(&mut self) -> Result<SyncResult> {
501        let service = self
502            .service
503            .as_ref()
504            .ok_or_else(|| anyhow!("Sync service not initialized"))?;
505
506        let mut result = SyncResult {
507            uploaded: 0,
508            downloaded: 0,
509            conflicts: 0,
510            errors: Vec::new(),
511        };
512
513        // Get remote sessions
514        let remote_sessions = service.list_remote_sessions().await?;
515
516        // Update state
517        self.state.last_full_sync = Some(
518            SystemTime::now()
519                .duration_since(UNIX_EPOCH)
520                .unwrap_or_default()
521                .as_secs() as i64,
522        );
523
524        // TODO: Compare local and remote, perform sync operations
525
526        Ok(result)
527    }
528
529    /// Upload a specific session
530    pub async fn upload_session(&mut self, session_id: &str, data: &[u8]) -> Result<UploadResult> {
531        let service = self
532            .service
533            .as_ref()
534            .ok_or_else(|| anyhow!("Sync service not initialized"))?;
535
536        service.upload_session(session_id, data).await
537    }
538
539    /// Download a specific session
540    pub async fn download_session(&self, session_id: &str) -> Result<Vec<u8>> {
541        let service = self
542            .service
543            .as_ref()
544            .ok_or_else(|| anyhow!("Sync service not initialized"))?;
545
546        service.download_session(session_id).await
547    }
548}
549
550/// Result of a sync operation
551#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct SyncResult {
553    pub uploaded: u32,
554    pub downloaded: u32,
555    pub conflicts: u32,
556    pub errors: Vec<String>,
557}
558
559// =============================================================================
560// Tests
561// =============================================================================
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use tempfile::tempdir;
567
568    #[tokio::test]
569    async fn test_local_sync_service() {
570        let temp_dir = tempdir().unwrap();
571        let sync_dir = temp_dir.path().join("sync");
572
573        let service = LocalSyncService::new(sync_dir.clone());
574
575        // Test connection
576        assert!(service.test_connection().await.unwrap());
577
578        // Test upload
579        let data = b"test session data";
580        let result = service.upload_session("test-session", data).await.unwrap();
581        assert!(result.success);
582
583        // Test list
584        let sessions = service.list_remote_sessions().await.unwrap();
585        assert_eq!(sessions.len(), 1);
586        assert_eq!(sessions[0].session_id, "test-session");
587
588        // Test download
589        let downloaded = service.download_session("test-session").await.unwrap();
590        assert_eq!(downloaded, data);
591
592        // Test delete
593        service.delete_remote_session("test-session").await.unwrap();
594        let sessions = service.list_remote_sessions().await.unwrap();
595        assert!(sessions.is_empty());
596    }
597}