use super::RemoteFileInfo;
use super::conflict::ConflictInfo;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum LocalChange {
Created {
path: String,
content_hash: String,
modified_at: i64,
},
Modified {
path: String,
content_hash: String,
modified_at: i64,
previous_hash: String,
},
Deleted {
path: String,
},
}
impl LocalChange {
pub fn path(&self) -> &str {
match self {
LocalChange::Created { path, .. } => path,
LocalChange::Modified { path, .. } => path,
LocalChange::Deleted { path } => path,
}
}
pub fn content_hash(&self) -> Option<&str> {
match self {
LocalChange::Created { content_hash, .. } => Some(content_hash),
LocalChange::Modified { content_hash, .. } => Some(content_hash),
LocalChange::Deleted { .. } => None,
}
}
pub fn modified_at(&self) -> Option<i64> {
match self {
LocalChange::Created { modified_at, .. } => Some(*modified_at),
LocalChange::Modified { modified_at, .. } => Some(*modified_at),
LocalChange::Deleted { .. } => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RemoteChange {
Created {
info: RemoteFileInfo,
},
Modified {
info: RemoteFileInfo,
previous_version: Option<String>,
},
Deleted {
path: String,
},
}
impl RemoteChange {
pub fn path(&self) -> &str {
match self {
RemoteChange::Created { info } => &info.path,
RemoteChange::Modified { info, .. } => &info.path,
RemoteChange::Deleted { path } => path,
}
}
pub fn modified_at(&self) -> Option<DateTime<Utc>> {
match self {
RemoteChange::Created { info } => Some(info.modified_at),
RemoteChange::Modified { info, .. } => Some(info.modified_at),
RemoteChange::Deleted { .. } => None,
}
}
pub fn content_hash(&self) -> Option<&str> {
match self {
RemoteChange::Created { info } => info.content_hash.as_deref(),
RemoteChange::Modified { info, .. } => info.content_hash.as_deref(),
RemoteChange::Deleted { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SyncDirection {
Upload,
Download,
}
#[derive(Debug, Clone)]
pub enum SyncAction {
Upload {
path: String,
},
Download {
path: String,
remote_info: RemoteFileInfo,
},
Delete {
path: String,
direction: SyncDirection,
},
Conflict {
info: ConflictInfo,
},
}
impl SyncAction {
pub fn path(&self) -> &str {
match self {
SyncAction::Upload { path } => path,
SyncAction::Download { path, .. } => path,
SyncAction::Delete { path, .. } => path,
SyncAction::Conflict { info } => &info.path,
}
}
pub fn is_upload(&self) -> bool {
matches!(self, SyncAction::Upload { .. })
}
pub fn is_download(&self) -> bool {
matches!(self, SyncAction::Download { .. })
}
pub fn is_conflict(&self) -> bool {
matches!(self, SyncAction::Conflict { .. })
}
}
pub fn detect_conflicts(
local_changes: &[LocalChange],
remote_changes: &[RemoteChange],
) -> Vec<ConflictInfo> {
let mut conflicts = Vec::new();
for local in local_changes {
if matches!(local, LocalChange::Deleted { .. }) {
continue;
}
for remote in remote_changes {
if matches!(remote, RemoteChange::Deleted { .. }) {
continue;
}
if local.path() == remote.path() {
conflicts.push(ConflictInfo {
path: local.path().to_string(),
local_modified_at: local.modified_at(),
remote_modified_at: remote.modified_at(),
local_hash: local.content_hash().map(String::from),
remote_hash: remote.content_hash().map(String::from),
});
}
}
}
conflicts
}
pub fn compute_sync_actions(
local_changes: &[LocalChange],
remote_changes: &[RemoteChange],
) -> Vec<SyncAction> {
let conflicts = detect_conflicts(local_changes, remote_changes);
let conflict_paths: std::collections::HashSet<String> =
conflicts.iter().map(|c| c.path.clone()).collect();
let mut actions = Vec::new();
for conflict in conflicts {
actions.push(SyncAction::Conflict { info: conflict });
}
for change in local_changes {
if conflict_paths.contains(change.path()) {
continue;
}
match change {
LocalChange::Created { path, .. } | LocalChange::Modified { path, .. } => {
actions.push(SyncAction::Upload { path: path.clone() });
}
LocalChange::Deleted { path } => {
actions.push(SyncAction::Delete {
path: path.clone(),
direction: SyncDirection::Upload, });
}
}
}
for change in remote_changes {
if conflict_paths.contains(change.path()) {
continue;
}
match change {
RemoteChange::Created { info } | RemoteChange::Modified { info, .. } => {
actions.push(SyncAction::Download {
path: info.path.clone(),
remote_info: info.clone(),
});
}
RemoteChange::Deleted { path } => {
actions.push(SyncAction::Delete {
path: path.clone(),
direction: SyncDirection::Download, });
}
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
fn make_remote_info(path: &str) -> RemoteFileInfo {
RemoteFileInfo {
path: path.to_string(),
size: 100,
modified_at: Utc::now(),
etag: None,
content_hash: Some("remote_hash".to_string()),
}
}
#[test]
fn test_detect_no_conflicts() {
let local = vec![LocalChange::Created {
path: "a.md".to_string(),
content_hash: "hash_a".to_string(),
modified_at: 1000,
}];
let remote = vec![RemoteChange::Created {
info: make_remote_info("b.md"),
}];
let conflicts = detect_conflicts(&local, &remote);
assert!(conflicts.is_empty());
}
#[test]
fn test_detect_conflict() {
let local = vec![LocalChange::Modified {
path: "shared.md".to_string(),
content_hash: "local_hash".to_string(),
modified_at: 2000,
previous_hash: "old_hash".to_string(),
}];
let remote = vec![RemoteChange::Modified {
info: make_remote_info("shared.md"),
previous_version: None,
}];
let conflicts = detect_conflicts(&local, &remote);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].path, "shared.md");
}
#[test]
fn test_compute_sync_actions() {
let local = vec![
LocalChange::Created {
path: "new_local.md".to_string(),
content_hash: "h1".to_string(),
modified_at: 1000,
},
LocalChange::Deleted {
path: "deleted_local.md".to_string(),
},
];
let remote = vec![RemoteChange::Created {
info: make_remote_info("new_remote.md"),
}];
let actions = compute_sync_actions(&local, &remote);
assert_eq!(actions.len(), 3);
let uploads: Vec<_> = actions.iter().filter(|a| a.is_upload()).collect();
let downloads: Vec<_> = actions.iter().filter(|a| a.is_download()).collect();
let deletes: Vec<_> = actions
.iter()
.filter(|a| matches!(a, SyncAction::Delete { .. }))
.collect();
assert_eq!(uploads.len(), 1);
assert_eq!(downloads.len(), 1);
assert_eq!(deletes.len(), 1);
}
#[test]
fn test_conflict_excludes_from_actions() {
let local = vec![LocalChange::Modified {
path: "conflict.md".to_string(),
content_hash: "local".to_string(),
modified_at: 2000,
previous_hash: "old".to_string(),
}];
let remote = vec![RemoteChange::Modified {
info: make_remote_info("conflict.md"),
previous_version: None,
}];
let actions = compute_sync_actions(&local, &remote);
assert_eq!(actions.len(), 1);
assert!(actions[0].is_conflict());
}
}