Skip to main content

mockforge_collab/
backup.rs

1//! Cloud backup and restore for workspaces
2
3use crate::core_bridge::CoreBridge;
4use crate::error::{CollabError, Result};
5use crate::history::VersionControl;
6use crate::workspace::WorkspaceService;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sqlx::{Pool, Sqlite};
10use std::path::Path;
11use std::sync::Arc;
12use uuid::Uuid;
13
14/// Storage backend type
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
16#[sqlx(type_name = "storage_backend", rename_all = "lowercase")]
17#[serde(rename_all = "lowercase")]
18pub enum StorageBackend {
19    /// Local filesystem
20    Local,
21    /// Amazon S3
22    S3,
23    /// Azure Blob Storage
24    Azure,
25    /// Google Cloud Storage
26    Gcs,
27    /// Custom storage backend
28    Custom,
29}
30
31/// Backup record
32#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
33pub struct WorkspaceBackup {
34    /// Unique backup ID
35    pub id: Uuid,
36    /// Workspace ID
37    pub workspace_id: Uuid,
38    /// Backup URL or path
39    pub backup_url: String,
40    /// Storage backend
41    pub storage_backend: StorageBackend,
42    /// Storage configuration (JSON)
43    pub storage_config: Option<serde_json::Value>,
44    /// Size in bytes
45    pub size_bytes: i64,
46    /// Backup format (yaml or json)
47    pub backup_format: String,
48    /// Whether backup is encrypted
49    pub encrypted: bool,
50    /// Commit ID this backup represents
51    pub commit_id: Option<Uuid>,
52    /// Created timestamp
53    pub created_at: DateTime<Utc>,
54    /// User who created the backup
55    pub created_by: Uuid,
56    /// Optional expiration date
57    pub expires_at: Option<DateTime<Utc>>,
58}
59
60impl WorkspaceBackup {
61    /// Create a new backup record
62    #[must_use]
63    pub fn new(
64        workspace_id: Uuid,
65        backup_url: String,
66        storage_backend: StorageBackend,
67        size_bytes: i64,
68        created_by: Uuid,
69    ) -> Self {
70        Self {
71            id: Uuid::new_v4(),
72            workspace_id,
73            backup_url,
74            storage_backend,
75            storage_config: None,
76            size_bytes,
77            backup_format: "yaml".to_string(),
78            encrypted: false,
79            commit_id: None,
80            created_at: Utc::now(),
81            created_by,
82            expires_at: None,
83        }
84    }
85}
86
87/// Backup service for managing workspace backups
88pub struct BackupService {
89    db: Pool<Sqlite>,
90    version_control: VersionControl,
91    local_backup_dir: Option<String>,
92    client: reqwest::Client,
93    core_bridge: Arc<CoreBridge>,
94    workspace_service: Arc<WorkspaceService>,
95}
96
97impl BackupService {
98    /// Create a new backup service
99    #[must_use]
100    pub fn new(
101        db: Pool<Sqlite>,
102        local_backup_dir: Option<String>,
103        core_bridge: Arc<CoreBridge>,
104        workspace_service: Arc<WorkspaceService>,
105    ) -> Self {
106        Self {
107            db: db.clone(),
108            version_control: VersionControl::new(db),
109            local_backup_dir,
110            client: reqwest::Client::new(),
111            core_bridge,
112            workspace_service,
113        }
114    }
115
116    /// Create a backup of a workspace
117    ///
118    /// Exports the workspace to the specified storage backend.
119    /// Supports local filesystem, Azure Blob Storage, and Google Cloud Storage.
120    /// For cloud storage, use `backup_workspace_with_config` to provide credentials.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if the workspace cannot be read or the storage backend fails.
125    #[allow(clippy::large_futures)]
126    pub async fn backup_workspace(
127        &self,
128        workspace_id: Uuid,
129        user_id: Uuid,
130        storage_backend: StorageBackend,
131        format: Option<String>,
132        commit_id: Option<Uuid>,
133    ) -> Result<WorkspaceBackup> {
134        self.backup_workspace_with_config(
135            workspace_id,
136            user_id,
137            storage_backend,
138            format,
139            commit_id,
140            None,
141        )
142        .await
143    }
144
145    /// Create a backup of a workspace with storage configuration
146    ///
147    /// Exports the workspace to the specified storage backend.
148    /// For Azure, `storage_config` should include:
149    /// - `account_name`: Azure storage account name (required)
150    /// - `container_name`: Container name (defaults to "mockforge-backups")
151    /// - `account_key` or `sas_token`: Credentials (optional, uses `DefaultAzureCredential` if not provided)
152    ///
153    /// For GCS, `storage_config` should include:
154    /// - `bucket_name`: GCS bucket name (defaults to "mockforge-backups")
155    ///
156    /// For local storage, `storage_config` is ignored.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the workspace cannot be read, serialization fails,
161    /// or the storage backend upload fails.
162    #[allow(clippy::large_futures)]
163    pub async fn backup_workspace_with_config(
164        &self,
165        workspace_id: Uuid,
166        user_id: Uuid,
167        storage_backend: StorageBackend,
168        format: Option<String>,
169        commit_id: Option<Uuid>,
170        storage_config: Option<serde_json::Value>,
171    ) -> Result<WorkspaceBackup> {
172        // Get workspace data using CoreBridge to get full workspace state
173        let workspace = self
174            .workspace_service
175            .get_workspace(workspace_id)
176            .await
177            .map_err(|e| CollabError::Internal(format!("Failed to get workspace: {e}")))?;
178
179        // Use CoreBridge to get full workspace state from mockforge-core
180        // This integrates with mockforge-core to get the complete workspace state
181        // including all mocks, folders, and configuration
182        let workspace_data = self
183            .core_bridge
184            .export_workspace_for_backup(&workspace)
185            .await
186            .map_err(|e| CollabError::Internal(format!("Failed to export workspace: {e}")))?;
187
188        // Serialize workspace data
189        let backup_format = format.unwrap_or_else(|| "yaml".to_string());
190        let serialized = match backup_format.as_str() {
191            "yaml" => serde_yaml::to_string(&workspace_data)
192                .map_err(|e| CollabError::Internal(format!("Failed to serialize to YAML: {e}")))?,
193            "json" => serde_json::to_string_pretty(&workspace_data)
194                .map_err(|e| CollabError::Internal(format!("Failed to serialize to JSON: {e}")))?,
195            _ => {
196                return Err(CollabError::InvalidInput(format!(
197                    "Unsupported backup format: {backup_format}"
198                )));
199            }
200        };
201
202        let size_bytes = i64::try_from(serialized.len()).unwrap_or(i64::MAX);
203
204        // Save to storage backend
205        let backup_url = match storage_backend {
206            StorageBackend::Local => {
207                self.save_to_local(workspace_id, &serialized, &backup_format).await?
208            }
209            StorageBackend::S3 => {
210                self.save_to_s3(workspace_id, &serialized, &backup_format, storage_config.as_ref())
211                    .await?
212            }
213            StorageBackend::Azure => {
214                self.save_to_azure(
215                    workspace_id,
216                    &serialized,
217                    &backup_format,
218                    storage_config.as_ref(),
219                )
220                .await?
221            }
222            StorageBackend::Gcs => {
223                self.save_to_gcs(workspace_id, &serialized, &backup_format, storage_config.as_ref())
224                    .await?
225            }
226            StorageBackend::Custom => {
227                self.save_to_custom(
228                    workspace_id,
229                    &serialized,
230                    &backup_format,
231                    storage_config.as_ref(),
232                )
233                .await?
234            }
235        };
236
237        // Create backup record
238        let mut backup =
239            WorkspaceBackup::new(workspace_id, backup_url, storage_backend, size_bytes, user_id);
240        backup.backup_format = backup_format;
241        backup.storage_config = storage_config;
242        backup.commit_id = commit_id;
243
244        // Use lowercase enum name for storage_backend to match CHECK constraint
245        let storage_backend_str = match backup.storage_backend {
246            StorageBackend::Local => "local",
247            StorageBackend::S3 => "s3",
248            StorageBackend::Azure => "azure",
249            StorageBackend::Gcs => "gcs",
250            StorageBackend::Custom => "custom",
251        };
252        let storage_config_str = backup.storage_config.as_ref().map(ToString::to_string);
253        let created_at_str = backup.created_at.to_rfc3339();
254        let expires_at_str = backup.expires_at.map(|dt| dt.to_rfc3339());
255
256        // Save to database - use Uuid directly to match how users/workspaces are stored
257        sqlx::query!(
258            r#"
259            INSERT INTO workspace_backups (
260                id, workspace_id, backup_url, storage_backend, storage_config,
261                size_bytes, backup_format, encrypted, commit_id, created_at, created_by, expires_at
262            )
263            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
264            "#,
265            backup.id,
266            backup.workspace_id,
267            backup.backup_url,
268            storage_backend_str,
269            storage_config_str,
270            backup.size_bytes,
271            backup.backup_format,
272            backup.encrypted,
273            backup.commit_id,
274            created_at_str,
275            backup.created_by,
276            expires_at_str
277        )
278        .execute(&self.db)
279        .await?;
280
281        Ok(backup)
282    }
283
284    /// Restore a workspace from a backup
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the backup is not found, data deserialization fails,
289    /// or the workspace cannot be restored.
290    pub async fn restore_workspace(
291        &self,
292        backup_id: Uuid,
293        target_workspace_id: Option<Uuid>,
294        _user_id: Uuid,
295    ) -> Result<Uuid> {
296        // Get backup record
297        let backup = self.get_backup(backup_id).await?;
298
299        // Load backup data
300        let backup_data = match backup.storage_backend {
301            StorageBackend::Local => self.load_from_local(&backup.backup_url).await?,
302            StorageBackend::Custom => {
303                self.load_from_custom(&backup.backup_url, backup.storage_config.as_ref())
304                    .await?
305            }
306            _ => {
307                return Err(CollabError::Internal(
308                    "Only local and custom backups are supported for restore".to_string(),
309                ));
310            }
311        };
312
313        // Deserialize workspace data
314        let workspace_data: serde_json::Value = match backup.backup_format.as_str() {
315            "yaml" => serde_yaml::from_str(&backup_data)
316                .map_err(|e| CollabError::Internal(format!("Failed to deserialize YAML: {e}")))?,
317            "json" => serde_json::from_str(&backup_data)
318                .map_err(|e| CollabError::Internal(format!("Failed to deserialize JSON: {e}")))?,
319            _ => {
320                return Err(CollabError::Internal(format!(
321                    "Unsupported backup format: {}",
322                    backup.backup_format
323                )));
324            }
325        };
326
327        // Get the user who created the backup (or use a default - this should be passed in)
328        // For now, we'll need to get it from the backup record
329        let backup_record = self.get_backup(backup_id).await?;
330        let owner_id = backup_record.created_by;
331
332        // Import workspace from backup using CoreBridge
333        let restored_team_workspace = self
334            .core_bridge
335            .import_workspace_from_backup(&workspace_data, owner_id, None)
336            .await?;
337
338        // Determine target workspace ID
339        let restored_workspace_id = target_workspace_id.unwrap_or(backup.workspace_id);
340
341        // If restoring to a different workspace, update the ID
342        let team_workspace = if restored_workspace_id == backup.workspace_id {
343            // Update existing workspace
344            restored_team_workspace
345        } else {
346            // Create new workspace with the restored data
347            let mut new_workspace = restored_team_workspace;
348            new_workspace.id = restored_workspace_id;
349            new_workspace
350        };
351
352        // Update the workspace in the database
353        // This is a simplified version - in production, you'd want to use WorkspaceService
354        // For now, we'll save it to disk and let the system pick it up
355        self.core_bridge.save_workspace_to_disk(&team_workspace).await?;
356
357        // Create restore commit if specified
358        if let Some(commit_id) = backup.commit_id {
359            // Restore to specific commit
360            let _ =
361                self.version_control.restore_to_commit(restored_workspace_id, commit_id).await?;
362        }
363
364        Ok(restored_workspace_id)
365    }
366
367    /// List all backups for a workspace
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the database query fails.
372    pub async fn list_backups(
373        &self,
374        workspace_id: Uuid,
375        limit: Option<i32>,
376    ) -> Result<Vec<WorkspaceBackup>> {
377        let limit = limit.unwrap_or(100);
378
379        let rows = sqlx::query!(
380            r#"
381            SELECT
382                id as "id: Uuid",
383                workspace_id as "workspace_id: Uuid",
384                backup_url,
385                storage_backend,
386                storage_config,
387                size_bytes,
388                backup_format,
389                encrypted,
390                commit_id as "commit_id: Uuid",
391                created_at,
392                created_by as "created_by: Uuid",
393                expires_at
394            FROM workspace_backups
395            WHERE workspace_id = ?
396            ORDER BY created_at DESC
397            LIMIT ?
398            "#,
399            workspace_id,
400            limit
401        )
402        .fetch_all(&self.db)
403        .await?;
404
405        let backups: Result<Vec<WorkspaceBackup>> = rows
406            .into_iter()
407            .map(|row| {
408                let storage_backend = match row.storage_backend.as_str() {
409                    "local" => StorageBackend::Local,
410                    "s3" => StorageBackend::S3,
411                    "azure" => StorageBackend::Azure,
412                    "gcs" => StorageBackend::Gcs,
413                    "custom" => StorageBackend::Custom,
414                    other => {
415                        return Err(CollabError::Internal(format!(
416                            "Invalid storage_backend: {other}"
417                        )))
418                    }
419                };
420                Ok(WorkspaceBackup {
421                    id: row.id,
422                    workspace_id: row.workspace_id,
423                    backup_url: row.backup_url,
424                    storage_backend,
425                    storage_config: row
426                        .storage_config
427                        .as_ref()
428                        .and_then(|s| serde_json::from_str(s).ok()),
429                    size_bytes: row.size_bytes,
430                    backup_format: row.backup_format,
431                    encrypted: row.encrypted != 0,
432                    commit_id: row.commit_id,
433                    created_at: DateTime::parse_from_rfc3339(&row.created_at)
434                        .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
435                        .with_timezone(&Utc),
436                    created_by: row.created_by,
437                    expires_at: row
438                        .expires_at
439                        .as_ref()
440                        .map(|s| {
441                            DateTime::parse_from_rfc3339(s)
442                                .map(|dt| dt.with_timezone(&Utc))
443                                .map_err(|e| {
444                                    CollabError::Internal(format!("Invalid timestamp: {e}"))
445                                })
446                        })
447                        .transpose()?,
448                })
449            })
450            .collect();
451        let backups = backups?;
452
453        Ok(backups)
454    }
455
456    /// Get a backup by ID
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if the backup is not found or the database query fails.
461    pub async fn get_backup(&self, backup_id: Uuid) -> Result<WorkspaceBackup> {
462        let row = sqlx::query!(
463            r#"
464            SELECT
465                id as "id: Uuid",
466                workspace_id as "workspace_id: Uuid",
467                backup_url,
468                storage_backend,
469                storage_config,
470                size_bytes,
471                backup_format,
472                encrypted,
473                commit_id as "commit_id: Uuid",
474                created_at,
475                created_by as "created_by: Uuid",
476                expires_at
477            FROM workspace_backups
478            WHERE id = ?
479            "#,
480            backup_id
481        )
482        .fetch_optional(&self.db)
483        .await?
484        .ok_or_else(|| CollabError::Internal(format!("Backup not found: {backup_id}")))?;
485
486        let storage_backend = match row.storage_backend.as_str() {
487            "local" => StorageBackend::Local,
488            "s3" => StorageBackend::S3,
489            "azure" => StorageBackend::Azure,
490            "gcs" => StorageBackend::Gcs,
491            "custom" => StorageBackend::Custom,
492            other => {
493                return Err(CollabError::Internal(format!("Invalid storage_backend: {other}")))
494            }
495        };
496
497        Ok(WorkspaceBackup {
498            id: row.id,
499            workspace_id: row.workspace_id,
500            backup_url: row.backup_url,
501            storage_backend,
502            storage_config: row.storage_config.as_ref().and_then(|s| serde_json::from_str(s).ok()),
503            size_bytes: row.size_bytes,
504            backup_format: row.backup_format,
505            encrypted: row.encrypted != 0,
506            commit_id: row.commit_id,
507            created_at: DateTime::parse_from_rfc3339(&row.created_at)
508                .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
509                .with_timezone(&Utc),
510            created_by: row.created_by,
511            expires_at: row
512                .expires_at
513                .as_ref()
514                .map(|s| {
515                    DateTime::parse_from_rfc3339(s)
516                        .map(|dt| dt.with_timezone(&Utc))
517                        .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))
518                })
519                .transpose()?,
520        })
521    }
522
523    /// Delete a backup
524    ///
525    /// # Errors
526    ///
527    /// Returns an error if the backup is not found or deletion from storage fails.
528    pub async fn delete_backup(&self, backup_id: Uuid) -> Result<()> {
529        // Get backup record to get the URL
530        let backup = self.get_backup(backup_id).await?;
531
532        // Delete from storage
533        match backup.storage_backend {
534            StorageBackend::Local => {
535                if Path::new(&backup.backup_url).exists() {
536                    tokio::fs::remove_file(&backup.backup_url).await.map_err(|e| {
537                        CollabError::Internal(format!("Failed to delete backup file: {e}"))
538                    })?;
539                }
540            }
541            StorageBackend::S3 => {
542                self.delete_from_s3(&backup.backup_url, backup.storage_config.as_ref()).await?;
543            }
544            StorageBackend::Azure => {
545                self.delete_from_azure(&backup.backup_url, backup.storage_config.as_ref())
546                    .await?;
547            }
548            StorageBackend::Gcs => {
549                self.delete_from_gcs(&backup.backup_url, backup.storage_config.as_ref()).await?;
550            }
551            StorageBackend::Custom => {
552                self.delete_from_custom(&backup.backup_url, backup.storage_config.as_ref())
553                    .await?;
554            }
555        }
556
557        // Delete from database
558        sqlx::query!(
559            r#"
560            DELETE FROM workspace_backups
561            WHERE id = ?
562            "#,
563            backup_id
564        )
565        .execute(&self.db)
566        .await?;
567
568        Ok(())
569    }
570
571    /// Save backup to local filesystem
572    async fn save_to_local(&self, workspace_id: Uuid, data: &str, ext: &str) -> Result<String> {
573        let backup_dir = self.local_backup_dir.as_ref().ok_or_else(|| {
574            CollabError::Internal("Local backup directory not configured".to_string())
575        })?;
576
577        // Ensure backup directory exists
578        tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
579            CollabError::Internal(format!("Failed to create backup directory: {e}"))
580        })?;
581
582        // Create backup filename with timestamp
583        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
584        let filename = format!("workspace_{workspace_id}_{timestamp}.{ext}");
585        let backup_path = Path::new(backup_dir).join(&filename);
586
587        // Write backup file
588        tokio::fs::write(&backup_path, data)
589            .await
590            .map_err(|e| CollabError::Internal(format!("Failed to write backup file: {e}")))?;
591
592        Ok(backup_path.to_string_lossy().to_string())
593    }
594
595    /// Load backup from local filesystem
596    async fn load_from_local(&self, backup_url: &str) -> Result<String> {
597        tokio::fs::read_to_string(backup_url)
598            .await
599            .map_err(|e| CollabError::Internal(format!("Failed to read backup file: {e}")))
600    }
601
602    /// Save backup to a custom HTTP storage backend.
603    ///
604    /// Expected config:
605    /// - `upload_url` (required): target URL for PUT uploads.
606    ///   Supports `{filename}` placeholder.
607    /// - `backup_url_base` (optional): base URL used to build persisted backup URL.
608    /// - `headers` (optional): object map of HTTP headers.
609    async fn save_to_custom(
610        &self,
611        workspace_id: Uuid,
612        data: &str,
613        format: &str,
614        storage_config: Option<&serde_json::Value>,
615    ) -> Result<String> {
616        let config = storage_config.ok_or_else(|| {
617            CollabError::Internal("Custom storage configuration required".to_string())
618        })?;
619
620        let upload_url = config.get("upload_url").and_then(|v| v.as_str()).ok_or_else(|| {
621            CollabError::Internal("Custom storage config must include 'upload_url'".to_string())
622        })?;
623
624        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
625        let ext = format;
626        let filename = format!("workspace_{workspace_id}_{timestamp}.{ext}");
627        #[allow(clippy::literal_string_with_formatting_args)]
628        let placeholder = "{filename}";
629        let resolved_upload_url = upload_url.replace(placeholder, &filename);
630
631        let mut request = self.client.put(&resolved_upload_url).body(data.to_string()).header(
632            "content-type",
633            match ext {
634                "yaml" => "application/x-yaml",
635                "json" => "application/json",
636                _ => "application/octet-stream",
637            },
638        );
639
640        if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
641            for (key, value) in headers {
642                if let Some(value) = value.as_str() {
643                    request = request.header(key, value);
644                }
645            }
646        }
647
648        let response = request
649            .send()
650            .await
651            .map_err(|e| CollabError::Internal(format!("Custom upload request failed: {e}")))?;
652
653        if !response.status().is_success() {
654            return Err(CollabError::Internal(format!(
655                "Custom upload failed with status {}",
656                response.status()
657            )));
658        }
659
660        if let Some(location) = response.headers().get("location").and_then(|v| v.to_str().ok()) {
661            return Ok(location.to_string());
662        }
663
664        if let Ok(body_json) = response.json::<serde_json::Value>().await {
665            if let Some(url) = body_json
666                .get("backup_url")
667                .or_else(|| body_json.get("url"))
668                .and_then(|v| v.as_str())
669            {
670                return Ok(url.to_string());
671            }
672        }
673
674        if let Some(base) = config.get("backup_url_base").and_then(|v| v.as_str()) {
675            return Ok(format!("{}/{}", base.trim_end_matches('/'), filename));
676        }
677
678        Ok(resolved_upload_url)
679    }
680
681    /// Load backup from custom HTTP storage backend.
682    async fn load_from_custom(
683        &self,
684        backup_url: &str,
685        storage_config: Option<&serde_json::Value>,
686    ) -> Result<String> {
687        let mut request = self.client.get(backup_url);
688        if let Some(config) = storage_config {
689            if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
690                for (key, value) in headers {
691                    if let Some(value) = value.as_str() {
692                        request = request.header(key, value);
693                    }
694                }
695            }
696        }
697
698        let response = request
699            .send()
700            .await
701            .map_err(|e| CollabError::Internal(format!("Custom download request failed: {e}")))?;
702        if !response.status().is_success() {
703            return Err(CollabError::Internal(format!(
704                "Custom download failed with status {}",
705                response.status()
706            )));
707        }
708
709        response
710            .text()
711            .await
712            .map_err(|e| CollabError::Internal(format!("Failed to read custom backup body: {e}")))
713    }
714
715    /// Save backup to S3
716    #[allow(unused_variables, clippy::unused_async)]
717    async fn save_to_s3(
718        &self,
719        workspace_id: Uuid,
720        data: &str,
721        format: &str,
722        storage_config: Option<&serde_json::Value>,
723    ) -> Result<String> {
724        #[cfg(feature = "s3")]
725        {
726            use aws_config::SdkConfig;
727            use aws_sdk_s3::config::{Credentials, Region};
728            use aws_sdk_s3::primitives::ByteStream;
729            use aws_sdk_s3::Client as S3Client;
730
731            let config = storage_config.ok_or_else(|| {
732                CollabError::Internal("S3 storage configuration required".to_string())
733            })?;
734
735            let bucket_name =
736                config.get("bucket_name").and_then(|v| v.as_str()).ok_or_else(|| {
737                    CollabError::Internal("S3 bucket_name not found in storage_config".to_string())
738                })?;
739
740            let prefix = config.get("prefix").and_then(|v| v.as_str()).unwrap_or("backups");
741            let region_str = config.get("region").and_then(|v| v.as_str()).unwrap_or("us-east-1");
742
743            let aws_config: SdkConfig = if let (Some(access_key_id), Some(secret_access_key)) = (
744                config.get("access_key_id").and_then(|v| v.as_str()),
745                config.get("secret_access_key").and_then(|v| v.as_str()),
746            ) {
747                let credentials =
748                    Credentials::new(access_key_id, secret_access_key, None, None, "mockforge");
749                aws_config::ConfigLoader::default()
750                    .credentials_provider(credentials)
751                    .region(Region::new(region_str.to_string()))
752                    .load()
753                    .await
754            } else {
755                aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
756            };
757
758            let client = S3Client::new(&aws_config);
759
760            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
761            let object_key = format!("{prefix}/workspace_{workspace_id}_{timestamp}.{format}");
762            let content_type = match format {
763                "yaml" => "application/x-yaml",
764                "json" => "application/json",
765                _ => "application/octet-stream",
766            };
767
768            client
769                .put_object()
770                .bucket(bucket_name)
771                .key(&object_key)
772                .content_type(content_type)
773                .body(ByteStream::from(data.as_bytes().to_vec()))
774                .send()
775                .await
776                .map_err(|e| CollabError::Internal(format!("Failed to upload to S3: {e}")))?;
777
778            let backup_url = format!("s3://{bucket_name}/{object_key}");
779            tracing::info!("Successfully uploaded backup to S3: {}", backup_url);
780            Ok(backup_url)
781        }
782
783        #[cfg(not(feature = "s3"))]
784        {
785            Err(CollabError::Internal(
786                "S3 backup requires 's3' feature to be enabled. Add 's3' feature to mockforge-collab in Cargo.toml.".to_string(),
787            ))
788        }
789    }
790
791    /// Save backup to Azure Blob Storage
792    #[allow(unused_variables, clippy::unused_async)]
793    async fn save_to_azure(
794        &self,
795        workspace_id: Uuid,
796        data: &str,
797        format: &str,
798        storage_config: Option<&serde_json::Value>,
799    ) -> Result<String> {
800        #[cfg(feature = "azure")]
801        {
802            use azure_identity::{DefaultAzureCredential, TokenCredentialOptions};
803            use azure_storage::StorageCredentials;
804            use azure_storage_blobs::prelude::*;
805            use std::sync::Arc;
806
807            // Get storage configuration
808            let config = storage_config.ok_or_else(|| {
809                CollabError::Internal("Azure storage configuration required".to_string())
810            })?;
811
812            let account_name = config
813                .get("account_name")
814                .and_then(|v| v.as_str())
815                .map(ToString::to_string)
816                .ok_or_else(|| {
817                    CollabError::Internal(
818                        "Azure account_name required in storage config".to_string(),
819                    )
820                })?;
821
822            let container_name = config
823                .get("container_name")
824                .and_then(|v| v.as_str())
825                .map_or_else(|| "mockforge-backups".to_string(), ToString::to_string);
826
827            // Build storage credentials
828            let storage_credentials = if let Some(account_key) =
829                config.get("account_key").and_then(|v| v.as_str()).map(ToString::to_string)
830            {
831                StorageCredentials::access_key(account_name.clone(), account_key)
832            } else if let Some(sas_token) =
833                config.get("sas_token").and_then(|v| v.as_str()).map(ToString::to_string)
834            {
835                StorageCredentials::sas_token(sas_token)
836                    .map_err(|e| CollabError::Internal(format!("Invalid SAS token: {e}")))?
837            } else {
838                let credential = Arc::new(
839                    DefaultAzureCredential::create(TokenCredentialOptions::default()).map_err(
840                        |e| {
841                            CollabError::Internal(format!(
842                                "Failed to create Azure credentials: {e}"
843                            ))
844                        },
845                    )?,
846                );
847                StorageCredentials::token_credential(credential)
848            };
849
850            // Create blob name with timestamp
851            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
852            let blob_name = format!("workspace_{workspace_id}_{timestamp}.{format}");
853
854            // Create blob client and upload
855            let blob_client = ClientBuilder::new(account_name.clone(), storage_credentials)
856                .blob_client(&container_name, &blob_name);
857
858            blob_client
859                .put_block_blob(data.as_bytes().to_vec())
860                .content_type(match format {
861                    "yaml" => "application/x-yaml",
862                    "json" => "application/json",
863                    _ => "application/octet-stream",
864                })
865                .await
866                .map_err(|e| CollabError::Internal(format!("Failed to upload to Azure: {e}")))?;
867
868            let backup_url = format!(
869                "https://{account_name}.blob.core.windows.net/{container_name}/{blob_name}"
870            );
871            tracing::info!("Successfully uploaded backup to Azure: {}", backup_url);
872            Ok(backup_url)
873        }
874
875        #[cfg(not(feature = "azure"))]
876        {
877            Err(CollabError::Internal(
878                "Azure backup requires 'azure' feature to be enabled. Add 'azure' feature to mockforge-collab in Cargo.toml.".to_string(),
879            ))
880        }
881    }
882
883    /// Save backup to Google Cloud Storage
884    #[allow(unused_variables, clippy::unused_async, clippy::large_futures)]
885    async fn save_to_gcs(
886        &self,
887        workspace_id: Uuid,
888        data: &str,
889        format: &str,
890        storage_config: Option<&serde_json::Value>,
891    ) -> Result<String> {
892        #[cfg(feature = "gcs")]
893        {
894            use bytes::Bytes;
895            use google_cloud_storage::client::Storage;
896
897            // Get storage configuration
898            let config = storage_config.ok_or_else(|| {
899                CollabError::Internal("GCS storage configuration required".to_string())
900            })?;
901
902            let bucket_name = config
903                .get("bucket_name")
904                .and_then(|v| v.as_str())
905                .unwrap_or("mockforge-backups");
906
907            // Initialize GCS client using the new builder API
908            let client = Storage::builder()
909                .build()
910                .await
911                .map_err(|e| CollabError::Internal(format!("Failed to create GCS client: {e}")))?;
912
913            // Create object name with timestamp
914            let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
915            let object_name = format!("workspace_{workspace_id}_{timestamp}.{format}");
916
917            // Upload object using the new write_object API
918            // Convert to bytes::Bytes which implements Into<Payload<BytesSource>>
919            let payload = Bytes::from(data.as_bytes().to_vec());
920            client
921                .write_object(bucket_name, &object_name, payload)
922                .send_unbuffered()
923                .await
924                .map_err(|e| CollabError::Internal(format!("Failed to upload to GCS: {e}")))?;
925
926            let backup_url = format!("gs://{bucket_name}/{object_name}");
927            tracing::info!("Successfully uploaded backup to GCS: {}", backup_url);
928            Ok(backup_url)
929        }
930
931        #[cfg(not(feature = "gcs"))]
932        {
933            Err(CollabError::Internal(
934                "GCS backup requires 'gcs' feature to be enabled. Add 'gcs' feature to mockforge-collab in Cargo.toml.".to_string(),
935            ))
936        }
937    }
938
939    /// Delete backup from S3
940    #[allow(clippy::unused_async)]
941    async fn delete_from_s3(
942        &self,
943        backup_url: &str,
944        storage_config: Option<&serde_json::Value>,
945    ) -> Result<()> {
946        #[cfg(not(feature = "s3"))]
947        let _ = (backup_url, storage_config);
948        #[cfg(feature = "s3")]
949        {
950            use aws_config::SdkConfig;
951            use aws_sdk_s3::config::{Credentials, Region};
952            use aws_sdk_s3::Client as S3Client;
953
954            // Parse S3 URL (format: s3://bucket-name/path/to/file)
955            if !backup_url.starts_with("s3://") {
956                return Err(CollabError::Internal(format!("Invalid S3 URL format: {backup_url}")));
957            }
958
959            let url_parts: Vec<&str> = backup_url
960                .strip_prefix("s3://")
961                .ok_or_else(|| {
962                    CollabError::Internal(format!("Invalid S3 URL format: {backup_url}"))
963                })?
964                .splitn(2, '/')
965                .collect();
966            if url_parts.len() != 2 {
967                return Err(CollabError::Internal(format!("Invalid S3 URL format: {backup_url}")));
968            }
969
970            let bucket = url_parts[0];
971            let key = url_parts[1];
972
973            // Build AWS config with credentials from storage_config or environment
974            let aws_config: SdkConfig = if let Some(config) = storage_config {
975                // Extract S3 credentials from storage_config
976                // Expected format: {"access_key_id": "...", "secret_access_key": "...", "region": "..."}
977                let access_key_id =
978                    config.get("access_key_id").and_then(|v| v.as_str()).ok_or_else(|| {
979                        CollabError::Internal(
980                            "S3 access_key_id not found in storage_config".to_string(),
981                        )
982                    })?;
983
984                let secret_access_key =
985                    config.get("secret_access_key").and_then(|v| v.as_str()).ok_or_else(|| {
986                        CollabError::Internal(
987                            "S3 secret_access_key not found in storage_config".to_string(),
988                        )
989                    })?;
990
991                let region_str =
992                    config.get("region").and_then(|v| v.as_str()).unwrap_or("us-east-1");
993
994                // Create credentials provider
995                let credentials = Credentials::new(
996                    access_key_id,
997                    secret_access_key,
998                    None, // session token
999                    None, // expiration
1000                    "mockforge",
1001                );
1002
1003                // Build AWS config with custom credentials and region
1004                aws_config::ConfigLoader::default()
1005                    .credentials_provider(credentials)
1006                    .region(Region::new(region_str.to_string()))
1007                    .load()
1008                    .await
1009            } else {
1010                // Use default AWS config (from environment variables, IAM role, etc.)
1011                aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await
1012            };
1013
1014            // Create S3 client
1015            let client = S3Client::new(&aws_config);
1016
1017            // Delete object from S3
1018            client
1019                .delete_object()
1020                .bucket(bucket)
1021                .key(key)
1022                .send()
1023                .await
1024                .map_err(|e| CollabError::Internal(format!("Failed to delete S3 object: {e}")))?;
1025
1026            tracing::info!("Successfully deleted S3 object: {}", backup_url);
1027            Ok(())
1028        }
1029
1030        #[cfg(not(feature = "s3"))]
1031        {
1032            Err(CollabError::Internal(
1033                "S3 deletion requires 's3' feature to be enabled. Add 's3' feature to mockforge-collab in Cargo.toml.".to_string(),
1034            ))
1035        }
1036    }
1037
1038    /// Delete backup from Azure Blob Storage
1039    #[allow(clippy::unused_async)]
1040    async fn delete_from_azure(
1041        &self,
1042        backup_url: &str,
1043        storage_config: Option<&serde_json::Value>,
1044    ) -> Result<()> {
1045        #[cfg(feature = "azure")]
1046        {
1047            use azure_identity::{DefaultAzureCredential, TokenCredentialOptions};
1048            use azure_storage::StorageCredentials;
1049            use azure_storage_blobs::prelude::*;
1050            use std::sync::Arc;
1051
1052            // Parse Azure URL (format: https://account.blob.core.windows.net/container/path)
1053            if !backup_url.contains("blob.core.windows.net") {
1054                return Err(CollabError::Internal(format!(
1055                    "Invalid Azure Blob URL format: {backup_url}"
1056                )));
1057            }
1058
1059            // Parse URL properly
1060            let url = url::Url::parse(backup_url)
1061                .map_err(|e| CollabError::Internal(format!("Invalid Azure URL: {e}")))?;
1062
1063            // Extract account name from hostname (e.g., "account.blob.core.windows.net" -> "account")
1064            let hostname = url
1065                .host_str()
1066                .ok_or_else(|| CollabError::Internal("Invalid Azure hostname".to_string()))?;
1067            let account_name = hostname.split('.').next().ok_or_else(|| {
1068                CollabError::Internal("Invalid Azure hostname format".to_string())
1069            })?;
1070
1071            // Extract container and blob name from path
1072            let path = url.path();
1073            let path_parts: Vec<&str> = path.splitn(3, '/').filter(|s| !s.is_empty()).collect();
1074            if path_parts.len() < 2 {
1075                return Err(CollabError::Internal(format!("Invalid Azure blob path: {path}")));
1076            }
1077
1078            let container_name = path_parts[0].to_string();
1079            let blob_name = path_parts[1..].join("/");
1080            let account_name = account_name.to_string();
1081
1082            // Helper function to create default credentials
1083            let create_default_creds = || -> Result<StorageCredentials> {
1084                let credential = Arc::new(
1085                    DefaultAzureCredential::create(TokenCredentialOptions::default()).map_err(
1086                        |e| {
1087                            CollabError::Internal(format!(
1088                                "Failed to create Azure credentials: {e}"
1089                            ))
1090                        },
1091                    )?,
1092                );
1093                Ok(StorageCredentials::token_credential(credential))
1094            };
1095
1096            // Build storage credentials from config or use DefaultAzureCredential
1097            let storage_credentials = if let Some(config) = storage_config {
1098                if let Some(account_key) =
1099                    config.get("account_key").and_then(|v| v.as_str()).map(ToString::to_string)
1100                {
1101                    // Use account key authentication
1102                    StorageCredentials::access_key(account_name.clone(), account_key)
1103                } else if let Some(sas_token) =
1104                    config.get("sas_token").and_then(|v| v.as_str()).map(ToString::to_string)
1105                {
1106                    // Use SAS token authentication
1107                    StorageCredentials::sas_token(sas_token)
1108                        .map_err(|e| CollabError::Internal(format!("Invalid SAS token: {e}")))?
1109                } else {
1110                    // Use DefaultAzureCredential for managed identity, environment vars, etc.
1111                    create_default_creds()?
1112                }
1113            } else {
1114                // Use DefaultAzureCredential
1115                create_default_creds()?
1116            };
1117
1118            // Create blob client and delete
1119            let blob_client = ClientBuilder::new(account_name, storage_credentials)
1120                .blob_client(&container_name, &blob_name);
1121
1122            blob_client
1123                .delete()
1124                .await
1125                .map_err(|e| CollabError::Internal(format!("Failed to delete Azure blob: {e}")))?;
1126
1127            tracing::info!("Successfully deleted Azure blob: {}", backup_url);
1128            Ok(())
1129        }
1130
1131        #[cfg(not(feature = "azure"))]
1132        {
1133            let _ = (backup_url, storage_config); // Suppress unused warnings
1134            Err(CollabError::Internal(
1135                "Azure deletion requires 'azure' feature to be enabled. Add 'azure' feature to mockforge-collab in Cargo.toml.".to_string(),
1136            ))
1137        }
1138    }
1139
1140    /// Delete backup from Google Cloud Storage
1141    #[allow(clippy::unused_async)]
1142    async fn delete_from_gcs(
1143        &self,
1144        backup_url: &str,
1145        _storage_config: Option<&serde_json::Value>,
1146    ) -> Result<()> {
1147        #[cfg(feature = "gcs")]
1148        {
1149            use google_cloud_storage::client::StorageControl;
1150
1151            // Parse GCS URL (format: gs://bucket-name/path/to/file)
1152            if !backup_url.starts_with("gs://") {
1153                return Err(CollabError::Internal(format!("Invalid GCS URL format: {backup_url}")));
1154            }
1155
1156            let url_parts: Vec<&str> = backup_url
1157                .strip_prefix("gs://")
1158                .ok_or_else(|| {
1159                    CollabError::Internal(format!("Invalid GCS URL format: {backup_url}"))
1160                })?
1161                .splitn(2, '/')
1162                .collect();
1163            if url_parts.len() != 2 {
1164                return Err(CollabError::Internal(format!(
1165                    "Invalid GCS URL format (expected gs://bucket/object): {backup_url}"
1166                )));
1167            }
1168
1169            let bucket_name = url_parts[0];
1170            let object_name = url_parts[1];
1171
1172            // Initialize GCS StorageControl client using the new API
1173            // Uses default credentials from environment (GOOGLE_APPLICATION_CREDENTIALS)
1174            // or metadata server when running on GCP
1175            let client = StorageControl::builder()
1176                .build()
1177                .await
1178                .map_err(|e| CollabError::Internal(format!("Failed to create GCS client: {e}")))?;
1179
1180            // Delete object using google-cloud-storage 1.5 API
1181            client
1182                .delete_object()
1183                .set_bucket(format!("projects/_/buckets/{bucket_name}"))
1184                .set_object(object_name)
1185                .send()
1186                .await
1187                .map_err(|e| CollabError::Internal(format!("Failed to delete GCS object: {e}")))?;
1188
1189            tracing::info!("Successfully deleted GCS object: {}", backup_url);
1190            Ok(())
1191        }
1192
1193        #[cfg(not(feature = "gcs"))]
1194        {
1195            let _ = backup_url; // Suppress unused warning
1196            Err(CollabError::Internal(
1197                "GCS deletion requires 'gcs' feature to be enabled. Add 'gcs' feature to mockforge-collab in Cargo.toml.".to_string(),
1198            ))
1199        }
1200    }
1201
1202    /// Delete backup from custom HTTP storage backend.
1203    async fn delete_from_custom(
1204        &self,
1205        backup_url: &str,
1206        storage_config: Option<&serde_json::Value>,
1207    ) -> Result<()> {
1208        let mut request = self.client.delete(backup_url);
1209        if let Some(config) = storage_config {
1210            if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
1211                for (key, value) in headers {
1212                    if let Some(value) = value.as_str() {
1213                        request = request.header(key, value);
1214                    }
1215                }
1216            }
1217        }
1218
1219        let response = request
1220            .send()
1221            .await
1222            .map_err(|e| CollabError::Internal(format!("Custom delete request failed: {e}")))?;
1223        if !response.status().is_success() {
1224            return Err(CollabError::Internal(format!(
1225                "Custom delete failed with status {}",
1226                response.status()
1227            )));
1228        }
1229        Ok(())
1230    }
1231
1232    /// Get workspace data for backup
1233    ///
1234    /// Gets the full workspace state from the `TeamWorkspace` and converts it to JSON.
1235    #[allow(dead_code)]
1236    async fn get_workspace_data(&self, workspace_id: Uuid) -> Result<serde_json::Value> {
1237        // Get the TeamWorkspace
1238        let team_workspace = self.workspace_service.get_workspace(workspace_id).await?;
1239
1240        // Use CoreBridge to get the full workspace state as JSON
1241        self.core_bridge.get_workspace_state_json(&team_workspace)
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248
1249    #[test]
1250    fn test_storage_backend_equality() {
1251        assert_eq!(StorageBackend::Local, StorageBackend::Local);
1252        assert_eq!(StorageBackend::S3, StorageBackend::S3);
1253        assert_eq!(StorageBackend::Azure, StorageBackend::Azure);
1254        assert_eq!(StorageBackend::Gcs, StorageBackend::Gcs);
1255        assert_eq!(StorageBackend::Custom, StorageBackend::Custom);
1256
1257        assert_ne!(StorageBackend::Local, StorageBackend::S3);
1258    }
1259
1260    #[test]
1261    fn test_storage_backend_serialization() {
1262        let backend = StorageBackend::S3;
1263        let json = serde_json::to_string(&backend).unwrap();
1264        let deserialized: StorageBackend = serde_json::from_str(&json).unwrap();
1265
1266        assert_eq!(backend, deserialized);
1267    }
1268
1269    #[test]
1270    fn test_storage_backend_all_variants() {
1271        let backends = vec![
1272            StorageBackend::Local,
1273            StorageBackend::S3,
1274            StorageBackend::Azure,
1275            StorageBackend::Gcs,
1276            StorageBackend::Custom,
1277        ];
1278
1279        for backend in backends {
1280            let json = serde_json::to_string(&backend).unwrap();
1281            let deserialized: StorageBackend = serde_json::from_str(&json).unwrap();
1282            assert_eq!(backend, deserialized);
1283        }
1284    }
1285
1286    #[test]
1287    fn test_workspace_backup_new() {
1288        let workspace_id = Uuid::new_v4();
1289        let created_by = Uuid::new_v4();
1290        let backup_url = "s3://bucket/backup.yaml".to_string();
1291        let size_bytes = 1024;
1292
1293        let backup = WorkspaceBackup::new(
1294            workspace_id,
1295            backup_url.clone(),
1296            StorageBackend::S3,
1297            size_bytes,
1298            created_by,
1299        );
1300
1301        assert_eq!(backup.workspace_id, workspace_id);
1302        assert_eq!(backup.backup_url, backup_url);
1303        assert_eq!(backup.storage_backend, StorageBackend::S3);
1304        assert_eq!(backup.size_bytes, size_bytes);
1305        assert_eq!(backup.created_by, created_by);
1306        assert_eq!(backup.backup_format, "yaml");
1307        assert!(!backup.encrypted);
1308        assert!(backup.commit_id.is_none());
1309        assert!(backup.expires_at.is_none());
1310        assert!(backup.storage_config.is_none());
1311    }
1312
1313    #[test]
1314    fn test_workspace_backup_clone() {
1315        let backup = WorkspaceBackup::new(
1316            Uuid::new_v4(),
1317            "backup.yaml".to_string(),
1318            StorageBackend::Local,
1319            512,
1320            Uuid::new_v4(),
1321        );
1322
1323        let cloned = backup.clone();
1324
1325        assert_eq!(backup.id, cloned.id);
1326        assert_eq!(backup.workspace_id, cloned.workspace_id);
1327        assert_eq!(backup.backup_url, cloned.backup_url);
1328        assert_eq!(backup.size_bytes, cloned.size_bytes);
1329    }
1330
1331    #[test]
1332    fn test_workspace_backup_serialization() {
1333        let backup = WorkspaceBackup::new(
1334            Uuid::new_v4(),
1335            "backup.yaml".to_string(),
1336            StorageBackend::Local,
1337            256,
1338            Uuid::new_v4(),
1339        );
1340
1341        let json = serde_json::to_string(&backup).unwrap();
1342        let deserialized: WorkspaceBackup = serde_json::from_str(&json).unwrap();
1343
1344        assert_eq!(backup.id, deserialized.id);
1345        assert_eq!(backup.workspace_id, deserialized.workspace_id);
1346        assert_eq!(backup.storage_backend, deserialized.storage_backend);
1347    }
1348
1349    #[test]
1350    fn test_workspace_backup_with_commit() {
1351        let mut backup = WorkspaceBackup::new(
1352            Uuid::new_v4(),
1353            "backup.yaml".to_string(),
1354            StorageBackend::Local,
1355            128,
1356            Uuid::new_v4(),
1357        );
1358
1359        let commit_id = Uuid::new_v4();
1360        backup.commit_id = Some(commit_id);
1361
1362        assert_eq!(backup.commit_id, Some(commit_id));
1363    }
1364
1365    #[test]
1366    fn test_workspace_backup_with_encryption() {
1367        let mut backup = WorkspaceBackup::new(
1368            Uuid::new_v4(),
1369            "backup.yaml".to_string(),
1370            StorageBackend::S3,
1371            2048,
1372            Uuid::new_v4(),
1373        );
1374
1375        backup.encrypted = true;
1376
1377        assert!(backup.encrypted);
1378    }
1379
1380    #[test]
1381    fn test_workspace_backup_with_expiration() {
1382        let mut backup = WorkspaceBackup::new(
1383            Uuid::new_v4(),
1384            "backup.yaml".to_string(),
1385            StorageBackend::Azure,
1386            512,
1387            Uuid::new_v4(),
1388        );
1389
1390        let expires_at = Utc::now() + chrono::Duration::days(30);
1391        backup.expires_at = Some(expires_at);
1392
1393        assert!(backup.expires_at.is_some());
1394    }
1395
1396    #[test]
1397    fn test_workspace_backup_with_storage_config() {
1398        let mut backup = WorkspaceBackup::new(
1399            Uuid::new_v4(),
1400            "backup.yaml".to_string(),
1401            StorageBackend::S3,
1402            1024,
1403            Uuid::new_v4(),
1404        );
1405
1406        let config = serde_json::json!({
1407            "region": "us-east-1",
1408            "bucket": "my-bucket"
1409        });
1410        backup.storage_config = Some(config.clone());
1411
1412        assert_eq!(backup.storage_config, Some(config));
1413    }
1414
1415    #[test]
1416    fn test_workspace_backup_different_formats() {
1417        let mut backup = WorkspaceBackup::new(
1418            Uuid::new_v4(),
1419            "backup.json".to_string(),
1420            StorageBackend::Local,
1421            256,
1422            Uuid::new_v4(),
1423        );
1424
1425        assert_eq!(backup.backup_format, "yaml"); // Default
1426
1427        backup.backup_format = "json".to_string();
1428        assert_eq!(backup.backup_format, "json");
1429    }
1430
1431    #[test]
1432    fn test_storage_backend_debug() {
1433        let backend = StorageBackend::S3;
1434        let debug_str = format!("{backend:?}");
1435        assert!(debug_str.contains("S3"));
1436    }
1437
1438    #[test]
1439    fn test_workspace_backup_debug() {
1440        let backup = WorkspaceBackup::new(
1441            Uuid::new_v4(),
1442            "backup.yaml".to_string(),
1443            StorageBackend::Local,
1444            100,
1445            Uuid::new_v4(),
1446        );
1447
1448        let debug_str = format!("{backup:?}");
1449        assert!(debug_str.contains("WorkspaceBackup"));
1450    }
1451}