//! Conflict detection and resolution for sync. use super::DeviceSyncState; use crate::config::ConflictResolution; /// Detect if there's a conflict between local and server state. #[must_use] pub fn detect_conflict(state: &DeviceSyncState) -> Option { // If either side has no hash, no conflict possible let local_hash = state.local_hash.as_ref()?; let server_hash = state.server_hash.as_ref()?; // Same hash = no conflict if local_hash == server_hash { return None; } // Both have different hashes = conflict Some(ConflictInfo { path: state.path.clone(), local_hash: local_hash.clone(), server_hash: server_hash.clone(), local_mtime: state.local_mtime, server_mtime: state.server_mtime, }) } /// Information about a detected conflict. #[derive(Debug, Clone)] pub struct ConflictInfo { pub path: String, pub local_hash: String, pub server_hash: String, pub local_mtime: Option, pub server_mtime: Option, } /// Result of resolving a conflict. #[derive(Debug, Clone)] pub enum ConflictOutcome { /// Use the server version UseServer, /// Use the local version (upload it) UseLocal, /// Keep both versions (rename one) KeepBoth { new_local_path: String }, /// Requires manual intervention Manual, } /// Resolve a conflict based on the configured strategy. #[must_use] pub fn resolve_conflict( conflict: &ConflictInfo, resolution: ConflictResolution, ) -> ConflictOutcome { match resolution { ConflictResolution::ServerWins => ConflictOutcome::UseServer, ConflictResolution::ClientWins => ConflictOutcome::UseLocal, ConflictResolution::KeepBoth => { let new_path = generate_conflict_path(&conflict.path, &conflict.local_hash); ConflictOutcome::KeepBoth { new_local_path: new_path, } }, ConflictResolution::Manual => ConflictOutcome::Manual, } } /// Generate a new path for the conflicting local file. /// Format: filename.conflict-<`short_hash>.ext` fn generate_conflict_path(original_path: &str, local_hash: &str) -> String { let short_hash = &local_hash[..8.min(local_hash.len())]; if let Some((base, ext)) = original_path.rsplit_once('.') { format!("{base}.conflict-{short_hash}.{ext}") } else { format!("{original_path}.conflict-{short_hash}") } } /// Automatic conflict resolution based on modification times. /// Useful when `ConflictResolution` is set to a time-based strategy. #[must_use] pub const fn resolve_by_mtime(conflict: &ConflictInfo) -> ConflictOutcome { match (conflict.local_mtime, conflict.server_mtime) { (Some(local), Some(server)) => { if local > server { ConflictOutcome::UseLocal } else { ConflictOutcome::UseServer } }, (Some(_), None) => ConflictOutcome::UseLocal, (None, Some(_)) => ConflictOutcome::UseServer, (None, None) => ConflictOutcome::UseServer, // Default to server } } #[cfg(test)] mod tests { use super::*; use crate::sync::FileSyncStatus; #[test] fn test_generate_conflict_path() { assert_eq!( generate_conflict_path("/path/to/file.txt", "abc12345"), "/path/to/file.conflict-abc12345.txt" ); assert_eq!( generate_conflict_path("/path/to/file", "abc12345"), "/path/to/file.conflict-abc12345" ); } #[test] fn test_detect_conflict() { let state_no_conflict = DeviceSyncState { device_id: super::super::DeviceId::new(), path: "/test".to_string(), local_hash: Some("abc".to_string()), server_hash: Some("abc".to_string()), local_mtime: None, server_mtime: None, sync_status: FileSyncStatus::Synced, last_synced_at: None, conflict_info_json: None, }; assert!(detect_conflict(&state_no_conflict).is_none()); let state_conflict = DeviceSyncState { device_id: super::super::DeviceId::new(), path: "/test".to_string(), local_hash: Some("abc".to_string()), server_hash: Some("def".to_string()), local_mtime: None, server_mtime: None, sync_status: FileSyncStatus::Conflict, last_synced_at: None, conflict_info_json: None, }; assert!(detect_conflict(&state_conflict).is_some()); } }