1use 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#[derive(Debug, Clone)]
18pub struct SyncConfig {
19 pub config_filename: String,
21 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
34pub struct SyncEngine {
36 fs: VirtualFs,
37 config: SyncConfig,
38 fslog: FsLog,
39}
40
41impl SyncEngine {
42 pub fn new(fs: VirtualFs, config: SyncConfig, fslog: FsLog) -> Self {
44 Self { fs, config, fslog }
45 }
46
47 pub fn fs(&self) -> &VirtualFs {
49 &self.fs
50 }
51
52 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 for path in &request.deleted {
83 let rel = path.trim_start_matches('/');
84 let _ = self.fs.del(DIR_USER_ROOT, rel);
85 }
86
87 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 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 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 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 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 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#[derive(Debug, Clone)]
219pub struct MediaEntry {
220 pub filename: String,
222 pub last_modified: i64,
224}
225
226#[derive(Debug, Clone)]
228pub struct MediaSyncResponse {
229 pub files: Vec<MediaEntry>,
231 pub timestamp: i64,
233}
234
235impl SyncEngine {
236 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 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 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 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 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 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}