1use serde::{Deserialize, Serialize};
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct Entry {
38 pub id: String,
39 #[serde(default)]
41 pub name: String,
42 #[serde(default)]
44 pub kind: String,
45 #[serde(default)]
49 pub version: String,
50 #[serde(default, skip_serializing_if = "String::is_empty")]
51 pub commit: String,
52 #[serde(default, skip_serializing_if = "is_zero")]
56 pub format: u32,
57 #[serde(default)]
60 pub started: i64,
61 #[serde(default)]
63 pub closed: i64,
64 pub working: bool,
67 #[serde(default)]
69 pub imported: i64,
70 #[serde(default)]
73 pub exported: i64,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub wrote: Option<(u32, u64)>,
77 #[serde(default)]
79 pub doses: bool,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub notifications: Option<bool>,
86 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
88 pub left: bool,
89 #[serde(default, skip_serializing_if = "String::is_empty")]
91 pub pad: String,
92}
93
94fn is_zero(n: &u32) -> bool {
95 *n == 0
96}
97
98pub const PHONE: &str = "phone";
99pub const COMPUTER: &str = "computer";
100
101impl Entry {
102 pub fn start(&mut self, now: i64) {
104 self.started = now;
105 self.working = true;
106 self.left = false;
107 }
108
109 pub fn close(&mut self, now: i64) {
112 self.closed = now.max(self.started);
113 self.working = false;
114 }
115
116 pub fn exported(&mut self, looked_ms: i64, wrote: Option<(u32, u64)>, now: i64) {
120 if looked_ms <= 0 {
121 return;
122 }
123 self.imported = now;
124 self.exported = self.exported.max(looked_ms / 1000);
125 if wrote.is_some() {
126 self.wrote = wrote;
127 }
128 }
129
130 pub fn build(&self) -> String {
133 sioul_core::build::described(&self.version, &self.commit)
134 }
135
136 pub fn said(&self) -> sioul_core::health::Said {
138 sioul_core::health::Said { started: self.started, closed: self.closed, working: self.working, exported: self.exported, imported: self.imported, doses: self.doses, left: self.left, phone: self.kind == PHONE }
139 }
140}
141
142fn folder_of(folder: &Path) -> PathBuf {
143 folder.join("devices")
144}
145
146fn file_of(folder: &Path, id: &str) -> PathBuf {
147 folder_of(folder).join(format!("{id}.device"))
148}
149
150fn bound(id: &str) -> String {
151 format!("device\u{1f}{id}")
152}
153
154pub fn publish(folder: &Path, key: &[u8; 32], entry: &Entry) -> Result<(), String> {
157 let path = file_of(folder, &entry.id);
158 let sealed = |pad: usize| serde_json::to_vec(&Entry { pad: ".".repeat(pad), ..entry.clone() }).map(|plain| crate::share::seal(key, &bound(&entry.id), &plain)).map_err(|e| e.to_string());
159 let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
160 crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
161}
162
163pub fn all(folder: &Path, key: &[u8; 32]) -> (Vec<Entry>, Vec<String>) {
171 let (mut entries, mut unread) = (Vec::new(), Vec::new());
172 let fetched = crate::remote::overlay(folder);
173 let ids = |dir: &Path| -> Vec<String> {
174 let listing = std::fs::read_dir(folder_of(dir)).into_iter().flatten().filter_map(Result::ok);
175 listing.filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_suffix(".device")).map(str::to_string)).filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()).collect()
176 };
177 let mut all: std::collections::BTreeSet<String> = ids(folder).into_iter().collect();
178 if let Some(fetched) = &fetched {
179 all.extend(ids(fetched));
180 }
181 let read = |dir: &Path, id: &str| crate::share::read_small(&file_of(dir, id)).and_then(|text| crate::share::open(key, &bound(id), text.trim())).and_then(|plain| serde_json::from_slice::<Entry>(&plain).ok()).filter(|entry| entry.id == id);
182 for id in all {
183 let entry = match (read(folder, &id), fetched.as_deref().and_then(|f| read(f, &id))) {
184 (Some(here), Some(fetched)) => Some(if written(&fetched) > written(&here) { fetched } else { here }),
185 (here, fetched) => here.or(fetched),
186 };
187 match entry {
188 Some(entry) => entries.push(entry),
189 None => unread.push(id),
190 }
191 }
192 entries.sort_by(|a, b| a.id.cmp(&b.id));
193 unread.sort();
194 (entries, unread)
195}
196
197pub(crate) fn written(entry: &Entry) -> (i64, i64, i64, i64, i64) {
201 (entry.started.max(entry.closed).max(entry.imported), entry.closed, entry.started, entry.imported, entry.exported)
202}
203
204pub fn own_path(state: &Path) -> PathBuf {
206 state.join("share").join("device.toml")
207}
208
209pub fn own(path: &Path) -> Entry {
211 std::fs::read_to_string(path).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
212}
213
214pub(crate) fn in_use(memory: &Path) -> bool {
217 own(&memory.with_file_name("device.toml")).working
218}
219
220pub const RESTATED: i64 = 60 * 60;
227
228#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
234struct Published {
235 said: String,
236 folder: String,
237 #[serde(default)]
238 key: String,
239 size: u64,
240 modified: u64,
241 at: i64,
242}
243
244fn sealed_with(key: &[u8; 32]) -> String {
247 use sha2::Digest;
248 let digest = sha2::Sha256::new().chain_update(b"sioul: the key a device entry was sealed with\x1f").chain_update(key).finalize();
249 digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
250}
251
252fn published_path(own: &Path) -> PathBuf {
253 own.with_file_name("device-published.toml")
254}
255
256pub(crate) fn steady(entry: &Entry) -> String {
259 let steady = Entry { imported: 0, exported: 0, pad: String::new(), ..entry.clone() };
260 serde_json::to_string(&steady).map(|text| crate::share::hash(&text)).unwrap_or_default()
261}
262
263fn stamp_of(path: &Path) -> Option<(u64, u64)> {
265 std::fs::metadata(path).ok().map(|meta| (meta.len(), crate::share::modified_ns(&meta)))
266}
267
268pub fn change(path: &Path, vault: Option<(&Path, &[u8; 32])>, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
273 change_at(path, vault, jiff::Timestamp::now().as_second(), change)
274}
275
276pub(crate) fn change_at(path: &Path, vault: Option<(&Path, &[u8; 32])>, now: i64, change: impl FnOnce(&mut Entry)) -> Result<Entry, String> {
278 sioul_core::filelock::with_lock(path, || {
279 let mut entry = own(path);
280 change(&mut entry);
281 entry.pad.clear();
282 let text = toml::to_string(&entry).map_err(|e| e.to_string())?;
283 crate::share::write_atomically(path, format!("# This device, as it tells your other devices (docs/database.md, \"Devices\").\n{text}").as_bytes())?;
284 if let Some((folder, key)) = vault
285 && !entry.id.is_empty()
286 {
287 let file = file_of(folder, &entry.id);
288 let said = steady(&entry);
289 let there = folder.display().to_string();
290 let last: Option<Published> = std::fs::read_to_string(published_path(path)).ok().and_then(|t| toml::from_str(&t).ok());
291 let mark = sealed_with(key);
293 let current = last.is_some_and(|last| last.said == said && last.folder == there && last.key == mark && stamp_of(&file) == Some((last.size, last.modified)) && (0..RESTATED).contains(&(now - last.at)));
294 if entry.working || !current {
295 publish(folder, key, &entry)?;
296 let (size, modified) = stamp_of(&file).unwrap_or_default();
297 let published = Published { said, folder: there, key: mark, size, modified, at: now };
298 let _ = toml::to_string(&published).map_err(|e| e.to_string()).and_then(|text| crate::share::write_atomically(&published_path(path), text.as_bytes()));
300 }
301 }
302 Ok(entry)
303 })
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::share::{Roots, Sharing, Store};
310 use sioul_core::config::Config;
311 use sioul_core::doses::{Answer, Answered, DoseRecords, SKIPPED, TAKEN};
312 use sioul_core::health::{Doubt, HealthState, Peer, doubts_now};
313
314 fn scratch(name: &str) -> PathBuf {
315 let dir = std::env::temp_dir().join(format!("sioul-devices-{name}-{}", std::process::id()));
316 let _ = std::fs::remove_dir_all(&dir);
317 std::fs::create_dir_all(&dir).unwrap();
318 dir
319 }
320
321 const KEY: [u8; 32] = [7u8; 32];
322
323 #[test]
324 fn a_device_says_how_it_is() {
325 let dir = scratch("says");
326 let (folder, state) = (dir.join("shared"), dir.join("state"));
327 let id = uuid::Uuid::new_v4().to_string();
328 let own = own_path(&state);
329 let vault = Some((folder.as_path(), &KEY));
330 change(&own, vault, |e| {
332 e.id = id.clone();
333 e.kind = PHONE.into();
334 e.doses = true;
335 e.start(1_000);
336 })
337 .unwrap();
338 let raw = std::fs::read_to_string(file_of(&folder, &id)).unwrap();
339 assert!(!raw.contains("working") && !raw.contains(&id), "sealed");
340 let size = raw.len();
341 let (entries, unread) = all(&folder, &KEY);
342 assert!(unread.is_empty());
343 assert_eq!((entries[0].started, entries[0].working, entries[0].said().phone), (1_000, true, true));
344 change(&own, vault, |e| e.exported(1_060_000, Some((1, 4)), 1_061)).unwrap();
346 change(&own, vault, |e| e.close(1_120)).unwrap();
348 let (entries, _) = all(&folder, &KEY);
349 assert_eq!((entries[0].exported, entries[0].wrote, entries[0].closed, entries[0].working), (1_060, Some((1, 4)), 1_120, false));
350 assert_ne!(std::fs::metadata(file_of(&folder, &id)).unwrap().len() as usize, size, "its size changes at each writing");
351 assert_eq!(super::own(&own).closed, 1_120);
353 let (entries, unread) = all(&folder, &[1u8; 32]);
355 assert!(entries.is_empty() && unread == [id.clone()]);
356 std::fs::copy(file_of(&folder, &id), folder_of(&folder).join(format!("{id} (conflicted copy).device"))).unwrap();
358 let other = uuid::Uuid::new_v4().to_string();
359 std::fs::copy(file_of(&folder, &id), file_of(&folder, &other)).unwrap();
360 let (entries, unread) = all(&folder, &KEY);
361 assert_eq!((entries.len(), unread), (1, vec![other]));
362 let without = serde_json::json!({ "id": id, "started": 1_000, "closed": 2_000 }).to_string();
364 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), without.as_bytes())).unwrap();
365 assert!(all(&folder, &KEY).0.is_empty());
366 let _ = std::fs::remove_dir_all(&dir);
367 }
368
369 #[test]
372 fn an_entry_says_its_build_and_an_older_one_still_reads() {
373 let dir = scratch("build");
374 let (folder, state) = (dir.join("shared"), dir.join("state"));
375 let id = uuid::Uuid::new_v4().to_string();
376 let vault = Some((folder.as_path(), &KEY));
377 change(&own_path(&state), vault, |e| {
378 e.id = id.clone();
379 e.version = sioul_core::build::VERSION.into();
380 e.commit = sioul_core::build::COMMIT.into();
381 e.start(1_000);
382 })
383 .unwrap();
384 let (entries, _) = all(&folder, &KEY);
385 assert_eq!((entries[0].version.as_str(), entries[0].commit.as_str()), (sioul_core::build::VERSION, sioul_core::build::COMMIT));
386 assert_eq!(entries[0].build(), sioul_core::build::DESCRIBED);
387 assert!(!std::fs::read_to_string(file_of(&folder, &id)).unwrap().contains(sioul_core::build::COMMIT), "sealed: the server never learns it");
388 for (plain, said) in [(serde_json::json!({ "id": id, "version": "0.0.2", "started": 1_000, "working": true }), "0.0.2"), (serde_json::json!({ "id": id, "started": 1_000, "working": true }), "")] {
390 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), plain.to_string().as_bytes())).unwrap();
391 let (entries, unread) = all(&folder, &KEY);
392 assert!(unread.is_empty() && entries[0].working && entries[0].commit.is_empty(), "{entries:?}");
393 assert_eq!(entries[0].build(), said);
394 }
395 let _ = std::fs::remove_dir_all(&dir);
396 }
397
398 #[test]
402 fn a_phone_says_whether_it_holds_notifications() {
403 let dir = scratch("notifications");
404 let (folder, state) = (dir.join("shared"), dir.join("state"));
405 let id = uuid::Uuid::new_v4().to_string();
406 let vault = Some((folder.as_path(), &KEY));
407 change(&own_path(&state), vault, |e| {
408 e.id = id.clone();
409 e.kind = PHONE.into();
410 e.notifications = Some(true);
411 e.start(1_000);
412 })
413 .unwrap();
414 change(&own_path(&state), vault, |e| e.close(1_100)).unwrap();
415 assert_eq!(all(&folder, &KEY).0[0].notifications, Some(true), "kept by a change that did not look");
416 change(&own_path(&state), vault, |e| e.notifications = Some(false)).unwrap();
417 assert_eq!(all(&folder, &KEY).0[0].notifications, Some(false));
418 let older = serde_json::json!({ "id": id, "kind": "phone", "started": 1_000, "working": true });
419 std::fs::write(file_of(&folder, &id), crate::share::seal(&KEY, &bound(&id), older.to_string().as_bytes())).unwrap();
420 assert_eq!(all(&folder, &KEY).0[0].notifications, None, "an older Sioul's: unsaid");
421 let _ = std::fs::remove_dir_all(&dir);
422 }
423
424 #[test]
431 fn a_device_put_away_writes_its_entry_only_when_it_says_something_new() {
432 let dir = scratch("put-away");
433 let (folder, state) = (dir.join("shared"), dir.join("state"));
434 let own = own_path(&state);
435 let vault = Some((folder.as_path(), &KEY));
436 let id = uuid::Uuid::new_v4().to_string();
437 let file = file_of(&folder, &id);
438 let bytes = || std::fs::read(&file).unwrap();
439 let exported = |at: i64, wrote: (u32, u64)| change_at(&own, vault, at, |e| e.exported(at * 1000, Some(wrote), at)).unwrap();
440 let t = 1_800_000_000;
441 change_at(&own, vault, t, |e| {
442 e.id = id.clone();
443 e.kind = PHONE.into();
444 e.doses = true;
445 e.start(t);
446 })
447 .unwrap();
448 exported(t, (1, 4));
449 change_at(&own, vault, t + 5, |e| e.close(t + 5)).unwrap();
450 let closed = bytes();
451 for n in 1..=5 {
453 exported(t + 5 + n * 120, (1, 4));
454 }
455 assert_eq!(bytes(), closed, "nothing new: the folder's copy as it was");
456 assert_eq!(super::own(&own).exported, t + 605, "kept here");
457 let seen = &all(&folder, &KEY).0[0];
458 assert_eq!((seen.exported, seen.wrote, seen.working), (t, Some((1, 4)), false));
459 exported(t + 700, (2, 1));
461 let seen = all(&folder, &KEY).0[0].clone();
462 assert_eq!((seen.exported, seen.wrote, seen.working), (t + 700, Some((2, 1)), false));
463 let wrote = bytes();
465 exported(t + 700 + RESTATED - 1, (2, 1));
466 assert_eq!(bytes(), wrote);
467 exported(t + 700 + RESTATED, (2, 1));
468 assert_eq!(all(&folder, &KEY).0[0].exported, t + 700 + RESTATED, "restated after an hour");
469 std::fs::write(&file, &closed).unwrap();
471 exported(t + 800 + RESTATED, (2, 1));
472 assert_eq!(all(&folder, &KEY).0[0].wrote, Some((2, 1)), "the copy put back is replaced");
473 change_at(&own, vault, t + 900 + RESTATED, |e| e.doses = false).unwrap();
475 assert!(!all(&folder, &KEY).0[0].doses);
476 let other = [9u8; 32];
478 exported(t + 950 + RESTATED, (2, 1));
479 change_at(&own, Some((folder.as_path(), &other)), t + 960 + RESTATED, |e| e.exported((t + 960 + RESTATED) * 1000, Some((2, 1)), t + 960 + RESTATED)).unwrap();
480 assert_eq!(all(&folder, &other).0.len(), 1, "readable under the new key at once");
481 change_at(&own, vault, t + 1_000 + RESTATED, |e| e.start(t + 1_000 + RESTATED)).unwrap();
483 for n in 1..=3 {
484 let at = t + 1_000 + RESTATED + n * 60;
485 exported(at, (2, 1));
486 let seen = &all(&folder, &KEY).0[0];
487 assert_eq!((seen.exported, seen.working), (at, true), "in use: export {n} there at once");
488 }
489 let elsewhere = dir.join("elsewhere");
491 change_at(&own, Some((elsewhere.as_path(), &KEY)), t + 2_000 + RESTATED, |e| e.close(t + 2_000 + RESTATED)).unwrap();
492 exported(t + 2_100 + RESTATED, (2, 1));
493 let moved = dir.join("moved");
494 change_at(&own, Some((moved.as_path(), &KEY)), t + 2_200 + RESTATED, |e| e.exported((t + 2_200 + RESTATED) * 1000, Some((2, 1)), t + 2_200 + RESTATED)).unwrap();
495 assert_eq!(all(&moved, &KEY).0.len(), 1, "the new folder has it at once");
496 let _ = std::fs::remove_dir_all(&dir);
497 }
498
499 #[test]
500 fn two_processes_never_undo_each_others_session() {
501 let dir = scratch("processes");
502 let (folder, state) = (dir.join("shared"), dir.join("state"));
503 let own = own_path(&state);
504 let vault = Some((folder.as_path(), &KEY));
505 let id = uuid::Uuid::new_v4().to_string();
506 change(&own, vault, |e| {
507 e.id = id.clone();
508 e.start(1_000);
509 })
510 .unwrap();
511 change(&own, vault, |e| e.close(1_100)).unwrap();
513 change(&own, vault, |e| e.exported(1_200_000, Some((1, 9)), 1_201)).unwrap();
514 let (entries, _) = all(&folder, &KEY);
515 assert_eq!((entries[0].working, entries[0].closed, entries[0].exported), (false, 1_100, 1_200));
516 change(&own, vault, |e| e.start(1_300)).unwrap();
518 change(&own, vault, |e| e.exported(1_310_000, Some((1, 9)), 1_311)).unwrap();
519 assert!(all(&folder, &KEY).0[0].working);
520 change(&own, vault, |e| e.exported(0, None, 1_400)).unwrap();
522 assert_eq!(all(&folder, &KEY).0[0].exported, 1_310);
523 let _ = std::fs::remove_dir_all(&dir);
524 }
525
526 struct Device {
530 roots: Roots,
531 id: String,
532 memory: PathBuf,
533 kind: &'static str,
534 }
535
536 impl Device {
537 fn new(base: &Path, name: &str, kind: &'static str) -> Device {
538 let root = base.join(name);
539 let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
540 for dir in [&roots.config, &roots.data, &roots.state] {
541 std::fs::create_dir_all(dir).unwrap();
542 }
543 let memory = roots.state.join("share").join("memory.json");
544 Device { roots, id: uuid::Uuid::new_v4().to_string(), memory, kind }
545 }
546
547 fn stores(&self) -> Vec<Store> {
548 crate::share::stores(&Config::default(), &self.roots)
549 }
550
551 fn exchange(&self, folder: &Path, now: i64) -> crate::share::Outcome {
554 let outcome = crate::share::exchange(&Sharing { folder, computer: &self.id, key: &KEY, memory: &self.memory, files: false, hurry: None }, &self.stores(), now * 1000).unwrap();
555 change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| e.exported(now * 1000, outcome.wrote, now)).unwrap();
557 outcome
558 }
559
560 fn session(&self, folder: &Path, f: impl FnOnce(&mut Entry)) {
561 let kind = self.kind;
562 let id = self.id.clone();
563 change(&own_path(&self.roots.state), Some((folder, &KEY)), |e| {
564 e.id = id;
565 e.kind = kind.into();
566 e.doses = true;
567 f(e);
568 })
569 .unwrap();
570 }
571
572 fn records(&self) -> PathBuf {
573 self.roots.state.join(sioul_core::doses::FILE)
574 }
575
576 fn marks(&self) -> PathBuf {
577 self.roots.state.join("health-state.toml")
578 }
579
580 fn answer(&self, key: &str, state: &str, at: i64) {
583 DoseRecords::update(&self.records(), at, |r| r.answer(key, &self.id, Answer { state: state.into(), at, noted: at, name: self.kind.into(), kind: self.kind.into() })).unwrap();
584 HealthState::update(&self.marks(), at, |s| {
585 if state == TAKEN {
586 s.taken.insert(key.to_string(), at);
587 } else {
588 s.not_taken.insert(key.to_string(), at);
589 }
590 })
591 .unwrap();
592 }
593
594 fn sees(&self, folder: &Path, other: &Device, now: i64) -> Peer {
597 let entry = all(folder, &KEY).0.into_iter().find(|e| e.id == other.id).expect("its entry");
598 let heard = crate::share::heard(&self.memory, &self.id);
599 Peer { id: other.id.clone(), name: entry.name.clone(), said: Some(entry.said()), complete: entry.wrote.is_none_or(|wrote| heard.complete(&other.id, wrote)), seen: now, heard: entry.exported, ..Peer::default() }
600 }
601
602 fn answered(&self, key: &str) -> Answered {
603 sioul_core::doses::answered(key, &HealthState::read(&self.marks()).unwrap(), &DoseRecords::read(&self.records()).unwrap())
604 }
605 }
606
607 const DUE: i64 = 1_800_000_000;
608 const DOSE: &str = "levo@1800000000";
609
610 fn pair(name: &str) -> (PathBuf, PathBuf, Device, Device) {
612 let base = scratch(name);
613 let folder = base.join("shared");
614 std::fs::create_dir_all(&folder).unwrap();
615 let desk = Device::new(&base, "desk", COMPUTER);
616 let phone = Device::new(&base, "phone", PHONE);
617 desk.session(&folder, |e| e.start(DUE - 7_200));
618 phone.session(&folder, |e| e.start(DUE - 7_200));
619 desk.exchange(&folder, DUE - 7_100);
620 phone.exchange(&folder, DUE - 7_090);
621 desk.exchange(&folder, DUE - 7_080);
622 (base, folder, desk, phone)
623 }
624
625 #[test]
626 fn the_phone_marks_taken_in_the_background_and_the_desktop_shows_taken() {
627 let (base, folder, desk, phone) = pair("background");
628 phone.exchange(&folder, DUE - 3_600);
630 phone.session(&folder, |e| e.close(DUE - 3_595));
631 desk.exchange(&folder, DUE - 3_500);
632 let at_dose = desk.sees(&folder, &phone, DUE + 30);
634 assert!(doubts_now(DUE, DUE + 30, None, &[at_dose]).is_empty());
635 assert_eq!(desk.answered(DOSE), Answered::Open);
636 phone.session(&folder, |e| e.start(DUE + 300));
639 phone.answer(DOSE, TAKEN, DUE + 300);
640 phone.exchange(&folder, DUE + 302);
641 phone.session(&folder, |e| e.close(DUE + 303));
642 desk.exchange(&folder, DUE + 360);
644 match desk.answered(DOSE) {
645 Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), (phone.id.as_str(), DUE + 300)),
646 other => panic!("{other:?}"),
647 }
648 assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
649 assert!(HealthState::read(&desk.marks()).unwrap().taken.contains_key(DOSE));
651 let _ = std::fs::remove_dir_all(&base);
652 }
653
654 #[test]
661 fn the_phone_put_away_and_quiet_is_known_without_rewriting_its_entry() {
662 let (base, folder, desk, phone) = pair("quiet-phone");
663 phone.exchange(&folder, DUE - 3_600);
664 phone.session(&folder, |e| e.close(DUE - 3_595));
665 let entry = std::fs::read(file_of(&folder, &phone.id)).unwrap();
666 for n in 0..12 {
667 phone.exchange(&folder, DUE - 3_500 + n * 300);
668 desk.exchange(&folder, DUE - 3_490 + n * 300);
669 }
670 assert_eq!(std::fs::read(file_of(&folder, &phone.id)).unwrap(), entry, "nothing new: its entry as it was");
671 for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
672 assert!(doubts_now(DUE, at, None, &[desk.sees(&folder, &phone, at)]).is_empty(), "known at {at}");
673 }
674 phone.session(&folder, |e| e.start(DUE + 300));
676 assert!(matches!(doubts_now(DUE, DUE + 310, None, &[desk.sees(&folder, &phone, DUE + 310)]).as_slice(), [Doubt::Working { .. }]));
677 phone.answer(DOSE, TAKEN, DUE + 300);
678 phone.exchange(&folder, DUE + 302);
679 phone.session(&folder, |e| e.close(DUE + 303));
680 desk.exchange(&folder, DUE + 360);
681 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
682 assert!(doubts_now(DUE, DUE + 360, None, &[desk.sees(&folder, &phone, DUE + 360)]).is_empty());
683 let _ = std::fs::remove_dir_all(&base);
684 }
685
686 #[test]
695 fn a_dose_is_known_with_seen_as_the_desktop_keeps_it() {
696 let (base, folder, desk, phone) = pair("seen-kept");
697 let mut watched: Option<((i64, i64, bool, i64, bool), i64)> = None;
698 let mut sees = |at: i64| -> Peer {
699 let entry = all(&folder, &KEY).0.into_iter().find(|e| e.id == phone.id).expect("its entry");
700 let state = (entry.started, entry.closed, entry.working, entry.exported, entry.left);
701 if watched.is_none_or(|(last, _)| last != state) {
702 watched = Some((state, at));
703 }
704 Peer { seen: watched.map_or(at, |(_, seen)| seen), ..desk.sees(&folder, &phone, at) }
705 };
706 phone.exchange(&folder, DUE - 3_600);
707 phone.session(&folder, |e| e.close(DUE - 3_595));
708 desk.exchange(&folder, DUE - 3_590);
709 let closed_seen = sees(DUE - 3_590).seen;
710 for n in 0..12 {
711 phone.exchange(&folder, DUE - 3_500 + n * 300);
712 desk.exchange(&folder, DUE - 3_490 + n * 300);
713 assert_eq!(sees(DUE - 3_490 + n * 300).seen, closed_seen, "put away and quiet: its entry not seen changing");
714 }
715 for at in [DUE + 30, DUE + 3_600, DUE + 6 * 3_600] {
716 let peer = sees(at);
717 assert!(at - peer.seen > sioul_core::health::FRESH);
718 assert!(doubts_now(DUE, at, None, &[peer]).is_empty(), "closed, its seen {} s old: known at {at}", at - closed_seen);
719 }
720 let later = DUE + 7 * 3_600;
722 phone.session(&folder, |e| e.start(later));
723 for n in 1..=5 {
724 phone.exchange(&folder, later + n * 60);
725 desk.exchange(&folder, later + n * 60 + 5);
726 assert!(doubts_now(later, later + n * 60 + 10, None, &[sees(later + n * 60 + 10)]).is_empty() || n * 60 < sioul_core::health::SKEW, "in use, exporting: known at minute {n}");
727 }
728 let stopped = later + 5 * 60 + 10 + sioul_core::health::FRESH + 60;
730 desk.exchange(&folder, stopped - 5);
731 assert!(matches!(doubts_now(later, stopped, None, &[sees(stopped)]).as_slice(), [Doubt::Working { .. }]), "a phone in use gone quiet: the doubt said");
732 let _ = std::fs::remove_dir_all(&base);
733 }
734
735 #[test]
736 fn the_phone_raised_but_not_exported_is_uncertain_and_its_records_behind_its_entry_too() {
737 let (base, folder, desk, phone) = pair("raised");
738 phone.session(&folder, |e| e.start(DUE + 300));
740 phone.answer(DOSE, TAKEN, DUE + 300);
741 desk.exchange(&folder, DUE + 400);
742 assert_eq!(desk.answered(DOSE), Answered::Open, "its answer not exported");
743 let doubts = doubts_now(DUE, DUE + 400, None, &[desk.sees(&folder, &phone, DUE + 400)]);
744 assert!(matches!(doubts.as_slice(), [Doubt::Working { device, .. }] if device.id == phone.id), "{doubts:?}");
745 let rounds = || -> Vec<PathBuf> { std::fs::read_dir(&folder).unwrap().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.file_name().unwrap().to_string_lossy().starts_with(&phone.id) && p.extension().is_some_and(|x| x == "jsonl")).collect() };
748 let before: Vec<(PathBuf, usize)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap().len())).collect();
749 phone.exchange(&folder, DUE + 500);
750 phone.session(&folder, |e| e.close(DUE + 501));
751 let held: Vec<(PathBuf, Vec<u8>)> = rounds().into_iter().map(|p| (p.clone(), std::fs::read(&p).unwrap())).collect();
752 for (path, bytes) in &held {
753 let had = before.iter().find(|(p, _)| p == path).map_or(0, |(_, size)| *size);
754 std::fs::write(path, &bytes[..had + (bytes.len() - had) / 2]).unwrap();
755 }
756 desk.exchange(&folder, DUE + 510);
757 let behind = desk.sees(&folder, &phone, DUE + 520);
758 assert!(!behind.complete);
759 assert!(matches!(doubts_now(DUE, DUE + 520, None, &[behind]).as_slice(), [Doubt::Coming { .. }]));
760 for (path, bytes) in &held {
761 std::fs::write(path, bytes).unwrap();
762 }
763 desk.exchange(&folder, DUE + 560);
764 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
765 assert!(doubts_now(DUE, DUE + 560, None, &[desk.sees(&folder, &phone, DUE + 560)]).is_empty());
766 let _ = std::fs::remove_dir_all(&base);
767 }
768
769 #[test]
770 fn the_same_dose_answered_on_both_devices_keeps_both() {
771 let (base, folder, desk, phone) = pair("both");
772 phone.answer(DOSE, TAKEN, DUE + 120);
774 desk.answer(DOSE, SKIPPED, DUE + 600);
775 phone.exchange(&folder, DUE + 700);
776 desk.exchange(&folder, DUE + 710);
777 phone.exchange(&folder, DUE + 720);
778 for device in [&desk, &phone] {
780 match device.answered(DOSE) {
781 Answered::Differ(given) => {
782 let said: Vec<(String, sioul_core::doses::State)> = given.iter().map(|g| (g.device.clone(), g.state.clone())).collect();
783 assert_eq!(said, [(phone.id.clone(), sioul_core::doses::State::Taken), (desk.id.clone(), sioul_core::doses::State::Skipped)]);
784 }
785 other => panic!("{other:?}"),
786 }
787 }
788 DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: TAKEN.into(), at: DUE + 120, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
790 HealthState::update(&desk.marks(), DUE + 800, |s| {
792 s.not_taken.remove(DOSE);
793 s.taken.insert(DOSE.into(), DUE + 120);
794 })
795 .unwrap();
796 desk.exchange(&folder, DUE + 810);
797 phone.exchange(&folder, DUE + 820);
798 assert!(matches!(phone.answered(DOSE), Answered::Taken(ref g) if g.device == phone.id));
799 let _ = std::fs::remove_dir_all(&base);
800 }
801
802 #[test]
803 fn a_choice_or_a_take_back_on_one_device_settles_both() {
804 let (base, folder, desk, phone) = pair("settle");
805 phone.answer(DOSE, TAKEN, DUE + 120);
807 desk.answer(DOSE, SKIPPED, DUE + 600);
808 phone.exchange(&folder, DUE + 700);
809 desk.exchange(&folder, DUE + 710);
810 phone.exchange(&folder, DUE + 720);
811 assert!(matches!(phone.answered(DOSE), Answered::Differ(_)));
812 DoseRecords::update(&desk.records(), DUE + 800, |r| r.choose(DOSE, &desk.id, Answer { state: SKIPPED.into(), at: DUE + 800, noted: DUE + 800, name: "desk".into(), kind: COMPUTER.into() })).unwrap();
814 HealthState::update(&desk.marks(), DUE + 800, |s| {
815 s.taken.remove(DOSE);
816 s.not_taken.insert(DOSE.into(), DUE + 800);
817 })
818 .unwrap();
819 desk.exchange(&folder, DUE + 810);
820 phone.exchange(&folder, DUE + 820);
821 desk.exchange(&folder, DUE + 830);
822 for device in [&desk, &phone] {
823 let answered = device.answered(DOSE);
824 assert!(matches!(answered, Answered::Skipped(ref g) if g.device == desk.id), "{answered:?}");
825 }
826 let iron = "iron@1800000000";
828 phone.answer(iron, TAKEN, DUE + 900);
829 phone.exchange(&folder, DUE + 910);
830 desk.exchange(&folder, DUE + 920);
831 assert!(matches!(desk.answered(iron), Answered::Taken(_)));
832 DoseRecords::update(&desk.records(), DUE + 1_000, |r| r.take_back(iron)).unwrap();
833 HealthState::update(&desk.marks(), DUE + 1_000, |s| {
834 s.taken.remove(iron);
835 })
836 .unwrap();
837 desk.exchange(&folder, DUE + 1_010);
838 phone.exchange(&folder, DUE + 1_020);
839 desk.exchange(&folder, DUE + 1_030);
840 for device in [&desk, &phone] {
841 assert_eq!(device.answered(iron), Answered::Open);
842 }
843 let _ = std::fs::remove_dir_all(&base);
844 }
845
846 #[test]
847 fn a_canary_opened_on_two_devices_merges_into_one_record() {
848 let (base, folder, desk, phone) = pair("canary");
849 DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
850 DoseRecords::update(&phone.records(), DUE + 20, |r| r.open(DOSE, &phone.id, DUE + 20)).unwrap();
851 desk.exchange(&folder, DUE + 60);
852 phone.exchange(&folder, DUE + 70);
853 desk.exchange(&folder, DUE + 80);
854 for device in [&desk, &phone] {
855 let records = DoseRecords::read(&device.records()).unwrap();
856 assert_eq!(records.dose.len(), 1);
857 assert_eq!(records.dose[DOSE].opened.len(), 2, "both openings kept");
858 assert_eq!(records.opened_first(DOSE), Some((phone.id.clone(), DUE + 20)));
859 assert_eq!(device.answered(DOSE), Answered::Open, "not taken yet: the canary");
860 }
861 phone.answer(DOSE, TAKEN, DUE + 90);
863 phone.exchange(&folder, DUE + 100);
864 desk.exchange(&folder, DUE + 110);
865 assert!(matches!(desk.answered(DOSE), Answered::Taken(_)));
866 assert_eq!(DoseRecords::read(&desk.records()).unwrap().dose[DOSE].opened.len(), 2);
867 let _ = std::fs::remove_dir_all(&base);
868 }
869
870 #[test]
871 fn an_older_sioul_takes_nothing_out_and_its_marks_still_count() {
872 let base = scratch("older");
873 let folder = base.join("shared");
874 std::fs::create_dir_all(&folder).unwrap();
875 let desk = Device::new(&base, "desk", COMPUTER);
876 let old = Device::new(&base, "old", PHONE);
877 let old_stores = || old.stores().into_iter().filter(|s| s.name != "state/health-doses.toml").collect::<Vec<_>>();
879 let old_exchange = |now: i64| crate::share::exchange(&Sharing { folder: &folder, computer: &old.id, key: &KEY, memory: &old.memory, files: false, hurry: None }, &old_stores(), now * 1000).unwrap();
880 desk.session(&folder, |e| e.start(DUE - 3_600));
881 desk.exchange(&folder, DUE - 3_500);
882 old_exchange(DUE - 3_400);
883 DoseRecords::update(&desk.records(), DUE + 30, |r| r.open(DOSE, &desk.id, DUE + 30)).unwrap();
885 desk.answer("iron@1800000000", SKIPPED, DUE + 60);
886 desk.exchange(&folder, DUE + 90);
887 for n in 0..3 {
888 old_exchange(DUE + 100 + n);
889 }
890 desk.exchange(&folder, DUE + 200);
891 let records = DoseRecords::read(&desk.records()).unwrap();
892 assert!(records.dose.contains_key(DOSE) && records.dose.contains_key("iron@1800000000"), "nothing taken out by the older one");
893 HealthState::update(&old.marks(), DUE + 300, |s| {
895 s.taken.insert(DOSE.into(), DUE + 300);
896 })
897 .unwrap();
898 old_exchange(DUE + 310);
899 desk.exchange(&folder, DUE + 320);
900 assert!(matches!(desk.answered(DOSE), Answered::Taken(ref g) if g.device.is_empty()));
901 assert!(all(&folder, &KEY).0.iter().all(|e| e.id != old.id));
903 let _ = std::fs::remove_dir_all(&base);
904 }
905}