1108 lines
39 KiB
Rust
1108 lines
39 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Sha256, Digest};
|
|
use crate::error::{Error, Result};
|
|
use crate::webdav::WebDavClient;
|
|
|
|
// --- Sync State ---
|
|
|
|
/// Persisted sync state for a workspace, stored as `.syncstate.json`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct SyncState {
|
|
pub last_sync: Option<DateTime<Utc>>,
|
|
pub files: HashMap<String, SyncFileEntry>,
|
|
}
|
|
|
|
/// Entry tracking the last-synced state of a single file.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SyncFileEntry {
|
|
pub checksum: String,
|
|
pub modified_at: Option<String>,
|
|
pub size: u64,
|
|
}
|
|
|
|
// --- Sync Actions ---
|
|
|
|
/// An action to take during sync, computed from the three-way diff.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SyncAction {
|
|
Upload { path: String },
|
|
Download { path: String },
|
|
DeleteLocal { path: String },
|
|
DeleteRemote { path: String },
|
|
ConflictLocalWins { path: String },
|
|
ConflictRemoteWins { path: String },
|
|
}
|
|
|
|
impl SyncAction {
|
|
pub fn path(&self) -> &str {
|
|
match self {
|
|
SyncAction::Upload { path }
|
|
| SyncAction::Download { path }
|
|
| SyncAction::DeleteLocal { path }
|
|
| SyncAction::DeleteRemote { path }
|
|
| SyncAction::ConflictLocalWins { path }
|
|
| SyncAction::ConflictRemoteWins { path } => path,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Result summary of a sync operation.
|
|
#[derive(Debug, Default)]
|
|
pub struct SyncResult {
|
|
pub uploaded: u32,
|
|
pub downloaded: u32,
|
|
pub deleted_local: u32,
|
|
pub deleted_remote: u32,
|
|
pub conflicts: u32,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
/// Sync direction mode.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SyncMode {
|
|
Push,
|
|
Pull,
|
|
Full,
|
|
}
|
|
|
|
// --- Local / Remote file info for diffing ---
|
|
|
|
/// Snapshot of a local file's state.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LocalFileInfo {
|
|
pub path: String,
|
|
pub checksum: String,
|
|
pub modified_at: Option<String>,
|
|
pub size: u64,
|
|
}
|
|
|
|
/// Snapshot of a remote file's state (from PROPFIND).
|
|
#[derive(Debug, Clone)]
|
|
pub struct RemoteFileSnapshot {
|
|
pub path: String,
|
|
pub last_modified: Option<String>,
|
|
pub size: u64,
|
|
}
|
|
|
|
// --- Three-way diff ---
|
|
|
|
/// Compute sync actions by comparing local files, remote files, and the last-synced base state.
|
|
///
|
|
/// Three-way diff logic:
|
|
/// | Local vs Base | Remote vs Base | Action |
|
|
/// |---------------|----------------|---------------------------------------------|
|
|
/// | unchanged | unchanged | skip |
|
|
/// | added | absent | upload |
|
|
/// | absent | added | download |
|
|
/// | modified | unchanged | upload |
|
|
/// | unchanged | modified | download |
|
|
/// | deleted | unchanged | delete remote |
|
|
/// | unchanged | deleted | delete local |
|
|
/// | modified | modified | last-write-wins (compare timestamps) |
|
|
/// | deleted | modified | download (remote wins) |
|
|
/// | modified | deleted | upload (local wins) |
|
|
/// | added | added | last-write-wins |
|
|
pub fn compute_sync_actions(
|
|
local_files: &[LocalFileInfo],
|
|
remote_files: &[RemoteFileSnapshot],
|
|
sync_state: &SyncState,
|
|
) -> Vec<SyncAction> {
|
|
let local_map: HashMap<&str, &LocalFileInfo> = local_files.iter().map(|f| (f.path.as_str(), f)).collect();
|
|
let remote_map: HashMap<&str, &RemoteFileSnapshot> = remote_files.iter().map(|f| (f.path.as_str(), f)).collect();
|
|
|
|
let mut all_paths: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
|
for f in local_files { all_paths.insert(&f.path); }
|
|
for f in remote_files { all_paths.insert(&f.path); }
|
|
for p in sync_state.files.keys() { all_paths.insert(p); }
|
|
|
|
let mut actions = Vec::new();
|
|
|
|
for path in all_paths {
|
|
let local = local_map.get(path);
|
|
let remote = remote_map.get(path);
|
|
let base = sync_state.files.get(path);
|
|
|
|
match (local, remote, base) {
|
|
// Both present, base known: check for changes
|
|
(Some(l), Some(r), Some(b)) => {
|
|
let local_changed = l.checksum != b.checksum;
|
|
let remote_changed = r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref();
|
|
|
|
match (local_changed, remote_changed) {
|
|
(false, false) => {} // Skip, unchanged
|
|
(true, false) => actions.push(SyncAction::Upload { path: path.to_string() }),
|
|
(false, true) => actions.push(SyncAction::Download { path: path.to_string() }),
|
|
(true, true) => {
|
|
// Both modified: last-write-wins based on timestamps
|
|
if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) {
|
|
actions.push(SyncAction::ConflictLocalWins { path: path.to_string() });
|
|
} else {
|
|
actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local only, no base: added locally
|
|
(Some(_), None, None) => {
|
|
actions.push(SyncAction::Upload { path: path.to_string() });
|
|
}
|
|
|
|
// Remote only, no base: added remotely
|
|
(None, Some(_), None) => {
|
|
actions.push(SyncAction::Download { path: path.to_string() });
|
|
}
|
|
|
|
// Both present, no base (both added): last-write-wins
|
|
(Some(l), Some(r), None) => {
|
|
if local_wins(l.modified_at.as_deref(), r.last_modified.as_deref()) {
|
|
actions.push(SyncAction::ConflictLocalWins { path: path.to_string() });
|
|
} else {
|
|
actions.push(SyncAction::ConflictRemoteWins { path: path.to_string() });
|
|
}
|
|
}
|
|
|
|
// Local present, remote gone, base known: remote was deleted
|
|
(Some(_), None, Some(_)) => {
|
|
// modified locally + deleted remote -> upload (local wins)
|
|
actions.push(SyncAction::Upload { path: path.to_string() });
|
|
}
|
|
|
|
// Remote present, local gone, base known: local was deleted
|
|
(None, Some(_), Some(b)) => {
|
|
let remote_changed = remote.is_some_and(|r| r.size != b.size || r.last_modified.as_deref() != b.modified_at.as_deref());
|
|
if remote_changed {
|
|
// deleted locally + modified remotely -> download (remote wins)
|
|
actions.push(SyncAction::Download { path: path.to_string() });
|
|
} else {
|
|
// deleted locally, remote unchanged -> delete remote
|
|
actions.push(SyncAction::DeleteRemote { path: path.to_string() });
|
|
}
|
|
}
|
|
|
|
// Both gone, base known: both deleted, skip (clean up base)
|
|
(None, None, Some(_)) => {}
|
|
|
|
// Local gone, remote gone, no base: nothing to do
|
|
(None, None, None) => {}
|
|
|
|
}
|
|
}
|
|
|
|
// Sort actions for deterministic output
|
|
actions.sort_by(|a, b| a.path().cmp(b.path()));
|
|
actions
|
|
}
|
|
|
|
/// Determine if local wins based on timestamps. True means local wins.
|
|
fn local_wins(local_modified: Option<&str>, remote_modified: Option<&str>) -> bool {
|
|
// Try parsing both; if we can't parse, local wins by default
|
|
let local_ts = local_modified.and_then(parse_timestamp);
|
|
let remote_ts = remote_modified.and_then(parse_timestamp);
|
|
match (local_ts, remote_ts) {
|
|
(Some(l), Some(r)) => l >= r,
|
|
(Some(_), None) => true,
|
|
(None, Some(_)) => false,
|
|
(None, None) => true, // Default to local
|
|
}
|
|
}
|
|
|
|
/// Parse a timestamp string (ISO 8601 or HTTP date format).
|
|
fn parse_timestamp(s: &str) -> Option<DateTime<Utc>> {
|
|
// Try ISO 8601 / RFC 3339
|
|
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
|
|
return Some(dt.with_timezone(&Utc));
|
|
}
|
|
// Try RFC 2822
|
|
if let Ok(dt) = DateTime::parse_from_rfc2822(s) {
|
|
return Some(dt.with_timezone(&Utc));
|
|
}
|
|
// Try HTTP date format: "Mon, 01 Jan 2026 00:00:00 GMT"
|
|
// Strip the day-of-week prefix and GMT suffix, parse the core date
|
|
if s.ends_with("GMT") {
|
|
let trimmed = s.trim_end_matches("GMT").trim();
|
|
// After stripping "Mon, " prefix: "01 Jan 2026 00:00:00"
|
|
if let Some(comma_pos) = trimmed.find(", ") {
|
|
let date_part = &trimmed[comma_pos + 2..];
|
|
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(date_part, "%d %b %Y %H:%M:%S") {
|
|
return Some(dt.and_utc());
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// --- Offline Queue ---
|
|
|
|
/// Persisted offline operation queue, stored as `.syncqueue.json`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct OfflineQueue {
|
|
pub operations: Vec<QueuedOperation>,
|
|
}
|
|
|
|
/// A queued sync operation that failed to execute.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct QueuedOperation {
|
|
pub action_type: String,
|
|
pub path: String,
|
|
pub queued_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl OfflineQueue {
|
|
pub fn load(workspace_path: &Path) -> Self {
|
|
let queue_path = workspace_path.join(".syncqueue.json");
|
|
if !queue_path.exists() {
|
|
return Self::default();
|
|
}
|
|
match std::fs::read_to_string(&queue_path) {
|
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
|
Err(_) => Self::default(),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self, workspace_path: &Path) -> Result<()> {
|
|
let queue_path = workspace_path.join(".syncqueue.json");
|
|
if self.operations.is_empty() {
|
|
// Clean up empty queue file
|
|
if queue_path.exists() {
|
|
let _ = std::fs::remove_file(&queue_path);
|
|
}
|
|
return Ok(());
|
|
}
|
|
let content = serde_json::to_string_pretty(self)?;
|
|
std::fs::write(&queue_path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Merge queued operations with fresh actions, deduplicating by path.
|
|
/// Fresh actions take precedence over stale queued ones.
|
|
pub fn merge_with_actions(&self, fresh_actions: Vec<SyncAction>) -> Vec<SyncAction> {
|
|
let mut result_map: HashMap<String, SyncAction> = HashMap::new();
|
|
|
|
// Add queued operations first (lower priority)
|
|
for op in &self.operations {
|
|
if let Some(action) = queued_op_to_action(op) {
|
|
result_map.insert(op.path.clone(), action);
|
|
}
|
|
}
|
|
|
|
// Fresh actions override queued ones
|
|
for action in fresh_actions {
|
|
result_map.insert(action.path().to_string(), action);
|
|
}
|
|
|
|
let mut actions: Vec<SyncAction> = result_map.into_values().collect();
|
|
actions.sort_by(|a, b| a.path().cmp(b.path()));
|
|
actions
|
|
}
|
|
}
|
|
|
|
fn queued_op_to_action(op: &QueuedOperation) -> Option<SyncAction> {
|
|
let path = op.path.clone();
|
|
match op.action_type.as_str() {
|
|
"upload" => Some(SyncAction::Upload { path }),
|
|
"download" => Some(SyncAction::Download { path }),
|
|
"delete_local" => Some(SyncAction::DeleteLocal { path }),
|
|
"delete_remote" => Some(SyncAction::DeleteRemote { path }),
|
|
"conflict_local_wins" => Some(SyncAction::ConflictLocalWins { path }),
|
|
"conflict_remote_wins" => Some(SyncAction::ConflictRemoteWins { path }),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn action_to_queued_op(action: &SyncAction) -> QueuedOperation {
|
|
let (action_type, path) = match action {
|
|
SyncAction::Upload { path } => ("upload", path),
|
|
SyncAction::Download { path } => ("download", path),
|
|
SyncAction::DeleteLocal { path } => ("delete_local", path),
|
|
SyncAction::DeleteRemote { path } => ("delete_remote", path),
|
|
SyncAction::ConflictLocalWins { path } => ("conflict_local_wins", path),
|
|
SyncAction::ConflictRemoteWins { path } => ("conflict_remote_wins", path),
|
|
};
|
|
QueuedOperation {
|
|
action_type: action_type.to_string(),
|
|
path: path.clone(),
|
|
queued_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
// --- File Scanning ---
|
|
|
|
/// Compute SHA-256 checksum of file contents.
|
|
pub fn compute_checksum(data: &[u8]) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data);
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
/// Check if a filename is a syncable file (*.md, .listdata.json, .metadata.json).
|
|
fn is_syncable(path: &str) -> bool {
|
|
let filename = path.rsplit('/').next().unwrap_or(path);
|
|
filename.ends_with(".md")
|
|
|| filename == ".listdata.json"
|
|
|| filename == ".metadata.json"
|
|
}
|
|
|
|
/// Scan local workspace files and compute checksums.
|
|
pub fn scan_local_files(workspace_path: &Path) -> Result<Vec<LocalFileInfo>> {
|
|
let mut files = Vec::new();
|
|
scan_dir_recursive(workspace_path, workspace_path, &mut files)?;
|
|
Ok(files)
|
|
}
|
|
|
|
fn scan_dir_recursive(root: &Path, dir: &Path, files: &mut Vec<LocalFileInfo>) -> Result<()> {
|
|
let entries = std::fs::read_dir(dir)?;
|
|
for entry in entries {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
let relative = path.strip_prefix(root)
|
|
.map_err(|e| Error::Sync(e.to_string()))?
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
|
|
// Skip sync state/queue files
|
|
if relative == ".syncstate.json" || relative == ".syncqueue.json" {
|
|
continue;
|
|
}
|
|
|
|
if path.is_dir() {
|
|
scan_dir_recursive(root, &path, files)?;
|
|
} else if is_syncable(&relative) {
|
|
let data = std::fs::read(&path)?;
|
|
let metadata = std::fs::metadata(&path)?;
|
|
let modified = metadata.modified().ok()
|
|
.map(|t| {
|
|
let dt: DateTime<Utc> = t.into();
|
|
dt.to_rfc3339()
|
|
});
|
|
|
|
files.push(LocalFileInfo {
|
|
path: relative,
|
|
checksum: compute_checksum(&data),
|
|
modified_at: modified,
|
|
size: data.len() as u64,
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Convert PROPFIND results into RemoteFileSnapshot list, recursing into directories.
|
|
fn scan_remote_files<'a>(client: &'a WebDavClient, base_path: &'a str) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<RemoteFileSnapshot>>> + Send + 'a>> {
|
|
let base_path = base_path.to_string();
|
|
Box::pin(async move {
|
|
let mut result = Vec::new();
|
|
let entries = client.list_files(&base_path).await?;
|
|
|
|
for entry in entries {
|
|
let full_path = if base_path.is_empty() {
|
|
entry.path.clone()
|
|
} else {
|
|
format!("{}/{}", base_path.trim_end_matches('/'), entry.path)
|
|
};
|
|
|
|
if entry.is_dir {
|
|
let sub_entries = scan_remote_files(client, &full_path).await?;
|
|
result.extend(sub_entries);
|
|
} else if is_syncable(&full_path) {
|
|
result.push(RemoteFileSnapshot {
|
|
path: full_path,
|
|
last_modified: entry.last_modified,
|
|
size: entry.content_length,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
})
|
|
}
|
|
|
|
// --- Sync State I/O ---
|
|
|
|
impl SyncState {
|
|
pub fn load(workspace_path: &Path) -> Self {
|
|
let state_path = workspace_path.join(".syncstate.json");
|
|
if !state_path.exists() {
|
|
return Self::default();
|
|
}
|
|
match std::fs::read_to_string(&state_path) {
|
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
|
Err(_) => Self::default(),
|
|
}
|
|
}
|
|
|
|
pub fn save(&self, workspace_path: &Path) -> Result<()> {
|
|
let state_path = workspace_path.join(".syncstate.json");
|
|
let content = serde_json::to_string_pretty(self)?;
|
|
std::fs::write(&state_path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Update the sync state for a single file after a successful sync action.
|
|
pub fn record_file(&mut self, path: &str, checksum: &str, modified_at: Option<&str>, size: u64) {
|
|
self.files.insert(path.to_string(), SyncFileEntry {
|
|
checksum: checksum.to_string(),
|
|
modified_at: modified_at.map(|s| s.to_string()),
|
|
size,
|
|
});
|
|
}
|
|
|
|
/// Remove a file entry from sync state (after deletion).
|
|
pub fn remove_file(&mut self, path: &str) {
|
|
self.files.remove(path);
|
|
}
|
|
}
|
|
|
|
// --- Sync Executor ---
|
|
|
|
/// Callback type for sync progress reporting.
|
|
pub type ProgressCallback = Box<dyn Fn(&str) + Send + Sync>;
|
|
|
|
/// Execute a full sync between a local workspace and a remote WebDAV server.
|
|
pub async fn sync_workspace(
|
|
workspace_path: &Path,
|
|
webdav_url: &str,
|
|
username: &str,
|
|
password: &str,
|
|
mode: SyncMode,
|
|
on_progress: Option<ProgressCallback>,
|
|
) -> Result<SyncResult> {
|
|
let client = WebDavClient::new(webdav_url, username, password);
|
|
let mut sync_state = SyncState::load(workspace_path);
|
|
let queue = OfflineQueue::load(workspace_path);
|
|
let mut result = SyncResult::default();
|
|
|
|
let report = |msg: &str| {
|
|
if let Some(ref cb) = on_progress {
|
|
cb(msg);
|
|
}
|
|
};
|
|
|
|
// Ensure remote root exists
|
|
client.test_connection().await?;
|
|
|
|
// Scan local files
|
|
let local_files = scan_local_files(workspace_path)?;
|
|
|
|
// Scan remote files
|
|
let remote_files = match scan_remote_files(&client, "").await {
|
|
Ok(files) => files,
|
|
Err(e) => {
|
|
// Network error during scan: save what we can and return
|
|
result.errors.push(format!("Failed to scan remote: {}", e));
|
|
return Ok(result);
|
|
}
|
|
};
|
|
|
|
// Compute actions from three-way diff
|
|
let fresh_actions = compute_sync_actions(&local_files, &remote_files, &sync_state);
|
|
|
|
// Merge with offline queue
|
|
let all_actions = queue.merge_with_actions(fresh_actions);
|
|
|
|
// Filter by sync mode
|
|
let actions: Vec<SyncAction> = all_actions.into_iter().filter(|a| match mode {
|
|
SyncMode::Full => true,
|
|
SyncMode::Push => matches!(a, SyncAction::Upload { .. } | SyncAction::DeleteRemote { .. } | SyncAction::ConflictLocalWins { .. }),
|
|
SyncMode::Pull => matches!(a, SyncAction::Download { .. } | SyncAction::DeleteLocal { .. } | SyncAction::ConflictRemoteWins { .. }),
|
|
}).collect();
|
|
|
|
// Execute actions, collecting failures for the queue
|
|
let mut failed_actions = Vec::new();
|
|
|
|
for action in &actions {
|
|
match execute_action(&client, workspace_path, action, &mut sync_state, &report).await {
|
|
Ok(()) => {
|
|
match action {
|
|
SyncAction::Upload { .. } | SyncAction::ConflictLocalWins { .. } => result.uploaded += 1,
|
|
SyncAction::Download { .. } | SyncAction::ConflictRemoteWins { .. } => result.downloaded += 1,
|
|
SyncAction::DeleteLocal { .. } => result.deleted_local += 1,
|
|
SyncAction::DeleteRemote { .. } => result.deleted_remote += 1,
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let msg = format!("Failed {}: {}", action.path(), e);
|
|
report(&format!(" ! {}", msg));
|
|
result.errors.push(msg);
|
|
if matches!(action,
|
|
SyncAction::Upload { .. } | SyncAction::Download { .. }
|
|
| SyncAction::ConflictLocalWins { .. } | SyncAction::ConflictRemoteWins { .. }
|
|
) {
|
|
result.conflicts += 1;
|
|
}
|
|
failed_actions.push(action.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save queue with remaining failed actions
|
|
let new_queue = OfflineQueue {
|
|
operations: failed_actions.iter().map(action_to_queued_op).collect(),
|
|
};
|
|
new_queue.save(workspace_path)?;
|
|
|
|
// Update sync state timestamp
|
|
sync_state.last_sync = Some(Utc::now());
|
|
sync_state.save(workspace_path)?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// Execute a single sync action.
|
|
async fn execute_action(
|
|
client: &WebDavClient,
|
|
workspace_path: &Path,
|
|
action: &SyncAction,
|
|
sync_state: &mut SyncState,
|
|
report: &(dyn Fn(&str) + Send + Sync),
|
|
) -> Result<()> {
|
|
match action {
|
|
SyncAction::Upload { path } | SyncAction::ConflictLocalWins { path } => {
|
|
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
|
let data = std::fs::read(&local_path)?;
|
|
let checksum = compute_checksum(&data);
|
|
|
|
// Ensure remote parent directory exists
|
|
if let Some(parent) = path_parent(path) {
|
|
client.ensure_dir(parent).await?;
|
|
}
|
|
|
|
report(&format!(" ^ Uploading {}", path));
|
|
client.put_file(path, data.clone()).await?;
|
|
|
|
// Record in sync state using local file metadata
|
|
let modified = std::fs::metadata(&local_path).ok()
|
|
.and_then(|m| m.modified().ok())
|
|
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
|
|
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
|
}
|
|
|
|
SyncAction::Download { path } | SyncAction::ConflictRemoteWins { path } => {
|
|
report(&format!(" v Downloading {}", path));
|
|
let data = client.get_file(path).await?;
|
|
let checksum = compute_checksum(&data);
|
|
|
|
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
|
if let Some(parent) = local_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(&local_path, &data)?;
|
|
|
|
// Record in sync state
|
|
let modified = std::fs::metadata(&local_path).ok()
|
|
.and_then(|m| m.modified().ok())
|
|
.map(|t| { let dt: DateTime<Utc> = t.into(); dt.to_rfc3339() });
|
|
sync_state.record_file(path, &checksum, modified.as_deref(), data.len() as u64);
|
|
}
|
|
|
|
SyncAction::DeleteLocal { path } => {
|
|
report(&format!(" x Deleting local {}", path));
|
|
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
|
if local_path.exists() {
|
|
std::fs::remove_file(&local_path)?;
|
|
}
|
|
sync_state.remove_file(path);
|
|
}
|
|
|
|
SyncAction::DeleteRemote { path } => {
|
|
report(&format!(" x Deleting remote {}", path));
|
|
client.delete_file(path).await?;
|
|
sync_state.remove_file(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the parent path of a sync path (e.g., "My Tasks/file.md" -> "My Tasks").
|
|
fn path_parent(path: &str) -> Option<&str> {
|
|
path.rfind('/').map(|i| &path[..i])
|
|
}
|
|
|
|
/// Get sync status information for display.
|
|
pub fn get_sync_status(workspace_path: &Path) -> Result<SyncStatusInfo> {
|
|
let sync_state = SyncState::load(workspace_path);
|
|
let queue = OfflineQueue::load(workspace_path);
|
|
let local_files = scan_local_files(workspace_path)?;
|
|
|
|
// Count pending changes (files changed since last sync)
|
|
let mut pending_changes = 0u32;
|
|
for file in &local_files {
|
|
if let Some(base) = sync_state.files.get(&file.path) {
|
|
if file.checksum != base.checksum {
|
|
pending_changes += 1;
|
|
}
|
|
} else {
|
|
pending_changes += 1; // New file
|
|
}
|
|
}
|
|
|
|
// Count files in base that are now missing locally (deleted)
|
|
for path in sync_state.files.keys() {
|
|
if !local_files.iter().any(|f| f.path == *path) {
|
|
pending_changes += 1;
|
|
}
|
|
}
|
|
|
|
Ok(SyncStatusInfo {
|
|
last_sync: sync_state.last_sync,
|
|
tracked_files: sync_state.files.len() as u32,
|
|
pending_changes,
|
|
queued_operations: queue.operations.len() as u32,
|
|
})
|
|
}
|
|
|
|
/// Summary of sync status for display.
|
|
pub struct SyncStatusInfo {
|
|
pub last_sync: Option<DateTime<Utc>>,
|
|
pub tracked_files: u32,
|
|
pub pending_changes: u32,
|
|
pub queued_operations: u32,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
// --- compute_sync_actions tests ---
|
|
|
|
fn make_local(path: &str, checksum: &str) -> LocalFileInfo {
|
|
LocalFileInfo {
|
|
path: path.to_string(),
|
|
checksum: checksum.to_string(),
|
|
modified_at: Some("2026-01-15T12:00:00+00:00".to_string()),
|
|
size: 100,
|
|
}
|
|
}
|
|
|
|
fn make_remote(path: &str) -> RemoteFileSnapshot {
|
|
RemoteFileSnapshot {
|
|
path: path.to_string(),
|
|
last_modified: Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string()),
|
|
size: 100,
|
|
}
|
|
}
|
|
|
|
fn make_base(checksum: &str) -> SyncFileEntry {
|
|
SyncFileEntry {
|
|
checksum: checksum.to_string(),
|
|
modified_at: Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string()),
|
|
size: 100,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_unchanged_both_sides() {
|
|
let local = vec![make_local("file.md", "abc123")];
|
|
let remote = vec![make_remote("file.md")];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("abc123"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert!(actions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_added_remote_absent() {
|
|
let local = vec![make_local("new.md", "abc123")];
|
|
let remote = vec![];
|
|
let state = SyncState::default();
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Upload { path: "new.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_added_local_absent() {
|
|
let local = vec![];
|
|
let remote = vec![make_remote("new.md")];
|
|
let state = SyncState::default();
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Download { path: "new.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_modified_remote_unchanged() {
|
|
let local = vec![make_local("file.md", "new_checksum")];
|
|
let remote = vec![make_remote("file.md")];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("old_checksum"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_modified_local_unchanged() {
|
|
let local = vec![make_local("file.md", "same_checksum")];
|
|
let mut remote = make_remote("file.md");
|
|
remote.size = 200; // Changed size indicates modification
|
|
let remote = vec![remote];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("same_checksum"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Download { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_deleted_remote_unchanged() {
|
|
let local = vec![];
|
|
let remote = vec![make_remote("file.md")];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("abc123"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::DeleteRemote { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_deleted_local_unchanged() {
|
|
let local = vec![make_local("file.md", "abc123")];
|
|
let remote = vec![];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("abc123"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
// Local present, remote gone, base known -> upload (local wins)
|
|
assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_both_modified_local_newer() {
|
|
let mut local = make_local("file.md", "new_local");
|
|
local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string());
|
|
let mut remote = make_remote("file.md");
|
|
remote.last_modified = Some("Mon, 01 Mar 2026 00:00:00 GMT".to_string());
|
|
remote.size = 200;
|
|
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("old_base"));
|
|
|
|
let actions = compute_sync_actions(&[local], &[remote], &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_both_modified_remote_newer() {
|
|
let mut local = make_local("file.md", "new_local");
|
|
local.modified_at = Some("2026-01-01T00:00:00+00:00".to_string());
|
|
let mut remote = make_remote("file.md");
|
|
remote.last_modified = Some("Sun, 15 Mar 2026 12:00:00 GMT".to_string());
|
|
remote.size = 200;
|
|
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("old_base"));
|
|
|
|
let actions = compute_sync_actions(&[local], &[remote], &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::ConflictRemoteWins { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_deleted_local_modified_remote() {
|
|
let local = vec![];
|
|
let mut remote = make_remote("file.md");
|
|
remote.size = 200; // Modified
|
|
let remote = vec![remote];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("abc123"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Download { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_modified_local_deleted_remote() {
|
|
let local = vec![make_local("file.md", "new_checksum")];
|
|
let remote = vec![];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("old_checksum"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::Upload { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_both_added_local_newer() {
|
|
let mut local = make_local("file.md", "local_content");
|
|
local.modified_at = Some("2026-03-15T12:00:00+00:00".to_string());
|
|
let mut remote = make_remote("file.md");
|
|
remote.last_modified = Some("Mon, 01 Jan 2026 00:00:00 GMT".to_string());
|
|
|
|
let state = SyncState::default(); // No base entry
|
|
|
|
let actions = compute_sync_actions(&[local], &[remote], &state);
|
|
assert_eq!(actions.len(), 1);
|
|
assert_eq!(actions[0], SyncAction::ConflictLocalWins { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_both_deleted() {
|
|
let local = vec![];
|
|
let remote = vec![];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("file.md".to_string(), make_base("abc123"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert!(actions.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_files_mixed() {
|
|
let local = vec![
|
|
make_local("keep.md", "same"),
|
|
make_local("modified.md", "new"),
|
|
make_local("new_local.md", "brand_new"),
|
|
];
|
|
let remote = vec![
|
|
make_remote("keep.md"),
|
|
make_remote("modified.md"),
|
|
make_remote("new_remote.md"),
|
|
];
|
|
let mut state = SyncState::default();
|
|
state.files.insert("keep.md".to_string(), make_base("same"));
|
|
state.files.insert("modified.md".to_string(), make_base("old"));
|
|
|
|
let actions = compute_sync_actions(&local, &remote, &state);
|
|
assert_eq!(actions.len(), 3);
|
|
// modified.md: local modified, remote unchanged -> upload
|
|
assert!(actions.iter().any(|a| matches!(a, SyncAction::Upload { path } if path == "modified.md")));
|
|
// new_local.md: added locally -> upload
|
|
assert!(actions.iter().any(|a| matches!(a, SyncAction::Upload { path } if path == "new_local.md")));
|
|
// new_remote.md: added remotely -> download
|
|
assert!(actions.iter().any(|a| matches!(a, SyncAction::Download { path } if path == "new_remote.md")));
|
|
}
|
|
|
|
// --- Sync state persistence ---
|
|
|
|
#[test]
|
|
fn test_sync_state_save_load_roundtrip() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let mut state = SyncState::default();
|
|
state.last_sync = Some(Utc::now());
|
|
state.record_file("test.md", "abc123", Some("2026-01-01T00:00:00Z"), 42);
|
|
|
|
state.save(temp_dir.path()).unwrap();
|
|
let loaded = SyncState::load(temp_dir.path());
|
|
|
|
assert!(loaded.last_sync.is_some());
|
|
assert_eq!(loaded.files.len(), 1);
|
|
assert_eq!(loaded.files["test.md"].checksum, "abc123");
|
|
assert_eq!(loaded.files["test.md"].size, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sync_state_load_missing() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let state = SyncState::load(temp_dir.path());
|
|
assert!(state.last_sync.is_none());
|
|
assert!(state.files.is_empty());
|
|
}
|
|
|
|
// --- Offline queue ---
|
|
|
|
#[test]
|
|
fn test_queue_save_load_roundtrip() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let queue = OfflineQueue {
|
|
operations: vec![QueuedOperation {
|
|
action_type: "upload".to_string(),
|
|
path: "test.md".to_string(),
|
|
queued_at: Utc::now(),
|
|
}],
|
|
};
|
|
|
|
queue.save(temp_dir.path()).unwrap();
|
|
let loaded = OfflineQueue::load(temp_dir.path());
|
|
assert_eq!(loaded.operations.len(), 1);
|
|
assert_eq!(loaded.operations[0].path, "test.md");
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_empty_cleans_up_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let queue_path = temp_dir.path().join(".syncqueue.json");
|
|
|
|
// Write a non-empty queue first
|
|
let queue = OfflineQueue {
|
|
operations: vec![QueuedOperation {
|
|
action_type: "upload".to_string(),
|
|
path: "test.md".to_string(),
|
|
queued_at: Utc::now(),
|
|
}],
|
|
};
|
|
queue.save(temp_dir.path()).unwrap();
|
|
assert!(queue_path.exists());
|
|
|
|
// Save empty queue should remove the file
|
|
let empty_queue = OfflineQueue::default();
|
|
empty_queue.save(temp_dir.path()).unwrap();
|
|
assert!(!queue_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_merge_fresh_overrides_stale() {
|
|
let queue = OfflineQueue {
|
|
operations: vec![QueuedOperation {
|
|
action_type: "upload".to_string(),
|
|
path: "file.md".to_string(),
|
|
queued_at: Utc::now(),
|
|
}],
|
|
};
|
|
|
|
let fresh = vec![SyncAction::Download { path: "file.md".to_string() }];
|
|
let merged = queue.merge_with_actions(fresh);
|
|
|
|
assert_eq!(merged.len(), 1);
|
|
assert_eq!(merged[0], SyncAction::Download { path: "file.md".to_string() });
|
|
}
|
|
|
|
#[test]
|
|
fn test_queue_merge_combines_different_paths() {
|
|
let queue = OfflineQueue {
|
|
operations: vec![QueuedOperation {
|
|
action_type: "upload".to_string(),
|
|
path: "a.md".to_string(),
|
|
queued_at: Utc::now(),
|
|
}],
|
|
};
|
|
|
|
let fresh = vec![SyncAction::Download { path: "b.md".to_string() }];
|
|
let merged = queue.merge_with_actions(fresh);
|
|
|
|
assert_eq!(merged.len(), 2);
|
|
}
|
|
|
|
// --- Checksum ---
|
|
|
|
#[test]
|
|
fn test_compute_checksum_deterministic() {
|
|
let data = b"hello world";
|
|
let c1 = compute_checksum(data);
|
|
let c2 = compute_checksum(data);
|
|
assert_eq!(c1, c2);
|
|
assert!(!c1.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_compute_checksum_different_data() {
|
|
let c1 = compute_checksum(b"hello");
|
|
let c2 = compute_checksum(b"world");
|
|
assert_ne!(c1, c2);
|
|
}
|
|
|
|
// --- File scanning ---
|
|
|
|
#[test]
|
|
fn test_is_syncable() {
|
|
assert!(is_syncable("file.md"));
|
|
assert!(is_syncable("My Tasks/Buy groceries.md"));
|
|
assert!(is_syncable(".listdata.json"));
|
|
assert!(is_syncable("My Tasks/.listdata.json"));
|
|
assert!(is_syncable(".metadata.json"));
|
|
assert!(!is_syncable(".syncstate.json"));
|
|
assert!(!is_syncable("random.txt"));
|
|
assert!(!is_syncable("image.png"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scan_local_files() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let root = temp_dir.path();
|
|
|
|
// Create a workspace-like structure
|
|
std::fs::write(root.join(".metadata.json"), "{}").unwrap();
|
|
std::fs::create_dir_all(root.join("My Tasks")).unwrap();
|
|
std::fs::write(root.join("My Tasks").join(".listdata.json"), "{}").unwrap();
|
|
std::fs::write(root.join("My Tasks").join("task1.md"), "# Task 1").unwrap();
|
|
std::fs::write(root.join("My Tasks").join("task2.md"), "# Task 2").unwrap();
|
|
// Non-syncable file should be skipped
|
|
std::fs::write(root.join("My Tasks").join("notes.txt"), "notes").unwrap();
|
|
// Sync state file should be skipped
|
|
std::fs::write(root.join(".syncstate.json"), "{}").unwrap();
|
|
|
|
let files = scan_local_files(root).unwrap();
|
|
assert_eq!(files.len(), 4); // .metadata.json, .listdata.json, task1.md, task2.md
|
|
assert!(files.iter().any(|f| f.path == ".metadata.json"));
|
|
assert!(files.iter().any(|f| f.path == "My Tasks/.listdata.json"));
|
|
assert!(files.iter().any(|f| f.path == "My Tasks/task1.md"));
|
|
assert!(files.iter().any(|f| f.path == "My Tasks/task2.md"));
|
|
assert!(!files.iter().any(|f| f.path.contains("notes.txt")));
|
|
assert!(!files.iter().any(|f| f.path.contains(".syncstate.json")));
|
|
}
|
|
|
|
// --- Sync status ---
|
|
|
|
#[test]
|
|
fn test_get_sync_status_no_state() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let root = temp_dir.path();
|
|
std::fs::write(root.join(".metadata.json"), "{}").unwrap();
|
|
|
|
let status = get_sync_status(root).unwrap();
|
|
assert!(status.last_sync.is_none());
|
|
assert_eq!(status.tracked_files, 0);
|
|
assert_eq!(status.pending_changes, 1); // .metadata.json is new
|
|
assert_eq!(status.queued_operations, 0);
|
|
}
|
|
|
|
// --- Timestamp parsing ---
|
|
|
|
#[test]
|
|
fn test_parse_timestamp_rfc3339() {
|
|
let result = parse_timestamp("2026-01-15T12:00:00+00:00");
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_timestamp_http_date() {
|
|
let result = parse_timestamp("Mon, 01 Jan 2026 00:00:00 GMT");
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_timestamp_invalid() {
|
|
let result = parse_timestamp("not a date");
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_wins_local_newer() {
|
|
assert!(local_wins(
|
|
Some("2026-03-15T12:00:00+00:00"),
|
|
Some("Mon, 01 Jan 2026 00:00:00 GMT"),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_wins_remote_newer() {
|
|
assert!(!local_wins(
|
|
Some("2026-01-01T00:00:00+00:00"),
|
|
Some("Sun, 15 Mar 2026 12:00:00 GMT"),
|
|
));
|
|
}
|
|
|
|
// --- path_parent ---
|
|
|
|
#[test]
|
|
fn test_path_parent() {
|
|
assert_eq!(path_parent("My Tasks/file.md"), Some("My Tasks"));
|
|
assert_eq!(path_parent("file.md"), None);
|
|
assert_eq!(path_parent("a/b/c.md"), Some("a/b"));
|
|
}
|
|
}
|