Skip to main content

sioul_sync/
trigger.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Shared as soon as Sioul saves (docs/database.md, "Sent as soon as it is
5//! saved"): when a file a sharing part carries changes here (a dose answered,
6//! a setting, `health.toml`, a project, a budget), an exchange runs a few
7//! seconds later, then this device's files go to the server. One mechanism
8//! for every change, whoever wrote it: the window looks at the shared files
9//! every two seconds (`Stamps`), and a change seen, like an exchange asked
10//! (a button, a dose answered, the minute's tick), wants one (`Soon`). Saves
11//! that follow each other wait for a pause: two seconds after the last, five
12//! after the first at most, so that a page saving each field as you type
13//! sends once, and soon.
14//!
15//! The notes and papers folders are not looked at (thousands of files, every
16//! two seconds): the minute's exchange reads them, as before.
17
18use crate::share::{Shape, Store};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22/// A save is followed by an exchange this long after the last one, if none follows (milliseconds)…
23pub const QUIET_MS: i64 = 2_000;
24/// …and never later than this after the first (milliseconds).
25pub const MOST_MS: i64 = 5_000;
26/// Exchanges for saves, and the minute's scheduled one, never start closer
27/// than this to the last (milliseconds): a file Sioul writes each minute (a
28/// session's time, a plan) costs one exchange a minute, not two, and a quiet
29/// minute only the scheduled one. An exchange asked (a button, a dose
30/// answered) goes at once all the same. A save is thus carried within a
31/// minute, within seconds when the last exchange is older.
32pub const MIN_GAP_MS: i64 = 50_000;
33/// A folder of shared files is not looked through past this many files.
34const LARGEST_WATCH: usize = 20_000;
35
36/// When an exchange is wanted: asked now, or saves seen, waiting for a pause.
37#[derive(Debug, Default, Clone, PartialEq, Eq)]
38pub struct Soon {
39    /// An exchange asked for this moment (milliseconds).
40    asked: Option<i64>,
41    /// The first save seen since the last exchange, and the last one (milliseconds).
42    first: Option<i64>,
43    last: i64,
44    /// When the last exchange started (milliseconds).
45    ran: i64,
46}
47
48impl Soon {
49    /// Nothing wanted yet.
50    pub const fn new() -> Soon {
51        Soon { asked: None, first: None, last: 0, ran: 0 }
52    }
53
54    /// An exchange asked at `now_ms` (a button, a dose answered, the minute's tick): due at once.
55    pub fn ask(&mut self, now_ms: i64) {
56        self.asked = Some(self.asked.map_or(now_ms, |at| at.min(now_ms)));
57    }
58
59    /// The minute's scheduled exchange, at `now_ms`: at once, unless one
60    /// started less than `MIN_GAP_MS` ago (a save's), which did its work.
61    pub fn tick(&mut self, now_ms: i64) {
62        if now_ms - self.ran >= MIN_GAP_MS {
63            self.ask(now_ms);
64        }
65    }
66
67    /// A shared file saved at `now_ms`: due after a pause.
68    pub fn saved(&mut self, now_ms: i64) {
69        self.first.get_or_insert(now_ms);
70        self.last = self.last.max(now_ms);
71    }
72
73    /// When the exchange is due (milliseconds); none when nothing is wanted.
74    pub fn due(&self) -> Option<i64> {
75        let after_saves = self.first.map(|first| (self.last + QUIET_MS).min(first + MOST_MS).max(self.ran + MIN_GAP_MS));
76        match (self.asked, after_saves) {
77            (Some(a), Some(b)) => Some(a.min(b)),
78            (a, b) => a.or(b),
79        }
80    }
81
82    /// Taken by the exchange starting at `now_ms`: what comes after wants the next one.
83    pub fn take(&mut self, now_ms: i64) {
84        *self = Soon { ran: now_ms, ..Soon::default() };
85    }
86}
87
88/// How the shared files stand: each one's size and time, by its path. A
89/// change of either is a save (or a file the exchange wrote, taken in after
90/// it with `refresh`).
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct Stamps(BTreeMap<PathBuf, (u64, u128)>);
93
94impl Stamps {
95    /// The files `stores` carry, as they are now: each file, each folder's
96    /// files below it (hidden ones left out: locks, files half written); the
97    /// notes and papers folders left out.
98    pub fn of(stores: &[Store]) -> Stamps {
99        let mut stamps = Stamps::default();
100        for store in stores.iter().filter(|s| watched(s)) {
101            stamps.add(&store.path);
102        }
103        stamps
104    }
105
106    fn add(&mut self, path: &Path) {
107        let Ok(meta) = std::fs::metadata(path) else { return };
108        if meta.is_file() {
109            self.0.insert(path.to_path_buf(), stamp(&meta));
110            return;
111        }
112        let mut stack = vec![path.to_path_buf()];
113        while let Some(dir) = stack.pop() {
114            for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
115                if self.0.len() >= LARGEST_WATCH {
116                    return;
117                }
118                if entry.file_name().to_string_lossy().starts_with('.') {
119                    continue;
120                }
121                match entry.file_type() {
122                    Ok(kind) if kind.is_dir() => stack.push(entry.path()),
123                    Ok(kind) if kind.is_file() => {
124                        if let Ok(meta) = entry.metadata() {
125                            self.0.insert(entry.path(), stamp(&meta));
126                        }
127                    }
128                    _ => {}
129                }
130            }
131        }
132    }
133
134    /// The files that differ from `before`: changed, new, gone.
135    pub fn changed(&self, before: &Stamps) -> Vec<PathBuf> {
136        let mut out: Vec<PathBuf> = self.0.iter().filter(|(path, stamp)| before.0.get(*path) != Some(stamp)).map(|(path, _)| path.clone()).collect();
137        out.extend(before.0.keys().filter(|path| !self.0.contains_key(*path)).cloned());
138        out
139    }
140
141    /// What the exchange wrote (`share::Outcome::written`, by store name),
142    /// taken in as it is now: never taken for a save.
143    pub fn refresh(&mut self, stores: &[Store], written: &std::collections::BTreeSet<String>) {
144        for store in stores.iter().filter(|s| watched(s) && written.contains(&s.name)) {
145            self.0.retain(|path, _| !path.starts_with(&store.path));
146            self.add(&store.path);
147        }
148    }
149
150    /// How many files are looked at.
151    pub fn len(&self) -> usize {
152        self.0.len()
153    }
154
155    pub fn is_empty(&self) -> bool {
156        self.0.is_empty()
157    }
158}
159
160/// Looked at every two seconds: every store but the folders sealed apart file by file (notes, papers).
161fn watched(store: &Store) -> bool {
162    !(store.folder && matches!(store.shape, Shape::Files))
163}
164
165fn stamp(meta: &std::fs::Metadata) -> (u64, u128) {
166    let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos());
167    (meta.len(), modified)
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::share::Roots;
174    use sioul_core::config::Config;
175
176    const T: i64 = 1_800_000_000_000;
177
178    /// Saves wait for a pause, two seconds, never more than five after the
179    /// first; an exchange asked goes at once; each exchange takes what came before it.
180    #[test]
181    fn the_trigger_waits_for_a_pause_and_never_long() {
182        let mut soon = Soon::default();
183        assert_eq!(soon.due(), None, "nothing wanted");
184        // One save: two seconds later.
185        soon.saved(T);
186        assert_eq!(soon.due(), Some(T + QUIET_MS));
187        // Saves every second (a page saving as you type): due five seconds after the first.
188        for n in 1..=8 {
189            soon.saved(T + n * 1_000);
190        }
191        assert_eq!(soon.due(), Some(T + MOST_MS));
192        // A pause after two saves, the last exchange a minute old: two seconds after the last.
193        soon.take(T - 60_000);
194        soon.saved(T + 10_000);
195        soon.saved(T + 11_000);
196        assert_eq!(soon.due(), Some(T + 11_000 + QUIET_MS));
197        // Asked meanwhile (a dose answered): at once.
198        soon.ask(T + 11_500);
199        assert_eq!(soon.due(), Some(T + 11_500));
200        // Taken by the exchange: a save during it wants the next one, never closer than `MIN_GAP_MS`.
201        soon.take(T + 11_500);
202        assert_eq!(soon.due(), None);
203        soon.saved(T + 12_000);
204        assert_eq!(soon.due(), Some(T + 11_500 + MIN_GAP_MS));
205        soon.ask(T + 13_000);
206        assert_eq!(soon.due(), Some(T + 13_000), "asked: at once all the same");
207    }
208
209    /// A quiet minute runs the scheduled exchange alone; a file saved each
210    /// minute (a session's time) costs one exchange a minute, not two; a save
211    /// right after the scheduled one makes one more, and the next scheduled
212    /// one, then too recent, is left out.
213    #[test]
214    fn a_quiet_minute_runs_the_scheduled_exchange_alone() {
215        // The worker as the window runs it: due, taken, run; counted.
216        let run = |events: &[(i64, &str)], until: i64| -> Vec<i64> {
217            let mut soon = Soon::new();
218            let mut ran = Vec::new();
219            let mut events = events.to_vec();
220            events.sort();
221            let mut next = 0;
222            for now in (T..=until).step_by(500) {
223                while next < events.len() && events[next].0 <= now {
224                    match events[next].1 {
225                        "tick" => soon.tick(events[next].0),
226                        "save" => soon.saved(events[next].0),
227                        _ => soon.ask(events[next].0),
228                    }
229                    next += 1;
230                }
231                if soon.due().is_some_and(|due| due <= now) {
232                    soon.take(now);
233                    ran.push(now);
234                }
235            }
236            ran
237        };
238        let ticks: Vec<(i64, &str)> = (0..10).map(|m| (T + m * 60_000, "tick")).collect();
239        // Ten quiet minutes: ten exchanges, the scheduled ones.
240        assert_eq!(run(&ticks, T + 10 * 60_000).len(), 10);
241        // A file written each minute, whenever in the minute: about one a minute.
242        for offset in [3_000, 12_000, 30_000, 50_000, 58_000] {
243            let mut busy = ticks.clone();
244            busy.extend((0..10).map(|m| (T + m * 60_000 + offset, "save")));
245            let ran = run(&busy, T + 10 * 60_000);
246            // At most one each `MIN_GAP_MS`: twelve in ten minutes, never twenty.
247            assert!(ran.len() <= 12, "saved {offset} ms after each tick: {} exchanges in ten minutes: {ran:?}", ran.len());
248            assert!(ran.windows(2).all(|w| w[1] - w[0] >= MIN_GAP_MS), "{ran:?}");
249        }
250        // One save in a quiet hour: carried within the minute, no more exchanges than the ticks and it.
251        let mut once = ticks.clone();
252        once.push((T + 3 * 60_000 + 20_000, "save"));
253        let ran = run(&once, T + 10 * 60_000);
254        assert!(ran.contains(&(T + 3 * 60_000 + MIN_GAP_MS)), "{ran:?}");
255        assert!(ran.len() <= 11, "{ran:?}");
256        // After a quiet stretch, a save goes within seconds.
257        let mut late = vec![(T, "tick")];
258        late.push((T + 70_000, "save"));
259        assert!(run(&late, T + 80_000).contains(&(T + 70_000 + QUIET_MS)));
260        // An exchange asked (a button) always runs.
261        let mut asked = ticks.clone();
262        asked.push((T + 5_000, "ask"));
263        assert!(run(&asked, T + 60_000).contains(&(T + 5_000)));
264    }
265
266    /// A save is seen in any shared file, not in notes nor in hidden files;
267    /// what the exchange wrote is taken in, never taken for a save.
268    #[test]
269    fn saves_are_seen_in_the_shared_files() {
270        let base = std::env::temp_dir().join(format!("sioul-trigger-{}", std::process::id()));
271        let _ = std::fs::remove_dir_all(&base);
272        let roots = Roots { config: base.join("config"), data: base.join("data"), state: base.join("state") };
273        let notes = base.join("notes");
274        for dir in [&roots.config, &roots.data.join("drafts"), &roots.state, &notes] {
275            std::fs::create_dir_all(dir).unwrap();
276        }
277        let config = Config { notes_root: Some(notes.display().to_string()), ..Config::default() };
278        let stores = crate::share::stores_of(&config, &roots, &|_| true);
279        std::fs::write(roots.config.join("config.toml"), "language = \"fr\"\n").unwrap();
280        std::fs::write(notes.join("lease.md"), "a note\n").unwrap();
281        let before = Stamps::of(&stores);
282        assert!(!before.is_empty() && before.0.keys().all(|p| !p.starts_with(&notes) || p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("sioul-"))), "{before:?}");
283        // A note, a lock, a file half written: no save.
284        std::fs::write(notes.join("lease.md"), "a note, longer\n").unwrap();
285        std::fs::write(roots.state.join(".health-state.toml.lock"), "").unwrap();
286        std::fs::write(roots.data.join("drafts").join(".draft.eml.123-1.sioul.tmp"), "half").unwrap();
287        assert!(Stamps::of(&stores).changed(&before).is_empty());
288        // A setting, a draft, a medicine: saves. The setting longer than before:
289        // Windows may give two writes a few milliseconds apart the same time.
290        std::fs::write(roots.config.join("config.toml"), "language = \"en\"\n# saved\n").unwrap();
291        std::fs::write(roots.data.join("drafts").join("draft.eml"), "Subject: lease\n").unwrap();
292        std::fs::write(roots.data.join("health.toml"), "[[medicine]]\nid = \"levo\"\n").unwrap();
293        let mut now = Stamps::of(&stores);
294        let changed = now.changed(&before);
295        assert_eq!(changed.len(), 3, "{changed:?}");
296        // The exchange writes the doses' record: taken in, not a save after it.
297        let seen = now.clone();
298        std::fs::write(roots.state.join("health-state.toml"), "[taken]\n").unwrap();
299        now.refresh(&stores, &["state/health-state.toml".to_string()].into());
300        assert!(Stamps::of(&stores).changed(&now).is_empty());
301        assert_eq!(now.changed(&seen).len(), 1);
302        let _ = std::fs::remove_dir_all(&base);
303    }
304}