Skip to main content

oxios_markdown/
fslog.rs

1//! Filesystem change log for sync.
2//!
3//! Ported from files.md (`server/sync/fslog.rs`) by Artem Zakirullin.
4//! Tracks file renames and deletes so clients can be notified.
5
6use std::collections::HashMap;
7use std::fs::{File, OpenOptions};
8use std::io::{BufRead, BufReader, Write};
9
10use parking_lot::Mutex;
11
12const RENAME_OP: &str = "ren";
13const DELETE_OP: &str = "del";
14
15/// Filesystem change logger.
16///
17/// Thread-safe append-only log of file renames and deletes.
18/// Used by the sync engine to notify clients of file movements.
19pub struct FsLog {
20    log_path: std::path::PathBuf,
21    lock: Mutex<()>,
22}
23
24impl FsLog {
25    /// Create a new FsLog writing to the given path.
26    pub fn new(log_path: std::path::PathBuf) -> Self {
27        Self {
28            log_path,
29            lock: Mutex::new(()),
30        }
31    }
32
33    /// Record a file rename.
34    pub fn log_rename(&self, time: i64, old_path: &str, new_path: &str) {
35        let _guard = self.lock.lock();
36        let _ = self.append(&format!(
37            "{} {} {} {}",
38            time,
39            RENAME_OP,
40            percent_encoding::utf8_percent_encode(old_path, percent_encoding::NON_ALPHANUMERIC),
41            percent_encoding::utf8_percent_encode(new_path, percent_encoding::NON_ALPHANUMERIC),
42        ));
43    }
44
45    /// Record a file deletion.
46    pub fn log_delete(&self, time: i64, path: &str) {
47        let _guard = self.lock.lock();
48        let _ = self.append(&format!(
49            "{} {} {}",
50            time,
51            DELETE_OP,
52            percent_encoding::utf8_percent_encode(path, percent_encoding::NON_ALPHANUMERIC),
53        ));
54    }
55
56    /// Read rename entries since a given timestamp.
57    ///
58    /// Returns a map of new_path → old_path.
59    pub fn renames_since(
60        &self,
61        user_prefix: &str,
62        after_timestamp: i64,
63    ) -> HashMap<String, String> {
64        let _guard = self.lock.lock();
65        let file = match File::open(&self.log_path) {
66            Ok(f) => f,
67            Err(_) => return HashMap::new(),
68        };
69
70        let reader = BufReader::new(file);
71        let mut result = HashMap::new();
72
73        for line in reader.lines().map_while(Result::ok) {
74            let parts: Vec<&str> = line.split_whitespace().collect();
75            if parts.len() != 4 {
76                continue;
77            }
78            let timestamp: i64 = match parts[0].parse() {
79                Ok(t) => t,
80                Err(_) => continue,
81            };
82            if parts[1] != RENAME_OP {
83                continue;
84            }
85            if timestamp < after_timestamp {
86                continue;
87            }
88
89            let old_path = decode_path(parts[2]);
90            let new_path = decode_path(parts[3]);
91
92            if !old_path.starts_with(user_prefix) || !new_path.starts_with(user_prefix) {
93                continue;
94            }
95
96            let old_rel = old_path
97                .strip_prefix(user_prefix)
98                .unwrap_or(&old_path)
99                .to_string();
100            let new_rel = new_path
101                .strip_prefix(user_prefix)
102                .unwrap_or(&new_path)
103                .to_string();
104            result.insert(new_rel, old_rel);
105        }
106        result
107    }
108
109    fn append(&self, record: &str) -> std::io::Result<()> {
110        if let Some(parent) = self.log_path.parent() {
111            std::fs::create_dir_all(parent)?;
112        }
113        let mut file = OpenOptions::new()
114            .create(true)
115            .append(true)
116            .open(&self.log_path)?;
117        writeln!(file, "{}", record)?;
118        file.sync_all()?;
119        Ok(())
120    }
121}
122
123fn decode_path(encoded: &str) -> String {
124    percent_encoding::percent_decode_str(encoded)
125        .decode_utf8_lossy()
126        .to_string()
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use tempfile::TempDir;
133
134    #[test]
135    fn test_log_and_read_renames() {
136        let dir = TempDir::new().unwrap();
137        let log = FsLog::new(dir.path().join("fslog"));
138        log.log_rename(1000, "/storage/1/a.md", "/storage/1/b.md");
139        log.log_rename(2000, "/storage/1/c.md", "/storage/1/d.md");
140
141        let renames = log.renames_since("/storage/1/", 1500);
142        assert_eq!(renames.len(), 1);
143        assert_eq!(renames.get("b.md"), None); // the new path is d.md, old is c.md
144    }
145}