1use crate::health::{HealthState, Problem, Unsound};
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30pub const KEPT_DAYS: i64 = 9;
33
34pub const FILE: &str = "health-doses.toml";
36
37#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
39pub struct DoseRecords {
40 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
41 pub dose: BTreeMap<String, Record>,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct Record {
47 #[serde(default, skip_serializing_if = "String::is_empty")]
48 pub medicine: String,
49 #[serde(default)]
51 pub due: i64,
52 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
54 pub opened: BTreeMap<String, i64>,
55 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57 pub answer: BTreeMap<String, Answer>,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Answer {
63 pub state: String,
65 #[serde(default)]
67 pub at: i64,
68 #[serde(default)]
70 pub noted: i64,
71 #[serde(default, skip_serializing_if = "String::is_empty")]
73 pub name: String,
74 #[serde(default, skip_serializing_if = "String::is_empty")]
75 pub kind: String,
76}
77
78pub const TAKEN: &str = "taken";
79pub const SKIPPED: &str = "skipped";
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum State {
84 Taken,
85 Skipped,
86 Other(String),
88}
89
90impl State {
91 fn of(word: &str) -> State {
92 match word {
93 TAKEN => State::Taken,
94 SKIPPED => State::Skipped,
95 other => State::Other(other.to_string()),
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Given {
103 pub state: State,
104 pub device: String,
107 pub name: String,
108 pub kind: String,
109 pub at: i64,
111 pub noted: i64,
113}
114
115impl Given {
116 fn captured(&self) -> i64 {
119 if self.noted > 0 { self.noted } else { self.at }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum Answered {
126 Open,
129 Taken(Given),
131 Skipped(Given),
133 Differ(Vec<Given>),
135}
136
137fn parts(key: &str) -> (String, i64) {
139 match key.rsplit_once('@') {
140 Some((medicine, due)) => (medicine.to_string(), due.parse().unwrap_or(0)),
141 None => (key.to_string(), 0),
142 }
143}
144
145pub fn answered(key: &str, state: &HealthState, records: &DoseRecords) -> Answered {
150 let mut given: Vec<Given> = records
151 .dose
152 .get(key)
153 .map(|record| record.answer.iter().map(|(device, a)| Given { state: State::of(&a.state), device: device.clone(), name: a.name.clone(), kind: a.kind.clone(), at: a.at, noted: a.noted }).collect())
154 .unwrap_or_default();
155 let has = |given: &[Given], wanted: &State| given.iter().any(|g| g.state == *wanted);
156 if let Some(at) = state.taken.get(key).copied().filter(|_| !has(&given, &State::Taken)) {
157 given.push(Given { state: State::Taken, device: String::new(), name: String::new(), kind: String::new(), at, noted: 0 });
158 }
159 if let Some(at) = state.not_taken.get(key).copied().filter(|_| !has(&given, &State::Skipped)) {
160 given.push(Given { state: State::Skipped, device: String::new(), name: String::new(), kind: String::new(), at, noted: at });
161 }
162 given.sort_by(|a, b| a.captured().cmp(&b.captured()).then_with(|| a.device.cmp(&b.device)));
163 let Some(first) = given.first().cloned() else { return Answered::Open };
164 if given.iter().any(|g| g.state != first.state) {
165 return Answered::Differ(given);
166 }
167 match first.state {
168 State::Taken => Answered::Taken(first),
169 State::Skipped => Answered::Skipped(first),
170 State::Other(_) => Answered::Differ(given),
171 }
172}
173
174impl DoseRecords {
175 pub fn default_path() -> PathBuf {
176 crate::config::state_dir().join(FILE)
177 }
178
179 pub fn read(path: &Path) -> Result<DoseRecords, Unsound> {
184 match std::fs::read_to_string(path) {
185 Ok(text) if text.trim().is_empty() && crate::health::witnessed(path) => Err(Unsound::Lost),
186 Ok(text) => toml::from_str(&text).map_err(|_| Unsound::Unreadable),
187 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
188 if crate::health::witnessed(path) {
189 Err(Unsound::Lost)
190 } else {
191 Ok(DoseRecords::default())
192 }
193 }
194 Err(_) => Err(Unsound::Unreadable),
195 }
196 }
197
198 pub fn update<T>(path: &Path, now: i64, change: impl FnOnce(&mut DoseRecords) -> T) -> Result<T, Problem> {
202 crate::filelock::with_lock(path, || {
203 let mut records = DoseRecords::read(path).map_err(Problem::Unsound)?;
204 let out = change(&mut records);
205 records.save(path, now).map_err(Problem::Write)?;
206 Ok(out)
207 })
208 }
209
210 pub fn set_aside(path: &Path, now: i64) -> Result<Option<PathBuf>, String> {
214 crate::filelock::with_lock(path, || {
215 for witness in crate::health::witnesses(path) {
216 let _ = std::fs::remove_file(witness);
217 }
218 if !path.exists() {
219 return Ok(None);
220 }
221 let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
222 let aside = path.with_file_name(format!("{name}.unreadable-{now}"));
223 std::fs::rename(path, &aside).map_err(|e| format!("{}: {e}", path.display()))?;
224 Ok(Some(aside))
225 })
226 }
227
228 fn save(&mut self, path: &Path, now: i64) -> Result<(), String> {
230 let oldest = now - KEPT_DAYS * 86_400;
231 self.dose.retain(|key, record| (if record.due > 0 { record.due } else { parts(key).1 }) >= oldest);
232 let text = format!("# Each dose that fell due, and the answers your devices captured (docs/health.md).\n{}", toml::to_string(self).map_err(|e| e.to_string())?);
233 crate::health::write_witnessed(path, text.as_bytes())
234 }
235
236 fn record(&mut self, key: &str) -> &mut Record {
237 self.dose.entry(key.to_string()).or_insert_with(|| {
238 let (medicine, due) = parts(key);
239 Record { medicine, due, ..Record::default() }
240 })
241 }
242
243 pub fn open(&mut self, key: &str, device: &str, now: i64) -> bool {
246 if self.dose.get(key).is_some_and(|r| !r.opened.is_empty() || !r.answer.is_empty()) {
247 return false;
248 }
249 self.record(key).opened.insert(device.to_string(), now);
250 true
251 }
252
253 pub fn opened_first(&self, key: &str) -> Option<(String, i64)> {
255 self.dose.get(key)?.opened.iter().min_by_key(|(device, at)| (**at, (*device).clone())).map(|(device, at)| (device.clone(), *at))
256 }
257
258 pub fn answer(&mut self, key: &str, device: &str, answer: Answer) {
262 self.record(key).answer.insert(device.to_string(), answer);
263 }
264
265 pub fn choose(&mut self, key: &str, device: &str, answer: Answer) {
268 let state = answer.state.clone();
269 let record = self.record(key);
270 record.answer.retain(|other, given| other == device || given.state == state);
271 record.answer.insert(device.to_string(), answer);
272 }
273
274 pub fn take_back(&mut self, key: &str) {
277 if let Some(record) = self.dose.get_mut(key) {
278 record.answer.retain(|_, given| given.state != TAKEN);
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn scratch(name: &str) -> PathBuf {
288 let dir = std::env::temp_dir().join(format!("sioul-doses-{name}-{}", std::process::id()));
289 let _ = std::fs::remove_dir_all(&dir);
290 std::fs::create_dir_all(&dir).unwrap();
291 dir
292 }
293
294 fn said(state: &str, at: i64, noted: i64, name: &str) -> Answer {
295 Answer { state: state.into(), at, noted, name: name.into(), kind: if name == "phone" { "phone".into() } else { "computer".into() } }
296 }
297
298 const KEY: &str = "levo@1800000000";
299
300 #[test]
301 fn records_that_cannot_be_trusted_are_never_taken_for_empty() {
302 let dir = scratch("trust");
303 let path = dir.join("health-doses.toml");
304 assert_eq!(DoseRecords::read(&path), Ok(DoseRecords::default()), "never written: empty, honestly");
305 DoseRecords::update(&path, 1_800_000_100, |r| r.answer(KEY, "phone-id", said(TAKEN, 1_800_000_060, 1_800_000_090, "phone"))).unwrap();
306 assert!(DoseRecords::read(&path).unwrap().dose[KEY].answer.contains_key("phone-id"));
307 std::fs::write(&path, "[dose.\"levo@1800000000\"\nstate =").unwrap();
309 assert_eq!(DoseRecords::read(&path), Err(Unsound::Unreadable));
310 assert_eq!(DoseRecords::update(&path, 1_800_000_200, |r| r.dose.clear()), Err(Problem::Unsound(Unsound::Unreadable)));
311 assert_eq!(std::fs::read_to_string(&path).unwrap(), "[dose.\"levo@1800000000\"\nstate =", "left as it was");
312 std::fs::remove_file(&path).unwrap();
314 assert_eq!(DoseRecords::read(&path), Err(Unsound::Lost));
315 assert_eq!(DoseRecords::set_aside(&path, 1_800_000_300), Ok(None));
316 assert_eq!(DoseRecords::read(&path), Ok(DoseRecords::default()), "set aside: new ones can start");
317 let _ = std::fs::remove_dir_all(&dir);
318 }
319
320 #[test]
321 fn answers_merge_by_the_earliest_and_answers_that_differ_both_stay() {
322 let empty = HealthState::default();
323 let mut records = DoseRecords::default();
324 assert_eq!(answered(KEY, &empty, &records), Answered::Open, "nothing anywhere: the canary");
325 records.open(KEY, "desk-id", 1_800_000_010);
326 assert_eq!(answered(KEY, &empty, &records), Answered::Open, "opened, not answered");
327 records.answer(KEY, "desk-id", said(TAKEN, 1_800_000_600, 1_800_000_600, "desk"));
329 records.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
330 match answered(KEY, &empty, &records) {
331 Answered::Taken(given) => assert_eq!((given.device.as_str(), given.at), ("phone-id", 1_800_000_120)),
332 other => panic!("{other:?}"),
333 }
334 records.answer(KEY, "desk-id", said(SKIPPED, 1_800_000_600, 1_800_000_600, "desk"));
336 match answered(KEY, &empty, &records) {
337 Answered::Differ(given) => {
338 assert_eq!(given.iter().map(|g| (g.device.as_str(), g.state.clone())).collect::<Vec<_>>(), [("phone-id", State::Taken), ("desk-id", State::Skipped)]);
339 }
340 other => panic!("{other:?}"),
341 }
342 records.choose(KEY, "desk-id", said(TAKEN, 1_800_000_700, 1_800_000_700, "desk"));
344 assert!(matches!(answered(KEY, &empty, &records), Answered::Taken(ref g) if g.device == "phone-id"));
345 records.choose(KEY, "desk-id", said(SKIPPED, 1_800_000_800, 1_800_000_800, "desk"));
347 assert!(matches!(answered(KEY, &empty, &records), Answered::Skipped(ref g) if g.device == "desk-id"));
348 assert_eq!(records.dose[KEY].answer.len(), 1);
349 records.answer(KEY, "tablet-id", said("half", 1_800_000_900, 1_800_000_900, "tablet"));
351 assert!(matches!(answered(KEY, &empty, &records), Answered::Differ(ref g) if g.len() == 2));
352 let mut back = DoseRecords::default();
354 back.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
355 back.take_back(KEY);
356 assert_eq!(answered(KEY, &empty, &back), Answered::Open);
357 }
358
359 #[test]
360 fn the_marks_of_older_sioul_are_answers_too() {
361 let records = DoseRecords::default();
362 let mut state = HealthState::default();
364 state.taken.insert(KEY.into(), 1_800_000_120);
365 assert!(matches!(answered(KEY, &state, &records), Answered::Taken(ref g) if g.device.is_empty() && g.at == 1_800_000_120));
366 let mut both = DoseRecords::default();
368 both.answer(KEY, "phone-id", said(TAKEN, 1_800_000_120, 1_800_000_125, "phone"));
369 assert!(matches!(answered(KEY, &state, &both), Answered::Taken(ref g) if g.device == "phone-id"));
370 let mut not = HealthState::default();
372 not.not_taken.insert(KEY.into(), 1_800_000_300);
373 assert!(matches!(answered(KEY, ¬, &both), Answered::Differ(ref g) if g.len() == 2));
374 state.not_taken.insert(KEY.into(), 1_800_000_300);
376 assert!(matches!(answered(KEY, &state, &records), Answered::Differ(_)));
377 }
378
379 #[test]
380 fn a_canary_opened_on_two_devices_is_one_record() {
381 let mut desk = DoseRecords::default();
382 assert!(desk.open(KEY, "desk-id", 1_800_000_030));
383 assert!(!desk.open(KEY, "desk-id", 1_800_000_090), "opened once");
384 desk.dose.get_mut(KEY).unwrap().opened.insert("phone-id".into(), 1_800_000_020);
386 assert_eq!(desk.dose.len(), 1);
387 assert_eq!(desk.opened_first(KEY), Some(("phone-id".into(), 1_800_000_020)));
388 assert_eq!(desk.dose[KEY].medicine, "levo");
389 assert_eq!(desk.dose[KEY].due, 1_800_000_000);
390 }
391
392 #[test]
393 fn records_go_nine_days_after_their_dose() {
394 let dir = scratch("prune");
395 let path = dir.join("health-doses.toml");
396 let due = 1_800_000_000;
397 DoseRecords::update(&path, due + 60, |r| {
398 r.open(KEY, "desk-id", due + 30);
399 r.open("iron@1800600000", "desk-id", 1_800_600_030);
400 })
401 .unwrap();
402 DoseRecords::update(&path, due + 8 * 86_400, |_| ()).unwrap();
404 assert_eq!(DoseRecords::read(&path).unwrap().dose.len(), 2);
405 DoseRecords::update(&path, due + 10 * 86_400, |_| ()).unwrap();
407 assert_eq!(DoseRecords::read(&path).unwrap().dose.keys().collect::<Vec<_>>(), ["iron@1800600000"]);
408 DoseRecords::update(&path, due + 10 * 86_400, |r| r.answer("iron@1800600000", "phone-id", said(TAKEN, 1_800_600_100, 1_800_600_110, "phone"))).unwrap();
410 let text = std::fs::read_to_string(&path).unwrap();
411 assert!(text.contains("[dose.\"iron@1800600000\".answer.phone-id]"), "{text}");
412 let _ = std::fs::remove_dir_all(&dir);
413 }
414}