Skip to main content

communitas_ui_api/
drive.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Drive DTOs for virtual disk, directory, file, and transfer views.
4//!
5//! These types are designed for UI rendering and MCP tool responses.
6//! They provide a simplified view of the underlying storage system
7//! with upload/download progress tracking and checksum verification.
8
9use serde::{Deserialize, Serialize};
10
11use crate::SyncState;
12
13/// Type of virtual disk (storage area).
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub enum DiskType {
16    /// Private disk - encrypted, local-only storage.
17    Private,
18    /// Public disk - content-addressed, distributed storage.
19    Public,
20    /// Shared disk - group-accessible with shared encryption.
21    Shared,
22}
23
24impl DiskType {
25    /// Returns a human-readable label for the disk type.
26    pub fn label(&self) -> &'static str {
27        match self {
28            Self::Private => "Private",
29            Self::Public => "Public",
30            Self::Shared => "Shared",
31        }
32    }
33}
34
35/// Information about a virtual disk.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct DiskInfo {
38    /// Type of the disk.
39    pub disk_type: DiskType,
40    /// Entity that owns this disk.
41    pub entity_id: String,
42    /// Total storage capacity in bytes.
43    pub total_bytes: u64,
44    /// Storage used in bytes.
45    pub used_bytes: u64,
46    /// Available storage in bytes.
47    pub available_bytes: u64,
48    /// Number of files stored.
49    pub file_count: u64,
50}
51
52/// Entry in a directory listing.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct DirectoryEntry {
55    /// File or directory name.
56    pub name: String,
57    /// Full path within the disk.
58    pub path: String,
59    /// Whether this entry is a directory.
60    pub is_directory: bool,
61    /// Size in bytes (0 for directories).
62    pub size_bytes: u64,
63    /// MIME type (None for directories).
64    pub mime_type: Option<String>,
65    /// Unix timestamp (ms) of last modification.
66    pub modified_at: i64,
67    /// Unix timestamp (ms) of creation.
68    pub created_at: i64,
69    /// BLAKE3 checksum (None for directories).
70    pub checksum: Option<String>,
71    /// Sync state of this entry.
72    pub sync_state: SyncState,
73}
74
75/// Metadata for a file.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct FileMetadata {
78    /// Unix timestamp (ms) of creation.
79    pub created_at: i64,
80    /// Unix timestamp (ms) of last modification.
81    pub modified_at: i64,
82    /// BLAKE3 checksum of the file content.
83    pub checksum: String,
84    /// Number of storage blocks.
85    pub block_count: u32,
86    /// Encryption method (None for unencrypted).
87    pub encryption: Option<String>,
88}
89
90/// Preview of a file with optional thumbnail and text excerpt.
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct FilePreview {
93    /// Full path within the disk.
94    pub path: String,
95    /// MIME type of the file.
96    pub mime_type: String,
97    /// Size in bytes.
98    pub size_bytes: u64,
99    /// Optional thumbnail data (e.g., for images).
100    pub thumbnail: Option<Vec<u8>>,
101    /// Optional text preview (first N characters for text files).
102    pub text_preview: Option<String>,
103    /// File metadata.
104    pub metadata: FileMetadata,
105}
106
107/// State of an upload operation.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub enum UploadState {
110    /// Upload is queued but not started.
111    Pending,
112    /// Upload is in progress.
113    Uploading,
114    /// Upload complete, verifying checksum.
115    Verifying,
116    /// Upload completed successfully.
117    Complete,
118    /// Upload failed with error message.
119    Failed(String),
120    /// Upload was cancelled by user.
121    Cancelled,
122    /// Upload can be resumed (detected on app restart).
123    Resumable,
124}
125
126impl UploadState {
127    /// Returns true if the upload is complete (successfully or not).
128    pub fn is_terminal(&self) -> bool {
129        matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
130    }
131
132    /// Returns true if the upload can be resumed.
133    pub fn is_resumable(&self) -> bool {
134        matches!(self, Self::Resumable | Self::Failed(_))
135    }
136}
137
138/// Progress of an upload operation.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct UploadProgress {
141    /// Unique upload identifier.
142    pub id: String,
143    /// Name of the file being uploaded.
144    pub file_name: String,
145    /// Destination path within the disk.
146    pub file_path: String,
147    /// Bytes uploaded so far.
148    pub bytes_uploaded: u64,
149    /// Total file size in bytes.
150    pub total_bytes: u64,
151    /// Current state of the upload.
152    pub state: UploadState,
153    /// Unix timestamp (ms) when upload started.
154    pub started_at: i64,
155    /// Whether checksum was verified after upload.
156    pub checksum_verified: bool,
157    /// Link to core TransferState for resume support.
158    #[serde(default)]
159    pub transfer_id: Option<String>,
160    /// Resume point in bytes (if this upload was resumed).
161    #[serde(default)]
162    pub resumed_from_bytes: Option<u64>,
163}
164
165impl UploadProgress {
166    /// Returns upload progress as a percentage (0-100).
167    pub fn percent_complete(&self) -> u32 {
168        if self.total_bytes == 0 {
169            0
170        } else {
171            ((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
172        }
173    }
174}
175
176/// State of a download operation.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub enum DownloadState {
179    /// Download is queued but not started.
180    Pending,
181    /// Download is in progress.
182    Downloading,
183    /// Download complete, verifying checksum.
184    Verifying,
185    /// Download completed successfully.
186    Complete,
187    /// Download failed with error message.
188    Failed(String),
189    /// Download was cancelled by user.
190    Cancelled,
191}
192
193impl DownloadState {
194    /// Returns true if the download is complete (successfully or not).
195    pub fn is_terminal(&self) -> bool {
196        matches!(self, Self::Complete | Self::Failed(_) | Self::Cancelled)
197    }
198}
199
200/// Progress of a download operation.
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202pub struct DownloadProgress {
203    /// Unique download identifier.
204    pub id: String,
205    /// Name of the file being downloaded.
206    pub file_name: String,
207    /// Local destination path.
208    pub destination_path: String,
209    /// Bytes downloaded so far.
210    pub bytes_downloaded: u64,
211    /// Total file size in bytes.
212    pub total_bytes: u64,
213    /// Current state of the download.
214    pub state: DownloadState,
215    /// Whether checksum was verified after download.
216    pub checksum_verified: bool,
217}
218
219impl DownloadProgress {
220    /// Returns download progress as a percentage (0-100).
221    pub fn percent_complete(&self) -> u32 {
222        if self.total_bytes == 0 {
223            0
224        } else {
225            ((self.bytes_downloaded as f64 / self.total_bytes as f64) * 100.0) as u32
226        }
227    }
228}
229
230/// Progress information for a single chunk during transfer.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct ChunkProgress {
233    /// Zero-based index of the current chunk.
234    pub chunk_index: u32,
235    /// Total number of chunks in the transfer.
236    pub total_chunks: u32,
237    /// Bytes transferred in this chunk.
238    pub bytes_transferred: u64,
239    /// Total bytes for this chunk.
240    pub chunk_size: u64,
241    /// BLAKE3 checksum of this chunk (hex-encoded).
242    pub chunk_checksum: Option<String>,
243}
244
245impl ChunkProgress {
246    /// Returns the chunk progress as a percentage (0-100).
247    pub fn chunk_percent(&self) -> u32 {
248        if self.chunk_size == 0 {
249            100
250        } else {
251            ((self.bytes_transferred as f64 / self.chunk_size as f64) * 100.0) as u32
252        }
253    }
254
255    /// Returns true if this is the last chunk.
256    pub fn is_last(&self) -> bool {
257        self.chunk_index + 1 >= self.total_chunks
258    }
259}
260
261/// Capability to resume an interrupted transfer.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
263pub enum ResumeCapability {
264    /// Transfer cannot be resumed (no state available).
265    None,
266    /// Transfer can be partially resumed (some chunks valid).
267    Partial,
268    /// Transfer can be fully resumed from last checkpoint.
269    Full,
270}
271
272impl ResumeCapability {
273    /// Returns true if the transfer can be resumed at all.
274    pub fn can_resume(&self) -> bool {
275        !matches!(self, Self::None)
276    }
277
278    /// Returns a human-readable description.
279    pub fn description(&self) -> &'static str {
280        match self {
281            Self::None => "Cannot resume - no saved state",
282            Self::Partial => "Can resume - some chunks may need re-transfer",
283            Self::Full => "Can resume - all checkpoints verified",
284        }
285    }
286}
287
288/// State of an in-progress or resumable transfer.
289#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
290pub struct TransferState {
291    /// Unique transfer identifier.
292    pub id: String,
293    /// Path of the file being transferred.
294    pub file_path: String,
295    /// Total file size in bytes.
296    pub total_bytes: u64,
297    /// Bytes successfully transferred.
298    pub bytes_transferred: u64,
299    /// Number of completed chunks.
300    pub chunks_completed: u32,
301    /// Total number of chunks.
302    pub total_chunks: u32,
303    /// BLAKE3 checksum of bytes transferred so far (hex-encoded).
304    pub checksum_so_far: String,
305    /// Unix timestamp (ms) when transfer started.
306    pub started_at: i64,
307    /// Unix timestamp (ms) of last activity.
308    pub last_updated: i64,
309    /// Resume capability for this transfer.
310    pub resume_capability: ResumeCapability,
311    /// Whether this is an upload (true) or download (false).
312    pub is_upload: bool,
313}
314
315impl TransferState {
316    /// Returns transfer progress as a percentage (0-100).
317    pub fn percent_complete(&self) -> u32 {
318        if self.total_bytes == 0 {
319            0
320        } else {
321            ((self.bytes_transferred as f64 / self.total_bytes as f64) * 100.0) as u32
322        }
323    }
324
325    /// Returns the number of remaining chunks.
326    pub fn chunks_remaining(&self) -> u32 {
327        self.total_chunks.saturating_sub(self.chunks_completed)
328    }
329
330    /// Returns bytes remaining to transfer.
331    pub fn bytes_remaining(&self) -> u64 {
332        self.total_bytes.saturating_sub(self.bytes_transferred)
333    }
334
335    /// Returns true if transfer is complete.
336    pub fn is_complete(&self) -> bool {
337        self.chunks_completed >= self.total_chunks
338    }
339}
340
341/// Error that may occur when resuming a transfer.
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub enum TransferError {
344    /// Transfer state not found for the given ID.
345    StateNotFound(String),
346    /// File was modified since transfer started.
347    FileModified {
348        /// Expected checksum from transfer state.
349        expected_checksum: String,
350        /// Actual checksum of file content.
351        actual_checksum: String,
352    },
353    /// Source file no longer exists.
354    SourceNotFound(String),
355    /// Destination path is no longer valid.
356    DestinationInvalid(String),
357    /// Transfer state is corrupted.
358    StateCorrupted(String),
359    /// Network error during transfer.
360    NetworkError(String),
361    /// Storage quota exceeded.
362    QuotaExceeded {
363        /// Required bytes for transfer.
364        required_bytes: u64,
365        /// Available bytes.
366        available_bytes: u64,
367    },
368    /// Checksum verification failed for a chunk.
369    ChunkVerificationFailed {
370        /// Index of the failed chunk.
371        chunk_index: u32,
372        /// Expected checksum.
373        expected: String,
374        /// Actual checksum.
375        actual: String,
376    },
377    /// Transfer was cancelled.
378    Cancelled,
379    /// Generic I/O error.
380    IoError(String),
381}
382
383impl TransferError {
384    /// Returns true if the error is recoverable by retrying.
385    pub fn is_recoverable(&self) -> bool {
386        matches!(
387            self,
388            Self::NetworkError(_) | Self::ChunkVerificationFailed { .. }
389        )
390    }
391
392    /// Returns a human-readable error message.
393    pub fn message(&self) -> String {
394        match self {
395            Self::StateNotFound(id) => format!("Transfer state not found: {id}"),
396            Self::FileModified { .. } => "File was modified during transfer".to_string(),
397            Self::SourceNotFound(path) => format!("Source file not found: {path}"),
398            Self::DestinationInvalid(path) => format!("Invalid destination: {path}"),
399            Self::StateCorrupted(reason) => format!("Transfer state corrupted: {reason}"),
400            Self::NetworkError(msg) => format!("Network error: {msg}"),
401            Self::QuotaExceeded {
402                required_bytes,
403                available_bytes,
404            } => {
405                format!(
406                    "Storage quota exceeded: need {} bytes, have {} available",
407                    required_bytes, available_bytes
408                )
409            }
410            Self::ChunkVerificationFailed { chunk_index, .. } => {
411                format!("Chunk {chunk_index} verification failed")
412            }
413            Self::Cancelled => "Transfer was cancelled".to_string(),
414            Self::IoError(msg) => format!("I/O error: {msg}"),
415        }
416    }
417}
418
419/// Quota information for a disk.
420#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
421pub struct QuotaInfo {
422    /// Type of disk.
423    pub disk_type: DiskType,
424    /// Storage used in bytes.
425    pub used_bytes: u64,
426    /// Storage quota (limit) in bytes.
427    pub quota_bytes: u64,
428    /// Percentage of quota used (0.0 - 100.0).
429    pub percent_used: f32,
430}
431
432impl QuotaInfo {
433    /// Returns the remaining bytes available.
434    pub fn remaining_bytes(&self) -> u64 {
435        self.quota_bytes.saturating_sub(self.used_bytes)
436    }
437
438    /// Returns true if quota is exceeded.
439    pub fn is_exceeded(&self) -> bool {
440        self.used_bytes >= self.quota_bytes
441    }
442
443    /// Returns true if quota usage is above the warning threshold (90%).
444    pub fn is_warning(&self) -> bool {
445        self.percent_used >= 90.0
446    }
447}
448
449/// Share link for a file with optional expiry and password protection.
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct ShareLink {
452    /// Unique share link ID.
453    pub id: String,
454    /// Entity ID that owns the file.
455    pub entity_id: String,
456    /// Disk type containing the file.
457    pub disk_type: DiskType,
458    /// Path to the shared file.
459    pub file_path: String,
460    /// File name for display.
461    pub file_name: String,
462    /// Shareable URL.
463    pub url: String,
464    /// Unix timestamp (ms) when link was created.
465    pub created_at: i64,
466    /// Unix timestamp (ms) when link expires (None = never).
467    pub expires_at: Option<i64>,
468    /// Whether the link is password protected.
469    pub password_protected: bool,
470    /// Number of times the link has been accessed.
471    pub access_count: u64,
472    /// Maximum number of accesses allowed (None = unlimited).
473    pub max_accesses: Option<u64>,
474    /// Whether the link is currently active.
475    pub active: bool,
476}
477
478impl ShareLink {
479    /// Returns true if the link has expired.
480    pub fn is_expired(&self, now_ms: i64) -> bool {
481        self.expires_at.is_some_and(|exp| now_ms >= exp)
482    }
483
484    /// Returns true if the link has reached max accesses.
485    pub fn is_access_limit_reached(&self) -> bool {
486        self.max_accesses
487            .is_some_and(|max| self.access_count >= max)
488    }
489
490    /// Returns true if the link is currently usable.
491    pub fn is_usable(&self, now_ms: i64) -> bool {
492        self.active && !self.is_expired(now_ms) && !self.is_access_limit_reached()
493    }
494
495    /// Returns remaining accesses (None if unlimited).
496    pub fn remaining_accesses(&self) -> Option<u64> {
497        self.max_accesses
498            .map(|max| max.saturating_sub(self.access_count))
499    }
500}
501
502/// Configuration for creating a share link.
503#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
504pub struct ShareLinkConfig {
505    /// Optional expiry duration in milliseconds from creation.
506    pub expires_in_ms: Option<i64>,
507    /// Optional password for the link.
508    pub password: Option<String>,
509    /// Maximum number of accesses (None = unlimited).
510    pub max_accesses: Option<u64>,
511}
512
513impl ShareLinkConfig {
514    /// Create a config that expires in a given number of hours.
515    pub fn expires_in_hours(hours: u32) -> Self {
516        Self {
517            expires_in_ms: Some(hours as i64 * 60 * 60 * 1000),
518            password: None,
519            max_accesses: None,
520        }
521    }
522
523    /// Create a config that expires in a given number of days.
524    pub fn expires_in_days(days: u32) -> Self {
525        Self {
526            expires_in_ms: Some(days as i64 * 24 * 60 * 60 * 1000),
527            password: None,
528            max_accesses: None,
529        }
530    }
531
532    /// Add password protection.
533    pub fn with_password(mut self, password: impl Into<String>) -> Self {
534        self.password = Some(password.into());
535        self
536    }
537
538    /// Limit number of accesses.
539    pub fn with_max_accesses(mut self, max: u64) -> Self {
540        self.max_accesses = Some(max);
541        self
542    }
543}
544
545/// Result of a share link access attempt.
546#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
547pub enum ShareLinkAccessResult {
548    /// Access granted, contains file metadata for download.
549    Granted {
550        /// File path on the disk.
551        file_path: String,
552        /// File name.
553        file_name: String,
554        /// File size in bytes.
555        size_bytes: u64,
556        /// MIME type.
557        mime_type: Option<String>,
558        /// BLAKE3 checksum.
559        checksum: String,
560    },
561    /// Password required to access the link.
562    PasswordRequired,
563    /// Incorrect password provided.
564    IncorrectPassword,
565    /// Link has expired.
566    Expired,
567    /// Access limit reached.
568    AccessLimitReached,
569    /// Link has been revoked.
570    Revoked,
571    /// Link not found.
572    NotFound,
573}
574
575impl ShareLinkAccessResult {
576    /// Returns true if access was granted.
577    pub fn is_granted(&self) -> bool {
578        matches!(self, Self::Granted { .. })
579    }
580
581    /// Returns a user-friendly error message (None if granted).
582    pub fn error_message(&self) -> Option<&'static str> {
583        match self {
584            Self::Granted { .. } => None,
585            Self::PasswordRequired => Some("This link requires a password"),
586            Self::IncorrectPassword => Some("Incorrect password"),
587            Self::Expired => Some("This link has expired"),
588            Self::AccessLimitReached => Some("This link has reached its access limit"),
589            Self::Revoked => Some("This link has been revoked"),
590            Self::NotFound => Some("Link not found"),
591        }
592    }
593}
594
595/// Usage statistics for a share link.
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597pub struct ShareLinkStats {
598    /// Total number of accesses.
599    pub total_accesses: u64,
600    /// Number of successful downloads.
601    pub successful_downloads: u64,
602    /// Number of failed password attempts.
603    pub failed_password_attempts: u64,
604    /// Unix timestamp (ms) of last access.
605    pub last_accessed_at: Option<i64>,
606    /// Unique IP addresses that accessed (hashed for privacy).
607    pub unique_accessors: u64,
608}
609
610// === Offline Staging Types ===
611
612/// State of a staged upload in the offline queue.
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
614pub enum StagedUploadState {
615    /// Queued and waiting for network connectivity.
616    Pending,
617    /// Upload in progress (network restored).
618    Uploading,
619    /// Upload paused due to conflict.
620    Conflicted,
621    /// Upload completed successfully.
622    Completed,
623    /// Upload failed after retries exhausted.
624    Failed,
625}
626
627impl StagedUploadState {
628    /// Returns true if the staged upload is in a terminal state.
629    pub fn is_terminal(&self) -> bool {
630        matches!(self, Self::Completed | Self::Failed)
631    }
632
633    /// Returns true if the staged upload requires user action.
634    pub fn requires_action(&self) -> bool {
635        matches!(self, Self::Conflicted | Self::Failed)
636    }
637
638    /// Returns a human-readable label for the state.
639    pub fn label(&self) -> &'static str {
640        match self {
641            Self::Pending => "Pending",
642            Self::Uploading => "Uploading",
643            Self::Conflicted => "Conflict",
644            Self::Completed => "Completed",
645            Self::Failed => "Failed",
646        }
647    }
648}
649
650/// A file staged for upload while offline.
651#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
652pub struct StagedUpload {
653    /// Unique identifier for this staged upload.
654    pub id: String,
655    /// Entity ID that owns the destination disk.
656    pub entity_id: String,
657    /// Destination disk type.
658    pub disk_type: DiskType,
659    /// Destination path within the disk.
660    pub destination_path: String,
661    /// Local file path (source).
662    pub local_path: String,
663    /// File name for display.
664    pub file_name: String,
665    /// File size in bytes.
666    pub size_bytes: u64,
667    /// MIME type of the file.
668    pub mime_type: Option<String>,
669    /// BLAKE3 checksum of the local file (at staging time).
670    pub local_checksum: String,
671    /// Current state of the staged upload.
672    pub state: StagedUploadState,
673    /// Number of upload retry attempts.
674    pub retry_count: u32,
675    /// Maximum retry attempts before marking as failed.
676    pub max_retries: u32,
677    /// Error message if state is Failed.
678    pub error: Option<String>,
679    /// Unix timestamp (ms) when the file was staged.
680    pub staged_at: i64,
681    /// Unix timestamp (ms) of last state update.
682    pub updated_at: i64,
683    /// Associated conflict (if state is Conflicted).
684    pub conflict: Option<StagingConflict>,
685}
686
687impl StagedUpload {
688    /// Returns true if the upload can be retried.
689    pub fn can_retry(&self) -> bool {
690        matches!(self.state, StagedUploadState::Failed) && self.retry_count < self.max_retries
691    }
692
693    /// Returns remaining retry attempts.
694    pub fn retries_remaining(&self) -> u32 {
695        self.max_retries.saturating_sub(self.retry_count)
696    }
697
698    /// Returns the age of this staged upload in milliseconds.
699    /// Returns 0 if `now_ms` is before `staged_at`.
700    pub fn age_ms(&self, now_ms: i64) -> i64 {
701        (now_ms - self.staged_at).max(0)
702    }
703}
704
705/// Type of conflict detected during staging sync.
706#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
707pub enum ConflictType {
708    /// File already exists at destination with different content.
709    FileExists,
710    /// Local file was modified after staging.
711    LocalModified,
712    /// Destination file was modified since staging.
713    RemoteModified,
714    /// Both local and remote were modified.
715    BothModified,
716    /// Destination path is now occupied by a directory.
717    PathTypeChanged,
718    /// Insufficient quota for the upload.
719    QuotaExceeded,
720}
721
722impl ConflictType {
723    /// Returns a human-readable description of the conflict.
724    pub fn description(&self) -> &'static str {
725        match self {
726            Self::FileExists => "A file with this name already exists",
727            Self::LocalModified => "The local file was modified after staging",
728            Self::RemoteModified => "The destination file was modified",
729            Self::BothModified => "Both local and remote files were modified",
730            Self::PathTypeChanged => "The destination path is now a directory",
731            Self::QuotaExceeded => "Insufficient storage quota",
732        }
733    }
734}
735
736/// Conflict detected during staging sync.
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
738pub struct StagingConflict {
739    /// Type of conflict.
740    pub conflict_type: ConflictType,
741    /// Staged file checksum (at staging time).
742    pub staged_checksum: String,
743    /// Current local file checksum (if different).
744    pub local_checksum: Option<String>,
745    /// Remote file checksum (if exists).
746    pub remote_checksum: Option<String>,
747    /// Remote file size (if exists).
748    pub remote_size_bytes: Option<u64>,
749    /// Unix timestamp (ms) when conflict was detected.
750    pub detected_at: i64,
751}
752
753impl StagingConflict {
754    /// Returns true if the conflict can be auto-resolved.
755    pub fn can_auto_resolve(&self) -> bool {
756        // Only quota conflicts require external action
757        !matches!(self.conflict_type, ConflictType::QuotaExceeded)
758    }
759}
760
761/// Resolution action for a staging conflict.
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
763pub enum ConflictResolution {
764    /// Keep the local version (overwrite remote).
765    KeepLocal,
766    /// Keep the remote version (discard staged).
767    KeepRemote,
768    /// Keep both (rename staged file).
769    KeepBoth,
770    /// Skip this file (remove from staging).
771    Skip,
772    /// Retry the upload (for transient errors).
773    Retry,
774}
775
776impl ConflictResolution {
777    /// Returns a human-readable label for the resolution.
778    pub fn label(&self) -> &'static str {
779        match self {
780            Self::KeepLocal => "Upload my version",
781            Self::KeepRemote => "Keep existing",
782            Self::KeepBoth => "Keep both",
783            Self::Skip => "Skip",
784            Self::Retry => "Retry",
785        }
786    }
787
788    /// Returns a description of what this resolution does.
789    pub fn description(&self) -> &'static str {
790        match self {
791            Self::KeepLocal => "Replace the remote file with your local version",
792            Self::KeepRemote => "Discard your local changes and keep the remote version",
793            Self::KeepBoth => "Upload with a new name to keep both versions",
794            Self::Skip => "Remove this file from the upload queue",
795            Self::Retry => "Try uploading again",
796        }
797    }
798}
799
800/// Status summary of the offline staging queue.
801#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
802pub struct StagingQueueStatus {
803    /// Total number of files in the staging queue.
804    pub total_files: u32,
805    /// Number of files pending upload.
806    pub pending_files: u32,
807    /// Number of files currently uploading.
808    pub uploading_files: u32,
809    /// Number of files with conflicts.
810    pub conflicted_files: u32,
811    /// Number of files that failed.
812    pub failed_files: u32,
813    /// Number of files completed.
814    pub completed_files: u32,
815    /// Total bytes pending upload.
816    pub total_bytes: u64,
817    /// Bytes uploaded so far.
818    pub bytes_uploaded: u64,
819    /// Whether the queue is currently syncing.
820    pub is_syncing: bool,
821    /// Whether network is available.
822    pub network_available: bool,
823    /// Unix timestamp (ms) of last sync attempt.
824    pub last_sync_at: Option<i64>,
825    /// Error from last sync attempt (if failed).
826    pub last_sync_error: Option<String>,
827}
828
829impl StagingQueueStatus {
830    /// Returns true if there are files requiring user action.
831    pub fn has_action_required(&self) -> bool {
832        self.conflicted_files > 0 || self.failed_files > 0
833    }
834
835    /// Returns true if the queue is empty (all terminal states).
836    pub fn is_empty(&self) -> bool {
837        self.pending_files == 0 && self.uploading_files == 0 && self.conflicted_files == 0
838    }
839
840    /// Returns true if all uploads completed successfully.
841    pub fn all_completed(&self) -> bool {
842        self.completed_files == self.total_files && self.total_files > 0
843    }
844
845    /// Returns upload progress as a percentage (0-100).
846    pub fn percent_complete(&self) -> u32 {
847        if self.total_bytes == 0 {
848            if self.total_files == 0 { 100 } else { 0 }
849        } else {
850            ((self.bytes_uploaded as f64 / self.total_bytes as f64) * 100.0) as u32
851        }
852    }
853
854    /// Returns the number of actionable items (pending + uploading).
855    pub fn active_count(&self) -> u32 {
856        self.pending_files + self.uploading_files
857    }
858}
859
860/// Event emitted when staging queue state changes.
861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862pub enum StagingEvent {
863    /// A file was added to the staging queue.
864    FileStaged {
865        /// ID of the staged upload.
866        upload_id: String,
867        /// File name.
868        file_name: String,
869    },
870    /// Upload started for a staged file.
871    UploadStarted {
872        /// ID of the staged upload.
873        upload_id: String,
874    },
875    /// Upload progress update.
876    UploadProgress {
877        /// ID of the staged upload.
878        upload_id: String,
879        /// Bytes uploaded so far.
880        bytes_uploaded: u64,
881        /// Total bytes.
882        total_bytes: u64,
883    },
884    /// Upload completed successfully.
885    UploadCompleted {
886        /// ID of the staged upload.
887        upload_id: String,
888        /// Final destination path.
889        destination_path: String,
890    },
891    /// Conflict detected during upload.
892    ConflictDetected {
893        /// ID of the staged upload.
894        upload_id: String,
895        /// Type of conflict.
896        conflict_type: ConflictType,
897    },
898    /// Upload failed.
899    UploadFailed {
900        /// ID of the staged upload.
901        upload_id: String,
902        /// Error message.
903        error: String,
904    },
905    /// Staging queue cleared.
906    QueueCleared {
907        /// Number of files removed.
908        files_removed: u32,
909    },
910    /// Network connectivity changed.
911    NetworkStatusChanged {
912        /// Whether network is now available.
913        available: bool,
914    },
915    /// Sync started.
916    SyncStarted,
917    /// Sync completed.
918    SyncCompleted {
919        /// Number of files uploaded.
920        files_uploaded: u32,
921        /// Number of files failed.
922        files_failed: u32,
923    },
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    #[test]
931    fn disk_type_label() {
932        assert_eq!(DiskType::Private.label(), "Private");
933        assert_eq!(DiskType::Public.label(), "Public");
934        assert_eq!(DiskType::Shared.label(), "Shared");
935    }
936
937    #[test]
938    fn upload_state_is_terminal() {
939        assert!(!UploadState::Pending.is_terminal());
940        assert!(!UploadState::Uploading.is_terminal());
941        assert!(!UploadState::Verifying.is_terminal());
942        assert!(UploadState::Complete.is_terminal());
943        assert!(UploadState::Failed("error".to_string()).is_terminal());
944        assert!(UploadState::Cancelled.is_terminal());
945    }
946
947    #[test]
948    fn download_state_is_terminal() {
949        assert!(!DownloadState::Pending.is_terminal());
950        assert!(!DownloadState::Downloading.is_terminal());
951        assert!(!DownloadState::Verifying.is_terminal());
952        assert!(DownloadState::Complete.is_terminal());
953        assert!(DownloadState::Failed("error".to_string()).is_terminal());
954        assert!(DownloadState::Cancelled.is_terminal());
955    }
956
957    #[test]
958    fn upload_progress_percent() {
959        let progress = UploadProgress {
960            id: "upload-1".to_string(),
961            file_name: "test.txt".to_string(),
962            file_path: "/test.txt".to_string(),
963            bytes_uploaded: 50,
964            total_bytes: 100,
965            state: UploadState::Uploading,
966            started_at: 0,
967            checksum_verified: false,
968            transfer_id: None,
969            resumed_from_bytes: None,
970        };
971        assert_eq!(progress.percent_complete(), 50);
972    }
973
974    #[test]
975    fn upload_progress_percent_zero_total() {
976        let progress = UploadProgress {
977            id: "upload-1".to_string(),
978            file_name: "empty.txt".to_string(),
979            file_path: "/empty.txt".to_string(),
980            bytes_uploaded: 0,
981            total_bytes: 0,
982            state: UploadState::Complete,
983            started_at: 0,
984            checksum_verified: true,
985            transfer_id: None,
986            resumed_from_bytes: None,
987        };
988        assert_eq!(progress.percent_complete(), 0);
989    }
990
991    #[test]
992    fn download_progress_percent() {
993        let progress = DownloadProgress {
994            id: "download-1".to_string(),
995            file_name: "test.txt".to_string(),
996            destination_path: "/tmp/test.txt".to_string(),
997            bytes_downloaded: 75,
998            total_bytes: 100,
999            state: DownloadState::Downloading,
1000            checksum_verified: false,
1001        };
1002        assert_eq!(progress.percent_complete(), 75);
1003    }
1004
1005    #[test]
1006    fn quota_remaining_bytes() {
1007        let quota = QuotaInfo {
1008            disk_type: DiskType::Private,
1009            used_bytes: 60,
1010            quota_bytes: 100,
1011            percent_used: 60.0,
1012        };
1013        assert_eq!(quota.remaining_bytes(), 40);
1014    }
1015
1016    #[test]
1017    fn quota_is_exceeded() {
1018        let over_quota = QuotaInfo {
1019            disk_type: DiskType::Private,
1020            used_bytes: 110,
1021            quota_bytes: 100,
1022            percent_used: 110.0,
1023        };
1024        assert!(over_quota.is_exceeded());
1025
1026        let under_quota = QuotaInfo {
1027            disk_type: DiskType::Private,
1028            used_bytes: 50,
1029            quota_bytes: 100,
1030            percent_used: 50.0,
1031        };
1032        assert!(!under_quota.is_exceeded());
1033    }
1034
1035    #[test]
1036    fn quota_warning_threshold() {
1037        let warning = QuotaInfo {
1038            disk_type: DiskType::Shared,
1039            used_bytes: 92,
1040            quota_bytes: 100,
1041            percent_used: 92.0,
1042        };
1043        assert!(warning.is_warning());
1044
1045        let safe = QuotaInfo {
1046            disk_type: DiskType::Shared,
1047            used_bytes: 85,
1048            quota_bytes: 100,
1049            percent_used: 85.0,
1050        };
1051        assert!(!safe.is_warning());
1052    }
1053
1054    #[test]
1055    fn chunk_progress_percent() {
1056        let progress = ChunkProgress {
1057            chunk_index: 5,
1058            total_chunks: 10,
1059            bytes_transferred: 512 * 1024,
1060            chunk_size: 1024 * 1024,
1061            chunk_checksum: Some("abc123".to_string()),
1062        };
1063        assert_eq!(progress.chunk_percent(), 50);
1064        assert!(!progress.is_last());
1065    }
1066
1067    #[test]
1068    fn chunk_progress_is_last() {
1069        let last_chunk = ChunkProgress {
1070            chunk_index: 9,
1071            total_chunks: 10,
1072            bytes_transferred: 1024 * 1024,
1073            chunk_size: 1024 * 1024,
1074            chunk_checksum: None,
1075        };
1076        assert!(last_chunk.is_last());
1077    }
1078
1079    #[test]
1080    fn chunk_progress_zero_size() {
1081        let zero_chunk = ChunkProgress {
1082            chunk_index: 0,
1083            total_chunks: 1,
1084            bytes_transferred: 0,
1085            chunk_size: 0,
1086            chunk_checksum: None,
1087        };
1088        assert_eq!(zero_chunk.chunk_percent(), 100);
1089    }
1090
1091    #[test]
1092    fn resume_capability_can_resume() {
1093        assert!(!ResumeCapability::None.can_resume());
1094        assert!(ResumeCapability::Partial.can_resume());
1095        assert!(ResumeCapability::Full.can_resume());
1096    }
1097
1098    #[test]
1099    fn resume_capability_descriptions() {
1100        assert!(ResumeCapability::None.description().contains("Cannot"));
1101        assert!(ResumeCapability::Partial.description().contains("some"));
1102        assert!(ResumeCapability::Full.description().contains("verified"));
1103    }
1104
1105    #[test]
1106    fn transfer_state_progress() {
1107        let state = TransferState {
1108            id: "transfer-1".to_string(),
1109            file_path: "/data/file.bin".to_string(),
1110            total_bytes: 10 * 1024 * 1024,
1111            bytes_transferred: 3 * 1024 * 1024,
1112            chunks_completed: 3,
1113            total_chunks: 10,
1114            checksum_so_far: "abc123".to_string(),
1115            started_at: 1000,
1116            last_updated: 2000,
1117            resume_capability: ResumeCapability::Full,
1118            is_upload: true,
1119        };
1120        assert_eq!(state.percent_complete(), 30);
1121        assert_eq!(state.chunks_remaining(), 7);
1122        assert_eq!(state.bytes_remaining(), 7 * 1024 * 1024);
1123        assert!(!state.is_complete());
1124    }
1125
1126    #[test]
1127    fn transfer_state_complete() {
1128        let complete_state = TransferState {
1129            id: "transfer-2".to_string(),
1130            file_path: "/data/done.bin".to_string(),
1131            total_bytes: 5 * 1024 * 1024,
1132            bytes_transferred: 5 * 1024 * 1024,
1133            chunks_completed: 5,
1134            total_chunks: 5,
1135            checksum_so_far: "final_hash".to_string(),
1136            started_at: 1000,
1137            last_updated: 3000,
1138            resume_capability: ResumeCapability::None,
1139            is_upload: false,
1140        };
1141        assert_eq!(complete_state.percent_complete(), 100);
1142        assert_eq!(complete_state.chunks_remaining(), 0);
1143        assert!(complete_state.is_complete());
1144    }
1145
1146    #[test]
1147    fn transfer_error_messages() {
1148        let not_found = TransferError::StateNotFound("xyz".to_string());
1149        assert!(not_found.message().contains("xyz"));
1150        assert!(!not_found.is_recoverable());
1151
1152        let network = TransferError::NetworkError("timeout".to_string());
1153        assert!(network.message().contains("timeout"));
1154        assert!(network.is_recoverable());
1155
1156        let chunk_fail = TransferError::ChunkVerificationFailed {
1157            chunk_index: 5,
1158            expected: "abc".to_string(),
1159            actual: "def".to_string(),
1160        };
1161        assert!(chunk_fail.message().contains("5"));
1162        assert!(chunk_fail.is_recoverable());
1163
1164        let quota = TransferError::QuotaExceeded {
1165            required_bytes: 1000,
1166            available_bytes: 500,
1167        };
1168        assert!(quota.message().contains("1000"));
1169        assert!(quota.message().contains("500"));
1170        assert!(!quota.is_recoverable());
1171    }
1172
1173    #[test]
1174    fn transfer_error_file_modified() {
1175        let modified = TransferError::FileModified {
1176            expected_checksum: "abc".to_string(),
1177            actual_checksum: "def".to_string(),
1178        };
1179        assert!(modified.message().contains("modified"));
1180        assert!(!modified.is_recoverable());
1181    }
1182
1183    #[test]
1184    fn transfer_error_cancelled() {
1185        let cancelled = TransferError::Cancelled;
1186        assert!(cancelled.message().contains("cancelled"));
1187        assert!(!cancelled.is_recoverable());
1188    }
1189
1190    // === Share Link Tests ===
1191
1192    #[test]
1193    fn share_link_is_expired() {
1194        let link = ShareLink {
1195            id: "link-1".to_string(),
1196            entity_id: "entity-1".to_string(),
1197            disk_type: DiskType::Public,
1198            file_path: "/public/doc.pdf".to_string(),
1199            file_name: "doc.pdf".to_string(),
1200            url: "https://example.com/s/abc123".to_string(),
1201            created_at: 1000,
1202            expires_at: Some(2000),
1203            password_protected: false,
1204            access_count: 0,
1205            max_accesses: None,
1206            active: true,
1207        };
1208
1209        assert!(!link.is_expired(1500)); // Before expiry
1210        assert!(link.is_expired(2000)); // At expiry
1211        assert!(link.is_expired(3000)); // After expiry
1212    }
1213
1214    #[test]
1215    fn share_link_no_expiry() {
1216        let link = ShareLink {
1217            id: "link-2".to_string(),
1218            entity_id: "entity-1".to_string(),
1219            disk_type: DiskType::Public,
1220            file_path: "/public/forever.txt".to_string(),
1221            file_name: "forever.txt".to_string(),
1222            url: "https://example.com/s/xyz".to_string(),
1223            created_at: 1000,
1224            expires_at: None,
1225            password_protected: false,
1226            access_count: 0,
1227            max_accesses: None,
1228            active: true,
1229        };
1230
1231        assert!(!link.is_expired(1_000_000_000)); // Never expires
1232    }
1233
1234    #[test]
1235    fn share_link_access_limit() {
1236        let link = ShareLink {
1237            id: "link-3".to_string(),
1238            entity_id: "entity-1".to_string(),
1239            disk_type: DiskType::Public,
1240            file_path: "/public/limited.pdf".to_string(),
1241            file_name: "limited.pdf".to_string(),
1242            url: "https://example.com/s/lim".to_string(),
1243            created_at: 1000,
1244            expires_at: None,
1245            password_protected: false,
1246            access_count: 5,
1247            max_accesses: Some(5),
1248            active: true,
1249        };
1250
1251        assert!(link.is_access_limit_reached());
1252        assert_eq!(link.remaining_accesses(), Some(0));
1253    }
1254
1255    #[test]
1256    fn share_link_has_accesses_remaining() {
1257        let link = ShareLink {
1258            id: "link-4".to_string(),
1259            entity_id: "entity-1".to_string(),
1260            disk_type: DiskType::Public,
1261            file_path: "/public/file.pdf".to_string(),
1262            file_name: "file.pdf".to_string(),
1263            url: "https://example.com/s/abc".to_string(),
1264            created_at: 1000,
1265            expires_at: None,
1266            password_protected: false,
1267            access_count: 3,
1268            max_accesses: Some(10),
1269            active: true,
1270        };
1271
1272        assert!(!link.is_access_limit_reached());
1273        assert_eq!(link.remaining_accesses(), Some(7));
1274    }
1275
1276    #[test]
1277    fn share_link_unlimited_accesses() {
1278        let link = ShareLink {
1279            id: "link-5".to_string(),
1280            entity_id: "entity-1".to_string(),
1281            disk_type: DiskType::Public,
1282            file_path: "/public/unlimited.txt".to_string(),
1283            file_name: "unlimited.txt".to_string(),
1284            url: "https://example.com/s/unl".to_string(),
1285            created_at: 1000,
1286            expires_at: None,
1287            password_protected: false,
1288            access_count: 1000,
1289            max_accesses: None,
1290            active: true,
1291        };
1292
1293        assert!(!link.is_access_limit_reached());
1294        assert_eq!(link.remaining_accesses(), None);
1295    }
1296
1297    #[test]
1298    fn share_link_is_usable() {
1299        let active_link = ShareLink {
1300            id: "link-6".to_string(),
1301            entity_id: "entity-1".to_string(),
1302            disk_type: DiskType::Public,
1303            file_path: "/public/active.pdf".to_string(),
1304            file_name: "active.pdf".to_string(),
1305            url: "https://example.com/s/act".to_string(),
1306            created_at: 1000,
1307            expires_at: Some(5000),
1308            password_protected: true,
1309            access_count: 2,
1310            max_accesses: Some(10),
1311            active: true,
1312        };
1313
1314        assert!(active_link.is_usable(3000)); // Within expiry, under limit, active
1315
1316        let inactive_link = ShareLink {
1317            active: false,
1318            ..active_link.clone()
1319        };
1320        assert!(!inactive_link.is_usable(3000)); // Inactive
1321
1322        let expired_link = ShareLink {
1323            expires_at: Some(2000),
1324            ..active_link.clone()
1325        };
1326        assert!(!expired_link.is_usable(3000)); // Expired
1327
1328        let maxed_link = ShareLink {
1329            access_count: 10,
1330            ..active_link
1331        };
1332        assert!(!maxed_link.is_usable(3000)); // Limit reached
1333    }
1334
1335    #[test]
1336    fn share_link_config_default() {
1337        let config = ShareLinkConfig::default();
1338        assert_eq!(config.expires_in_ms, None);
1339        assert_eq!(config.password, None);
1340        assert_eq!(config.max_accesses, None);
1341    }
1342
1343    #[test]
1344    fn share_link_config_expires_in_hours() {
1345        let config = ShareLinkConfig::expires_in_hours(24);
1346        assert_eq!(config.expires_in_ms, Some(24 * 60 * 60 * 1000));
1347        assert_eq!(config.password, None);
1348        assert_eq!(config.max_accesses, None);
1349    }
1350
1351    #[test]
1352    fn share_link_config_expires_in_days() {
1353        let config = ShareLinkConfig::expires_in_days(7);
1354        assert_eq!(config.expires_in_ms, Some(7 * 24 * 60 * 60 * 1000));
1355        assert_eq!(config.password, None);
1356        assert_eq!(config.max_accesses, None);
1357    }
1358
1359    #[test]
1360    fn share_link_config_builder_chain() {
1361        let config = ShareLinkConfig::expires_in_days(30)
1362            .with_password("secret123")
1363            .with_max_accesses(100);
1364
1365        assert_eq!(config.expires_in_ms, Some(30 * 24 * 60 * 60 * 1000));
1366        assert_eq!(config.password, Some("secret123".to_string()));
1367        assert_eq!(config.max_accesses, Some(100));
1368    }
1369
1370    #[test]
1371    fn share_link_access_result_granted() {
1372        let granted = ShareLinkAccessResult::Granted {
1373            file_path: "/public/doc.pdf".to_string(),
1374            file_name: "doc.pdf".to_string(),
1375            size_bytes: 1024 * 1024,
1376            mime_type: Some("application/pdf".to_string()),
1377            checksum: "abc123".to_string(),
1378        };
1379
1380        assert!(granted.is_granted());
1381        assert_eq!(granted.error_message(), None);
1382    }
1383
1384    #[test]
1385    fn share_link_access_result_errors() {
1386        assert!(!ShareLinkAccessResult::PasswordRequired.is_granted());
1387        assert!(
1388            ShareLinkAccessResult::PasswordRequired
1389                .error_message()
1390                .is_some()
1391        );
1392
1393        assert!(!ShareLinkAccessResult::IncorrectPassword.is_granted());
1394        assert!(
1395            ShareLinkAccessResult::IncorrectPassword
1396                .error_message()
1397                .unwrap()
1398                .contains("Incorrect")
1399        );
1400
1401        assert!(!ShareLinkAccessResult::Expired.is_granted());
1402        assert!(
1403            ShareLinkAccessResult::Expired
1404                .error_message()
1405                .unwrap()
1406                .contains("expired")
1407        );
1408
1409        assert!(!ShareLinkAccessResult::AccessLimitReached.is_granted());
1410        assert!(
1411            ShareLinkAccessResult::AccessLimitReached
1412                .error_message()
1413                .unwrap()
1414                .contains("limit")
1415        );
1416
1417        assert!(!ShareLinkAccessResult::Revoked.is_granted());
1418        assert!(
1419            ShareLinkAccessResult::Revoked
1420                .error_message()
1421                .unwrap()
1422                .contains("revoked")
1423        );
1424
1425        assert!(!ShareLinkAccessResult::NotFound.is_granted());
1426        assert!(
1427            ShareLinkAccessResult::NotFound
1428                .error_message()
1429                .unwrap()
1430                .contains("not found")
1431        );
1432    }
1433
1434    #[test]
1435    fn share_link_stats_construction() {
1436        let stats = ShareLinkStats {
1437            total_accesses: 150,
1438            successful_downloads: 120,
1439            failed_password_attempts: 5,
1440            last_accessed_at: Some(1234567890),
1441            unique_accessors: 45,
1442        };
1443
1444        assert_eq!(stats.total_accesses, 150);
1445        assert_eq!(stats.successful_downloads, 120);
1446        assert_eq!(stats.failed_password_attempts, 5);
1447        assert_eq!(stats.last_accessed_at, Some(1234567890));
1448        assert_eq!(stats.unique_accessors, 45);
1449    }
1450
1451    // === Staging Queue Tests ===
1452
1453    #[test]
1454    fn staged_upload_state_is_terminal() {
1455        assert!(!StagedUploadState::Pending.is_terminal());
1456        assert!(!StagedUploadState::Uploading.is_terminal());
1457        assert!(!StagedUploadState::Conflicted.is_terminal());
1458        assert!(StagedUploadState::Completed.is_terminal());
1459        assert!(StagedUploadState::Failed.is_terminal());
1460    }
1461
1462    #[test]
1463    fn staged_upload_state_requires_action() {
1464        assert!(!StagedUploadState::Pending.requires_action());
1465        assert!(!StagedUploadState::Uploading.requires_action());
1466        assert!(StagedUploadState::Conflicted.requires_action());
1467        assert!(!StagedUploadState::Completed.requires_action());
1468        assert!(StagedUploadState::Failed.requires_action());
1469    }
1470
1471    #[test]
1472    fn staged_upload_state_labels() {
1473        assert_eq!(StagedUploadState::Pending.label(), "Pending");
1474        assert_eq!(StagedUploadState::Uploading.label(), "Uploading");
1475        assert_eq!(StagedUploadState::Conflicted.label(), "Conflict");
1476        assert_eq!(StagedUploadState::Completed.label(), "Completed");
1477        assert_eq!(StagedUploadState::Failed.label(), "Failed");
1478    }
1479
1480    fn make_staged_upload(state: StagedUploadState, retry_count: u32) -> StagedUpload {
1481        StagedUpload {
1482            id: "staged-1".to_string(),
1483            entity_id: "entity-1".to_string(),
1484            disk_type: DiskType::Private,
1485            destination_path: "/docs/report.pdf".to_string(),
1486            local_path: "/tmp/report.pdf".to_string(),
1487            file_name: "report.pdf".to_string(),
1488            size_bytes: 1024 * 1024,
1489            mime_type: Some("application/pdf".to_string()),
1490            local_checksum: "abc123".to_string(),
1491            state,
1492            retry_count,
1493            max_retries: 3,
1494            error: None,
1495            staged_at: 1000,
1496            updated_at: 2000,
1497            conflict: None,
1498        }
1499    }
1500
1501    #[test]
1502    fn staged_upload_can_retry() {
1503        let pending = make_staged_upload(StagedUploadState::Pending, 0);
1504        assert!(!pending.can_retry()); // Not failed
1505
1506        let failed_can_retry = make_staged_upload(StagedUploadState::Failed, 1);
1507        assert!(failed_can_retry.can_retry()); // Failed but retries remaining
1508
1509        let failed_maxed = make_staged_upload(StagedUploadState::Failed, 3);
1510        assert!(!failed_maxed.can_retry()); // Retries exhausted
1511    }
1512
1513    #[test]
1514    fn staged_upload_retries_remaining() {
1515        let zero_retries = make_staged_upload(StagedUploadState::Pending, 0);
1516        assert_eq!(zero_retries.retries_remaining(), 3);
1517
1518        let one_retry = make_staged_upload(StagedUploadState::Failed, 1);
1519        assert_eq!(one_retry.retries_remaining(), 2);
1520
1521        let maxed_retries = make_staged_upload(StagedUploadState::Failed, 3);
1522        assert_eq!(maxed_retries.retries_remaining(), 0);
1523
1524        let over_retries = make_staged_upload(StagedUploadState::Failed, 5);
1525        assert_eq!(over_retries.retries_remaining(), 0);
1526    }
1527
1528    #[test]
1529    fn staged_upload_age() {
1530        let upload = make_staged_upload(StagedUploadState::Pending, 0);
1531        assert_eq!(upload.age_ms(5000), 4000);
1532        assert_eq!(upload.age_ms(1000), 0);
1533        assert_eq!(upload.age_ms(500), 0); // saturating_sub
1534    }
1535
1536    #[test]
1537    fn conflict_type_descriptions() {
1538        assert!(ConflictType::FileExists.description().contains("exists"));
1539        assert!(ConflictType::LocalModified.description().contains("local"));
1540        assert!(
1541            ConflictType::RemoteModified
1542                .description()
1543                .contains("destination")
1544        );
1545        assert!(ConflictType::BothModified.description().contains("Both"));
1546        assert!(
1547            ConflictType::PathTypeChanged
1548                .description()
1549                .contains("directory")
1550        );
1551        assert!(ConflictType::QuotaExceeded.description().contains("quota"));
1552    }
1553
1554    #[test]
1555    fn staging_conflict_can_auto_resolve() {
1556        let file_exists = StagingConflict {
1557            conflict_type: ConflictType::FileExists,
1558            staged_checksum: "abc".to_string(),
1559            local_checksum: None,
1560            remote_checksum: Some("def".to_string()),
1561            remote_size_bytes: Some(1024),
1562            detected_at: 1000,
1563        };
1564        assert!(file_exists.can_auto_resolve());
1565
1566        let quota_exceeded = StagingConflict {
1567            conflict_type: ConflictType::QuotaExceeded,
1568            staged_checksum: "abc".to_string(),
1569            local_checksum: None,
1570            remote_checksum: None,
1571            remote_size_bytes: None,
1572            detected_at: 1000,
1573        };
1574        assert!(!quota_exceeded.can_auto_resolve());
1575    }
1576
1577    #[test]
1578    fn conflict_resolution_labels() {
1579        assert_eq!(ConflictResolution::KeepLocal.label(), "Upload my version");
1580        assert_eq!(ConflictResolution::KeepRemote.label(), "Keep existing");
1581        assert_eq!(ConflictResolution::KeepBoth.label(), "Keep both");
1582        assert_eq!(ConflictResolution::Skip.label(), "Skip");
1583        assert_eq!(ConflictResolution::Retry.label(), "Retry");
1584    }
1585
1586    #[test]
1587    fn conflict_resolution_descriptions() {
1588        assert!(
1589            ConflictResolution::KeepLocal
1590                .description()
1591                .contains("Replace")
1592        );
1593        assert!(
1594            ConflictResolution::KeepRemote
1595                .description()
1596                .contains("Discard")
1597        );
1598        assert!(ConflictResolution::KeepBoth.description().contains("both"));
1599        assert!(ConflictResolution::Skip.description().contains("Remove"));
1600        assert!(ConflictResolution::Retry.description().contains("again"));
1601    }
1602
1603    #[test]
1604    fn staging_queue_status_has_action_required() {
1605        let no_action = StagingQueueStatus {
1606            total_files: 5,
1607            pending_files: 3,
1608            uploading_files: 2,
1609            conflicted_files: 0,
1610            failed_files: 0,
1611            completed_files: 0,
1612            total_bytes: 1000,
1613            bytes_uploaded: 500,
1614            is_syncing: true,
1615            network_available: true,
1616            last_sync_at: Some(1000),
1617            last_sync_error: None,
1618        };
1619        assert!(!no_action.has_action_required());
1620
1621        let has_conflict = StagingQueueStatus {
1622            conflicted_files: 1,
1623            ..no_action.clone()
1624        };
1625        assert!(has_conflict.has_action_required());
1626
1627        let has_failed = StagingQueueStatus {
1628            failed_files: 2,
1629            ..no_action
1630        };
1631        assert!(has_failed.has_action_required());
1632    }
1633
1634    #[test]
1635    fn staging_queue_status_is_empty() {
1636        let empty = StagingQueueStatus {
1637            total_files: 5,
1638            pending_files: 0,
1639            uploading_files: 0,
1640            conflicted_files: 0,
1641            failed_files: 0,
1642            completed_files: 5,
1643            total_bytes: 1000,
1644            bytes_uploaded: 1000,
1645            is_syncing: false,
1646            network_available: true,
1647            last_sync_at: Some(1000),
1648            last_sync_error: None,
1649        };
1650        assert!(empty.is_empty());
1651
1652        let has_pending = StagingQueueStatus {
1653            pending_files: 2,
1654            completed_files: 3,
1655            ..empty.clone()
1656        };
1657        assert!(!has_pending.is_empty());
1658
1659        let has_uploading = StagingQueueStatus {
1660            uploading_files: 1,
1661            completed_files: 4,
1662            ..empty.clone()
1663        };
1664        assert!(!has_uploading.is_empty());
1665
1666        let has_conflicted = StagingQueueStatus {
1667            conflicted_files: 1,
1668            completed_files: 4,
1669            ..empty
1670        };
1671        assert!(!has_conflicted.is_empty());
1672    }
1673
1674    #[test]
1675    fn staging_queue_status_all_completed() {
1676        let all_done = StagingQueueStatus {
1677            total_files: 5,
1678            pending_files: 0,
1679            uploading_files: 0,
1680            conflicted_files: 0,
1681            failed_files: 0,
1682            completed_files: 5,
1683            total_bytes: 1000,
1684            bytes_uploaded: 1000,
1685            is_syncing: false,
1686            network_available: true,
1687            last_sync_at: Some(1000),
1688            last_sync_error: None,
1689        };
1690        assert!(all_done.all_completed());
1691
1692        let partial = StagingQueueStatus {
1693            completed_files: 3,
1694            ..all_done.clone()
1695        };
1696        assert!(!partial.all_completed());
1697
1698        let empty_queue = StagingQueueStatus {
1699            total_files: 0,
1700            completed_files: 0,
1701            ..all_done
1702        };
1703        assert!(!empty_queue.all_completed());
1704    }
1705
1706    #[test]
1707    fn staging_queue_status_percent_complete() {
1708        let half_done = StagingQueueStatus {
1709            total_files: 4,
1710            pending_files: 2,
1711            uploading_files: 0,
1712            conflicted_files: 0,
1713            failed_files: 0,
1714            completed_files: 2,
1715            total_bytes: 1000,
1716            bytes_uploaded: 500,
1717            is_syncing: false,
1718            network_available: true,
1719            last_sync_at: None,
1720            last_sync_error: None,
1721        };
1722        assert_eq!(half_done.percent_complete(), 50);
1723
1724        let zero_bytes = StagingQueueStatus {
1725            total_bytes: 0,
1726            bytes_uploaded: 0,
1727            ..half_done.clone()
1728        };
1729        assert_eq!(zero_bytes.percent_complete(), 0); // has files but zero bytes
1730
1731        let empty_queue = StagingQueueStatus {
1732            total_files: 0,
1733            total_bytes: 0,
1734            bytes_uploaded: 0,
1735            pending_files: 0,
1736            completed_files: 0,
1737            ..half_done
1738        };
1739        assert_eq!(empty_queue.percent_complete(), 100); // empty = complete
1740    }
1741
1742    #[test]
1743    fn staging_queue_status_active_count() {
1744        let status = StagingQueueStatus {
1745            total_files: 10,
1746            pending_files: 5,
1747            uploading_files: 2,
1748            conflicted_files: 1,
1749            failed_files: 1,
1750            completed_files: 1,
1751            total_bytes: 5000,
1752            bytes_uploaded: 500,
1753            is_syncing: true,
1754            network_available: true,
1755            last_sync_at: Some(1000),
1756            last_sync_error: None,
1757        };
1758        assert_eq!(status.active_count(), 7); // pending + uploading
1759    }
1760
1761    #[test]
1762    fn staging_event_variants() {
1763        // Test that all event variants can be constructed
1764        let staged = StagingEvent::FileStaged {
1765            upload_id: "u1".to_string(),
1766            file_name: "file.txt".to_string(),
1767        };
1768        assert!(matches!(staged, StagingEvent::FileStaged { .. }));
1769
1770        let started = StagingEvent::UploadStarted {
1771            upload_id: "u1".to_string(),
1772        };
1773        assert!(matches!(started, StagingEvent::UploadStarted { .. }));
1774
1775        let progress = StagingEvent::UploadProgress {
1776            upload_id: "u1".to_string(),
1777            bytes_uploaded: 500,
1778            total_bytes: 1000,
1779        };
1780        assert!(matches!(progress, StagingEvent::UploadProgress { .. }));
1781
1782        let completed = StagingEvent::UploadCompleted {
1783            upload_id: "u1".to_string(),
1784            destination_path: "/docs/file.txt".to_string(),
1785        };
1786        assert!(matches!(completed, StagingEvent::UploadCompleted { .. }));
1787
1788        let conflict = StagingEvent::ConflictDetected {
1789            upload_id: "u1".to_string(),
1790            conflict_type: ConflictType::FileExists,
1791        };
1792        assert!(matches!(conflict, StagingEvent::ConflictDetected { .. }));
1793
1794        let failed = StagingEvent::UploadFailed {
1795            upload_id: "u1".to_string(),
1796            error: "network error".to_string(),
1797        };
1798        assert!(matches!(failed, StagingEvent::UploadFailed { .. }));
1799
1800        let cleared = StagingEvent::QueueCleared { files_removed: 5 };
1801        assert!(matches!(cleared, StagingEvent::QueueCleared { .. }));
1802
1803        let network = StagingEvent::NetworkStatusChanged { available: true };
1804        assert!(matches!(network, StagingEvent::NetworkStatusChanged { .. }));
1805
1806        let sync_start = StagingEvent::SyncStarted;
1807        assert!(matches!(sync_start, StagingEvent::SyncStarted));
1808
1809        let sync_done = StagingEvent::SyncCompleted {
1810            files_uploaded: 10,
1811            files_failed: 2,
1812        };
1813        assert!(matches!(sync_done, StagingEvent::SyncCompleted { .. }));
1814    }
1815}