Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9e0ff5ea33a5cf697473423e88f167ce6a6a6964
147 lines
4.3 KiB
Rust
147 lines
4.3 KiB
Rust
//! 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<ConflictInfo> {
|
|
// 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<i64>,
|
|
pub server_mtime: Option<i64>,
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|