Back up local files before overwriting during ConflictRemoteWins so data is never silently lost. Fix false-positive change detection by parsing timestamps before comparing (different formats like RFC3339 vs HTTP date were never equal as strings). Add zeroize crate to zero WebDAV credentials in memory on drop, preventing exposure in core dumps.
1194 lines
43 KiB
Rust
1194 lines
43 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;
|
|
// Compare remote vs base using parsed timestamps to avoid format mismatches
|
|
let remote_changed = r.size != b.size || !timestamps_equal(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 || !timestamps_equal(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
|
|
}
|
|
|
|
/// Compare two timestamps for equality by parsing both, tolerating format differences.
|
|
fn timestamps_equal(a: Option<&str>, b: Option<&str>) -> bool {
|
|
match (a, b) {
|
|
(None, None) => true,
|
|
(Some(a), Some(b)) => {
|
|
// Try string equality first (fast path)
|
|
if a == b { return true; }
|
|
// Parse both and compare as DateTime
|
|
match (parse_timestamp(a), parse_timestamp(b)) {
|
|
(Some(ta), Some(tb)) => ta == tb,
|
|
_ => false,
|
|
}
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// 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) => match serde_json::from_str(&content) {
|
|
Ok(queue) => queue,
|
|
Err(e) => {
|
|
eprintln!("Warning: corrupt sync queue, backing up and resetting: {}", e);
|
|
let backup = workspace_path.join(".syncqueue.json.bak");
|
|
let _ = std::fs::copy(&queue_path, &backup);
|
|
Self::default()
|
|
}
|
|
},
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to read sync queue: {}", e);
|
|
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 file is syncable: *.md files and metadata files at expected depths.
|
|
fn is_syncable(path: &str) -> bool {
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
let filename = parts.last().copied().unwrap_or(path);
|
|
// .metadata.json only at workspace root (depth 1)
|
|
if filename == ".metadata.json" {
|
|
return parts.len() == 1;
|
|
}
|
|
// .listdata.json only inside a list directory (depth 2)
|
|
if filename == ".listdata.json" {
|
|
return parts.len() == 2;
|
|
}
|
|
// .md files inside a list directory (depth 2)
|
|
if filename.ends_with(".md") {
|
|
return parts.len() == 2;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// 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 } => {
|
|
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);
|
|
|
|
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::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);
|
|
|
|
if let Some(parent) = path_parent(path) {
|
|
client.ensure_dir(parent).await?;
|
|
}
|
|
|
|
report(&format!(" ^ Conflict: uploading local version of {}", path));
|
|
client.put_file(path, data.clone()).await?;
|
|
|
|
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 } => {
|
|
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::ConflictRemoteWins { path } => {
|
|
let local_path = workspace_path.join(path.replace('/', std::path::MAIN_SEPARATOR_STR));
|
|
// Back up local version before overwriting with remote
|
|
if local_path.exists() {
|
|
let backup_path = local_path.with_extension("conflict-backup");
|
|
let _ = std::fs::copy(&local_path, &backup_path);
|
|
report(&format!(" ! Backed up local version to {}", backup_path.display()));
|
|
}
|
|
report(&format!(" v Conflict: downloading remote version of {}", path));
|
|
let data = client.get_file(path).await?;
|
|
let checksum = compute_checksum(&data);
|
|
|
|
if let Some(parent) = local_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
std::fs::write(&local_path, &data)?;
|
|
|
|
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() {
|
|
// .md files must be inside a list dir (depth 2)
|
|
assert!(is_syncable("My Tasks/Buy groceries.md"));
|
|
assert!(!is_syncable("file.md")); // root-level md not valid
|
|
// .listdata.json inside a list dir (depth 2)
|
|
assert!(is_syncable("My Tasks/.listdata.json"));
|
|
assert!(!is_syncable(".listdata.json")); // root-level not valid
|
|
// .metadata.json only at root (depth 1)
|
|
assert!(is_syncable(".metadata.json"));
|
|
assert!(!is_syncable("My Tasks/.metadata.json")); // nested not valid
|
|
// Non-syncable
|
|
assert!(!is_syncable(".syncstate.json"));
|
|
assert!(!is_syncable("random.txt"));
|
|
assert!(!is_syncable("image.png"));
|
|
assert!(!is_syncable("a/b/c/deep.md")); // too deep
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|