1use crate::share::{Shape, Store};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22pub const QUIET_MS: i64 = 2_000;
24pub const MOST_MS: i64 = 5_000;
26pub const MIN_GAP_MS: i64 = 50_000;
33const LARGEST_WATCH: usize = 20_000;
35
36#[derive(Debug, Default, Clone, PartialEq, Eq)]
38pub struct Soon {
39 asked: Option<i64>,
41 first: Option<i64>,
43 last: i64,
44 ran: i64,
46}
47
48impl Soon {
49 pub const fn new() -> Soon {
51 Soon { asked: None, first: None, last: 0, ran: 0 }
52 }
53
54 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 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 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 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 pub fn take(&mut self, now_ms: i64) {
84 *self = Soon { ran: now_ms, ..Soon::default() };
85 }
86}
87
88#[derive(Debug, Default, Clone, PartialEq, Eq)]
92pub struct Stamps(BTreeMap<PathBuf, (u64, u128)>);
93
94impl Stamps {
95 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 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 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 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
160fn 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 #[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 soon.saved(T);
186 assert_eq!(soon.due(), Some(T + QUIET_MS));
187 for n in 1..=8 {
189 soon.saved(T + n * 1_000);
190 }
191 assert_eq!(soon.due(), Some(T + MOST_MS));
192 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 soon.ask(T + 11_500);
199 assert_eq!(soon.due(), Some(T + 11_500));
200 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 #[test]
214 fn a_quiet_minute_runs_the_scheduled_exchange_alone() {
215 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 assert_eq!(run(&ticks, T + 10 * 60_000).len(), 10);
241 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 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 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 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 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 #[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, ¬es] {
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(¬es) || p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("sioul-"))), "{before:?}");
283 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 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 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}