Skip to main content

sioul_core/
filelock.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! One writer at a time on a file that two parts of Sioul change: the window
5//! marks a dose in the doses' record while the sharing writes in it what your
6//! other devices marked. One writing an older copy over the other would lose a
7//! dose taken, and the sharing would then take it out on every device. An
8//! advisory lock on a hidden file beside it (`.<name>.lock`, which the
9//! sharing never carries), held while the file is read, changed and written.
10
11use std::path::{Path, PathBuf};
12
13/// Bytes written beside `path` under a hidden name of their own, then renamed
14/// over it: a reader never finds the file empty or half written (the sharing
15/// reads without the lock), and a full disk leaves the file as it was rather
16/// than empty (review of 5 October 2026, F10).
17pub fn replace(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
18    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
19    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
20    let temporary = path.with_file_name(format!(".{name}.{}-{}.new", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
21    let written = std::fs::write(&temporary, bytes).and_then(|()| std::fs::rename(&temporary, path));
22    if written.is_err() {
23        let _ = std::fs::remove_file(&temporary);
24    }
25    written
26}
27
28/// Whether a writer of `path` takes its lock here (its hidden lock file is
29/// there): for files the sharing writes without it otherwise (a note, which
30/// most writers do not lock), so that a file one of Sioul's own writers
31/// changes under its lock (the letters' list) is written under it too.
32pub fn has_lock(path: &Path) -> bool {
33    lock_path(path).exists()
34}
35
36fn lock_path(path: &Path) -> PathBuf {
37    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
38    path.with_file_name(format!(".{name}.lock"))
39}
40
41thread_local! {
42    /// The locks this thread holds: one taken again inside (a writer calling
43    /// another of the same file) is already held, never waited for.
44    static HELD: std::cell::RefCell<Vec<PathBuf>> = const { std::cell::RefCell::new(Vec::new()) };
45}
46
47/// `f`, with the lock on `path` held: other threads and programs wait for it.
48/// Where no lock can be taken (a folder not writable), `f` runs all the same:
49/// writing it fails there anyway. Taken again by the thread holding it, it is
50/// held already.
51pub fn with_lock<T>(path: &Path, f: impl FnOnce() -> T) -> T {
52    let lock = lock_path(path);
53    if HELD.with(|held| held.borrow().contains(&lock)) {
54        return f();
55    }
56    struct Holding(PathBuf);
57    impl Drop for Holding {
58        fn drop(&mut self) {
59            HELD.with(|held| held.borrow_mut().retain(|p| *p != self.0));
60        }
61    }
62    let mut options = std::fs::OpenOptions::new();
63    options.create(true).truncate(false).write(true);
64    // Empty, it says nothing; yours alone all the same, as what it guards is (0600 on Unix).
65    #[cfg(unix)]
66    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
67    let file = path.parent().and_then(|parent| std::fs::create_dir_all(parent).ok()).and_then(|()| options.open(lock_path(path)).ok());
68    let locked = file.as_ref().is_some_and(|f| f.lock().is_ok());
69    HELD.with(|held| held.borrow_mut().push(lock.clone()));
70    let holding = Holding(lock);
71    let out = f();
72    drop(holding);
73    if locked && let Some(file) = &file {
74        let _ = file.unlock();
75    }
76    out
77}
78
79/// A TOML file as it was read, kept beside the state read from it (a field
80/// that serde skips): saved, only what changed since it was read is written
81/// over what the file holds then (`save_merged`), so that a change your other
82/// devices brought in meanwhile (the sharing writes under the same lock)
83/// stays. Equal to any other, so that it changes no comparison of the state.
84#[derive(Clone, Default)]
85pub struct Read(Option<std::sync::Arc<toml::Table>>);
86
87impl Read {
88    /// The file's text, as read.
89    pub fn of(text: &str) -> Read {
90        Read(text.parse::<toml::Table>().ok().map(std::sync::Arc::new))
91    }
92}
93
94impl PartialEq for Read {
95    fn eq(&self, _: &Read) -> bool {
96        true
97    }
98}
99
100impl Eq for Read {}
101
102impl std::fmt::Debug for Read {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str("Read")
105    }
106}
107
108/// `state` written into `path`, under its lock, the file read again first:
109/// what changed from `read` (the file as it was read) to `state` is set over
110/// what the file holds now, field by field (a list as a set: what was added
111/// added, what was taken out taken out; a list or table missing on one side
112/// is an empty one, as a state skips an empty field); the rest stays as the
113/// file holds it. A file missing when it was read is read as empty. Nothing
114/// read before that could be merged (a file that did not read): `state`
115/// whole. Written beside, then moved.
116pub fn save_merged<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T) -> Result<(), String> {
117    save_merged_by(path, read, state, |_, _| {})
118}
119
120/// The same, `settle` then given the merged table and the file as it was
121/// just before (none when missing): for what a field-by-field merge cannot
122/// say (a mark that must never go backwards).
123pub fn save_merged_by<T: serde::Serialize + serde::de::DeserializeOwned>(path: &Path, read: &Read, state: &T, settle: impl FnOnce(&mut toml::Table, Option<&toml::Table>)) -> Result<(), String> {
124    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
125    let new = match toml::Value::try_from(state).map_err(|e| e.to_string())? {
126        toml::Value::Table(table) => table,
127        _ => return Err(format!("{}: not a table", path.display())),
128    };
129    // What of the file as read this Sioul knows: read into `T` and written
130    // again. A field it does not know (a newer Sioul's) is never taken out.
131    let known: Option<toml::Table> = read.0.as_ref().and_then(|base| T::deserialize(toml::Value::Table((**base).clone())).ok()).and_then(|t| toml::Value::try_from(&t).ok()).and_then(|v| match v {
132        toml::Value::Table(table) => Some(table),
133        _ => None,
134    });
135    with_lock(path, || {
136        let current = std::fs::read_to_string(path).ok().and_then(|text| text.parse::<toml::Table>().ok());
137        let mut table = match (&read.0, &current) {
138            (Some(base), Some(current)) => {
139                let mut merged = current.clone();
140                merge(&mut merged, base, &new, known.as_ref());
141                merged
142            }
143            _ => new.clone(),
144        };
145        settle(&mut table, current.as_ref());
146        if let Some(parent) = path.parent() {
147            std::fs::create_dir_all(parent).map_err(fail)?;
148        }
149        let temporary = path.with_file_name(format!(".{}.sioul.tmp", path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()));
150        std::fs::write(&temporary, toml::to_string(&table).map_err(|e| e.to_string())?).map_err(fail)?;
151        std::fs::rename(&temporary, path).map_err(fail)
152    })
153}
154
155static EMPTY: std::sync::LazyLock<toml::Table> = std::sync::LazyLock::new(toml::Table::new);
156
157/// What changed from `base` to `new`, set over `current`. A list or a table
158/// on one side only stands against an empty one on the other: emptied, it
159/// takes out what it held, never what another writer added since; filled
160/// from nothing, it adds to what another writer put there.
161///
162/// `known`: the base as the state's type reads it (none: all of it); a key of
163/// the base it does not hold, nor `new`, is one that type does not know,
164/// left as `current` holds it.
165fn merge(current: &mut toml::Table, base: &toml::Table, new: &toml::Table, known: Option<&toml::Table>) {
166    let keys: std::collections::BTreeSet<String> = base.keys().chain(new.keys()).filter(|key| new.contains_key(*key) || known.is_none_or(|known| known.contains_key(*key)) || !base.contains_key(*key)).cloned().collect();
167    let no_list = toml::Value::Array(Vec::new());
168    let no_table = toml::Value::Table(toml::Table::new());
169    for key in keys {
170        let (before, after) = match (base.get(&key), new.get(&key)) {
171            (Some(before @ toml::Value::Array(_)), None) => (Some(before), Some(&no_list)),
172            (None, Some(after @ toml::Value::Array(_))) => (Some(&no_list), Some(after)),
173            (Some(before @ toml::Value::Table(_)), None) => (Some(before), Some(&no_table)),
174            (None, Some(after @ toml::Value::Table(_))) => (Some(&no_table), Some(after)),
175            pair => pair,
176        };
177        match (before, after) {
178            (before, after) if before == after => {}
179            (Some(toml::Value::Table(before)), Some(toml::Value::Table(after))) => match current.get_mut(&key) {
180                Some(toml::Value::Table(inner)) => {
181                    merge(inner, before, after, known.map(|k| k.get(&key).and_then(toml::Value::as_table).unwrap_or(&*EMPTY)));
182                    if inner.is_empty() && !new.contains_key(&key) {
183                        current.remove(&key);
184                    }
185                }
186                _ if after.is_empty() && !new.contains_key(&key) => {}
187                _ => {
188                    current.insert(key, toml::Value::Table(after.clone()));
189                }
190            },
191            (Some(toml::Value::Array(before)), Some(toml::Value::Array(after))) => match current.get_mut(&key) {
192                Some(toml::Value::Array(inner)) => {
193                    inner.retain(|item| !before.contains(item) || after.contains(item));
194                    for item in after.iter().filter(|item| !before.contains(item)) {
195                        if !inner.contains(item) {
196                            inner.push(item.clone());
197                        }
198                    }
199                    if inner.is_empty() && !new.contains_key(&key) {
200                        current.remove(&key);
201                    }
202                }
203                _ if after.is_empty() && !new.contains_key(&key) => {}
204                _ => {
205                    current.insert(key, toml::Value::Array(after.clone()));
206                }
207            },
208            (_, Some(after)) => {
209                current.insert(key, after.clone());
210            }
211            (_, None) => {
212                current.remove(&key);
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    /// A state saved after another writer changed the file since it was read
223    /// keeps that writer's change: only what this one changed is written.
224    #[test]
225    fn a_save_keeps_what_another_writer_changed_since_it_was_read() {
226        let dir = std::env::temp_dir().join(format!("sioul-merge-{}", std::process::id()));
227        let _ = std::fs::remove_dir_all(&dir);
228        std::fs::create_dir_all(&dir).unwrap();
229        let path = dir.join("quiet.toml");
230        std::fs::write(&path, "work_until = 10
231lighter = [2026-10-01]
232").unwrap();
233        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
234        // Another writer (the sharing) adds a day and sets a field.
235        std::fs::write(&path, "work_until = 10
236rest_until = 20
237lighter = [2026-10-01, 2026-10-02]
238").unwrap();
239        // This one, from what it read, changes work_until and adds another day.
240        let mine: toml::Table = "work_until = 11
241lighter = [2026-10-01, 2026-10-03]
242".parse().unwrap();
243        save_merged(&path, &read, &mine).unwrap();
244        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
245        assert_eq!(now["work_until"].as_integer(), Some(11));
246        assert_eq!(now["rest_until"].as_integer(), Some(20), "the other writer's field stays");
247        assert_eq!(now["lighter"].as_array().unwrap().len(), 3, "both days added stay: {now:?}");
248        let _ = std::fs::remove_dir_all(&dir);
249    }
250
251    /// A list the window empties (a state skips an empty field) takes out
252    /// what it held, never what another writer added since; a list written
253    /// into a file that was missing when read adds to what another writer put
254    /// there since (third review, T9).
255    #[test]
256    fn an_emptied_or_new_list_keeps_what_another_writer_added() {
257        let dir = std::env::temp_dir().join(format!("sioul-merge-empty-{}", std::process::id()));
258        let _ = std::fs::remove_dir_all(&dir);
259        std::fs::create_dir_all(&dir).unwrap();
260        let path = dir.join("quiet.toml");
261        std::fs::write(&path, "lighter = [2026-10-12]\n").unwrap();
262        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
263        std::fs::write(&path, "lighter = [2026-10-12, 2026-10-14]\n").unwrap();
264        save_merged(&path, &read, &toml::Table::new()).unwrap();
265        assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "lighter = [2026-10-14]");
266        // Emptied with nothing added since: the field goes.
267        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
268        save_merged(&path, &read, &toml::Table::new()).unwrap();
269        assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "");
270        // Missing when read; the other writer makes it; this one adds its own.
271        std::fs::remove_file(&path).unwrap();
272        let read = Read::of(&std::fs::read_to_string(&path).unwrap_or_default());
273        std::fs::write(&path, "ignored = [\"a\"]\n[seen]\nx = 1\n").unwrap();
274        let mine: toml::Table = "ignored = [\"b\"]\n[seen]\ny = 2\n".parse().unwrap();
275        save_merged(&path, &read, &mine).unwrap();
276        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
277        assert_eq!(now["ignored"].as_array().unwrap().len(), 2, "{now:?}");
278        assert_eq!((now["seen"].get("x").and_then(toml::Value::as_integer), now["seen"].get("y").and_then(toml::Value::as_integer)), (Some(1), Some(2)), "{now:?}");
279        let _ = std::fs::remove_dir_all(&dir);
280    }
281
282    /// A field the state's type does not know (a newer Sioul's) stays, saved
283    /// over by an older one.
284    #[test]
285    fn a_field_a_newer_sioul_wrote_stays() {
286        #[derive(serde::Serialize, serde::Deserialize)]
287        struct Older {
288            #[serde(default)]
289            work_until: Option<i64>,
290        }
291        let dir = std::env::temp_dir().join(format!("sioul-merge-newer-{}", std::process::id()));
292        let _ = std::fs::remove_dir_all(&dir);
293        std::fs::create_dir_all(&dir).unwrap();
294        let path = dir.join("quiet.toml");
295        std::fs::write(&path, "work_until = 10\nnewer = \"kept\"\n[table]\nnewer = 1\n").unwrap();
296        let read = Read::of(&std::fs::read_to_string(&path).unwrap());
297        save_merged(&path, &read, &Older { work_until: Some(11) }).unwrap();
298        let now: toml::Table = std::fs::read_to_string(&path).unwrap().parse().unwrap();
299        assert_eq!((now["work_until"].as_integer(), now["newer"].as_str(), now.get("table").is_some()), (Some(11), Some("kept"), true), "{now:?}");
300        let _ = std::fs::remove_dir_all(&dir);
301    }
302
303    #[test]
304    fn writers_take_turns() {
305        let dir = std::env::temp_dir().join(format!("sioul-lock-{}", std::process::id()));
306        let _ = std::fs::remove_dir_all(&dir);
307        std::fs::create_dir_all(&dir).unwrap();
308        let path = dir.join("count.txt");
309        std::fs::write(&path, "0").unwrap();
310        // Eight threads add one each, a hundred times: read, wait, write. Locked, none is lost.
311        let threads: Vec<_> = (0..8)
312            .map(|_| {
313                let path = path.clone();
314                std::thread::spawn(move || {
315                    for _ in 0..100 {
316                        with_lock(&path, || {
317                            let n: u32 = std::fs::read_to_string(&path).unwrap().trim().parse().unwrap();
318                            std::thread::yield_now();
319                            std::fs::write(&path, (n + 1).to_string()).unwrap();
320                        });
321                    }
322                })
323            })
324            .collect();
325        for thread in threads {
326            thread.join().unwrap();
327        }
328        assert_eq!(std::fs::read_to_string(&path).unwrap(), "800");
329        assert!(dir.join(".count.txt.lock").exists(), "hidden, so never shared");
330        let _ = std::fs::remove_dir_all(&dir);
331    }
332}