Skip to main content

oxios_markdown/
sync.rs

1//! Sync engine for client-server file synchronization.
2//!
3//! Ported from files.md (`server/sync/sync.go`) by Artem Zakirullin.
4//! Implements mtime-based 3-way merge synchronization with conflict resolution.
5
6use std::collections::HashMap;
7
8use crate::fs::VirtualFs;
9use crate::fslog::FsLog;
10use crate::merge::merge;
11use crate::types::{
12    DIR_MEDIA, DIR_USER_ROOT, FsError, MD_EXT, STATUS_MERGED, STATUS_NOT_MODIFIED, STATUS_OK,
13    STATUS_UPDATED_ON_SERVER, SyncError, SyncFile, SyncRequest, SyncResponse,
14};
15
16/// Configuration for the sync engine.
17#[derive(Debug, Clone)]
18pub struct SyncConfig {
19    /// Knowledge base config filename (usually "config.json").
20    pub config_filename: String,
21    /// Storage directory prefix for user data.
22    pub storage_dir: String,
23}
24
25impl Default for SyncConfig {
26    fn default() -> Self {
27        Self {
28            config_filename: "config.json".to_string(),
29            storage_dir: String::new(),
30        }
31    }
32}
33
34/// Sync engine: handles batch and single-file synchronization.
35pub struct SyncEngine {
36    fs: VirtualFs,
37    config: SyncConfig,
38    fslog: FsLog,
39}
40
41impl SyncEngine {
42    /// Create a new sync engine.
43    pub fn new(fs: VirtualFs, config: SyncConfig, fslog: FsLog) -> Self {
44        Self { fs, config, fslog }
45    }
46
47    /// Get a reference to the underlying filesystem.
48    pub fn fs(&self) -> &VirtualFs {
49        &self.fs
50    }
51
52    /// Perform batch file synchronization.
53    ///
54    /// Algorithm:
55    /// 1. Apply client deletions
56    /// 2. Save client modifications (merge on conflict)
57    /// 3. Send server files that the client doesn't have
58    /// 4. Include rename log entries
59    pub fn sync_filenames(
60        &self,
61        user_id: i64,
62        request: SyncRequest,
63    ) -> Result<SyncResponse, SyncError> {
64        let mut files_to_send: Vec<SyncFile> = Vec::new();
65        let mut dir_timestamps: HashMap<String, i64> = HashMap::new();
66
67        let mut last_sync: i64 = 0;
68        for ts in request.timestamps.values() {
69            if *ts > last_sync {
70                last_sync = *ts;
71            }
72        }
73
74        let renames = if last_sync != 0 {
75            let user_prefix = format!("{}/{}/", self.config.storage_dir, user_id);
76            self.fslog.renames_since(&user_prefix, last_sync)
77        } else {
78            HashMap::new()
79        };
80
81        // Process deletions
82        for path in &request.deleted {
83            let rel = path.trim_start_matches('/');
84            let _ = self.fs.del(DIR_USER_ROOT, rel);
85        }
86
87        // Process modifications
88        for client_file in &request.modified {
89            let rel = client_file.path.trim_start_matches('/');
90            let server_mtime = self.fs.mtime(DIR_USER_ROOT, rel).ok();
91            let mut content = client_file.content.clone();
92
93            // If server file is newer, merge with client content
94            if let Some(server_modified) = server_mtime
95                && server_modified > client_file.last_modified
96                && let Ok(server_content) = self.fs.read(DIR_USER_ROOT, rel)
97            {
98                content = merge(&server_content, &client_file.content);
99            }
100
101            // Skip config file
102            if client_file.path == self.config.config_filename {
103                continue;
104            }
105
106            match self.fs.write(DIR_USER_ROOT, rel, &content) {
107                Err(FsError::QuotaExceeded) => return Err(SyncError::QuotaExceeded),
108                Err(e) => tracing::warn!(path = %rel, error = %e, "Sync write failed"),
109                Ok(_) => {}
110            }
111        }
112
113        // Build response with files the client needs
114        let server_timestamps = self
115            .fs
116            .mtimes(DIR_USER_ROOT, &[MD_EXT, ".txt"])
117            .map_err(|e| SyncError::Storage(e.to_string()))?;
118
119        for (path, server_time) in &server_timestamps {
120            let parts: Vec<&str> = path.split('/').collect();
121            let dir = if parts.len() == 1 { "." } else { parts[0] };
122            let client_dir_time = request.timestamps.get(dir).copied().unwrap_or(0);
123
124            if server_time > &client_dir_time
125                && let Ok(content) = self.fs.read(DIR_USER_ROOT, path)
126            {
127                files_to_send.push(SyncFile {
128                    status: STATUS_OK.to_string(),
129                    path: path.clone(),
130                    last_modified: *server_time,
131                    client_last_modified: 0,
132                    client_last_synced: 0,
133                    content,
134                });
135            }
136
137            let existing = dir_timestamps.get(dir).copied().unwrap_or(0);
138            if *server_time > existing {
139                dir_timestamps.insert(dir.to_string(), *server_time);
140            }
141        }
142
143        Ok(SyncResponse {
144            status: STATUS_OK.to_string(),
145            files: files_to_send,
146            timestamps: dir_timestamps,
147            renames,
148        })
149    }
150
151    /// Synchronize a single file.
152    pub fn sync_file(
153        &self,
154        _user_id: i64,
155        client_file: SyncFile,
156    ) -> Result<SyncResponse, SyncError> {
157        let rel = client_file.path.trim_start_matches('/');
158        let server_content = self.fs.read(DIR_USER_ROOT, rel).ok();
159        let server_mtime = self.fs.mtime(DIR_USER_ROOT, rel).ok().unwrap_or(0);
160
161        // Already up to date?
162        if let Some(ref content) = server_content
163            && *content == client_file.content
164        {
165            return Ok(SyncResponse {
166                status: STATUS_NOT_MODIFIED.to_string(),
167                ..SyncResponse::default()
168            });
169        }
170
171        let mut status = STATUS_OK.to_string();
172        let mut content = client_file.content.clone();
173        let mut should_update = true;
174
175        if let Some(ref server_content) = server_content {
176            let not_modified_on_client = client_file.client_last_synced != 0
177                && client_file.client_last_modified == client_file.client_last_synced;
178            let modified_on_server = server_mtime > client_file.last_modified;
179
180            if modified_on_server && not_modified_on_client {
181                content = server_content.clone();
182                should_update = false;
183            } else if modified_on_server {
184                content = merge(server_content, &client_file.content);
185                status = STATUS_MERGED.to_string();
186            }
187        }
188
189        if should_update {
190            self.fs
191                .write(DIR_USER_ROOT, rel, &content)
192                .map_err(|e| SyncError::Storage(e.to_string()))?;
193            return Ok(SyncResponse {
194                status: STATUS_UPDATED_ON_SERVER.to_string(),
195                ..SyncResponse::default()
196            });
197        }
198
199        let final_mtime = self.fs.mtime(DIR_USER_ROOT, rel).unwrap_or(0);
200        Ok(SyncResponse {
201            status: status.clone(),
202            files: vec![SyncFile {
203                status,
204                path: client_file.path,
205                last_modified: final_mtime,
206                client_last_modified: client_file.last_modified,
207                client_last_synced: client_file.client_last_synced,
208                content,
209            }],
210            ..SyncResponse::default()
211        })
212    }
213}
214
215// ── Media types and methods ───────────────────────────────
216
217/// Media file entry.
218#[derive(Debug, Clone)]
219pub struct MediaEntry {
220    /// Filename within the media directory.
221    pub filename: String,
222    /// Last modified timestamp (millis since epoch).
223    pub last_modified: i64,
224}
225
226/// Media sync response.
227#[derive(Debug, Clone)]
228pub struct MediaSyncResponse {
229    /// Media files modified since the given timestamp.
230    pub files: Vec<MediaEntry>,
231    /// Latest modification timestamp among returned files.
232    pub timestamp: i64,
233}
234
235impl SyncEngine {
236    /// List media files modified since a given timestamp.
237    ///
238    /// Returns all media files whose mtime > `since_timestamp`,
239    /// along with the latest timestamp for incremental sync.
240    pub fn sync_media_filenames(
241        &self,
242        since_timestamp: i64,
243    ) -> Result<MediaSyncResponse, SyncError> {
244        let mtimes = self
245            .fs
246            .mtimes(DIR_MEDIA, &[])
247            .map_err(|e| SyncError::Storage(e.to_string()))?;
248
249        let mut files: Vec<MediaEntry> = Vec::new();
250        let mut latest_timestamp: i64 = 0;
251
252        for (filename, mod_time) in &mtimes {
253            if *mod_time <= since_timestamp {
254                continue;
255            }
256            if *mod_time > latest_timestamp {
257                latest_timestamp = *mod_time;
258            }
259            files.push(MediaEntry {
260                filename: filename.clone(),
261                last_modified: *mod_time,
262            });
263        }
264
265        Ok(MediaSyncResponse {
266            files,
267            timestamp: latest_timestamp,
268        })
269    }
270
271    /// Upload a media file (from raw bytes).
272    pub fn sync_media_upload(&self, filename: &str, data: &[u8]) -> Result<(), SyncError> {
273        let exists = self
274            .fs
275            .exists(DIR_MEDIA, filename)
276            .map_err(|e| SyncError::Storage(e.to_string()))?;
277
278        if exists {
279            // File already exists, skip
280            return Ok(());
281        }
282
283        self.fs
284            .write_bytes(DIR_MEDIA, filename, data)
285            .map_err(|e| match e {
286                FsError::QuotaExceeded => SyncError::QuotaExceeded,
287                other => SyncError::Storage(other.to_string()),
288            })?;
289
290        Ok(())
291    }
292
293    /// Read a media file as raw bytes.
294    pub fn sync_media_read(&self, filename: &str) -> Result<Vec<u8>, SyncError> {
295        self.fs
296            .read_bytes(DIR_MEDIA, filename)
297            .map_err(|e| SyncError::Storage(e.to_string()))
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use tempfile::TempDir;
305
306    fn test_engine() -> (SyncEngine, TempDir) {
307        let dir = TempDir::new().unwrap();
308        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
309        let fslog = FsLog::new(dir.path().join("fslog"));
310        let config = SyncConfig {
311            config_filename: "config.json".into(),
312            storage_dir: dir.path().to_string_lossy().to_string(),
313        };
314        (SyncEngine::new(fs, config, fslog), dir)
315    }
316
317    #[test]
318    fn test_sync_file_new() {
319        let (engine, _t) = test_engine();
320        let resp = engine
321            .sync_file(
322                1,
323                SyncFile {
324                    status: String::new(),
325                    path: "test.md".into(),
326                    last_modified: 0,
327                    client_last_modified: 0,
328                    client_last_synced: 0,
329                    content: "hello".into(),
330                },
331            )
332            .unwrap();
333        assert_eq!(resp.status, STATUS_UPDATED_ON_SERVER);
334    }
335
336    #[test]
337    fn test_sync_file_not_modified() {
338        let (engine, _t) = test_engine();
339        engine.fs.write(DIR_USER_ROOT, "test.md", "hello").unwrap();
340        let resp = engine
341            .sync_file(
342                1,
343                SyncFile {
344                    status: String::new(),
345                    path: "test.md".into(),
346                    last_modified: 0,
347                    client_last_modified: 0,
348                    client_last_synced: 0,
349                    content: "hello".into(),
350                },
351            )
352            .unwrap();
353        assert_eq!(resp.status, STATUS_NOT_MODIFIED);
354    }
355
356    #[test]
357    fn test_batch_sync_creates_files() {
358        let (engine, _t) = test_engine();
359        let resp = engine
360            .sync_filenames(
361                1,
362                SyncRequest {
363                    modified: vec![SyncFile {
364                        status: String::new(),
365                        path: "new.md".into(),
366                        last_modified: 0,
367                        client_last_modified: 0,
368                        client_last_synced: 0,
369                        content: "new content".into(),
370                    }],
371                    deleted: vec![],
372                    timestamps: HashMap::new(),
373                },
374            )
375            .unwrap();
376        assert_eq!(resp.status, STATUS_OK);
377        assert!(engine.fs.exists(DIR_USER_ROOT, "new.md").unwrap());
378    }
379
380    #[test]
381    fn test_sync_media_upload_and_read() {
382        let (engine, _t) = test_engine();
383        engine.fs.make_dir(DIR_MEDIA).unwrap();
384
385        // Binary data that is NOT valid UTF-8
386        let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0xFF, 0xD8, 0x00];
387
388        engine.sync_media_upload("photo.png", data).unwrap();
389
390        let read_back = engine.sync_media_read("photo.png").unwrap();
391        assert_eq!(read_back, data);
392    }
393
394    #[test]
395    fn test_sync_media_upload_skips_existing() {
396        let (engine, _t) = test_engine();
397        engine.fs.make_dir(DIR_MEDIA).unwrap();
398
399        engine.sync_media_upload("file.bin", b"original").unwrap();
400        // Uploading again should skip (no overwrite)
401        engine.sync_media_upload("file.bin", b"updated").unwrap();
402
403        let content = engine.sync_media_read("file.bin").unwrap();
404        assert_eq!(content, b"original");
405    }
406}