1//! Undoing a soft delete. 2//! 3//! When she puts a memory away (a soft delete: it stays, only hidden) the store writes a [`SoftAction`] and the 4//! shell offers "Undo" for [`UNDO_WINDOW_MS`]. `undo(action_id)` moves the thing back, and is refused after the 5//! window, for an action that was already undone, and for anything that is not a soft action in the log: the 6//! parents' permanent forget is never one, and forgetting a memory deletes its actions with it (the store's 7//! schema cascades), so there is nothing left to undo and no way to write that down. 8//! 9//! This module is the rule and nothing else: a store keeps the log and does the moving. 10 11/// How long after a soft delete it can be undone: long enough to see the offer, read it or hear it and tap, short 12/// enough that the memory is not left in doubt. 13pub const UNDO_WINDOW_MS: u64 = 8_000; 14 15/// What kind of soft delete an action was. One kind exists today; every new kind of soft delete is a variant here 16/// and a way to move it back in the store. 17#[derive(Clone, Copy, Debug, PartialEq, Eq)] 18pub enum SoftKind { 19 /// She put a memory away. 20 PutAway, 21} 22 23impl SoftKind { 24 /// The word the store writes. 25 pub fn word(self) -> &'static str { 26 match self { 27 SoftKind::PutAway => "put_away", 28 } 29 } 30 31 pub fn from_word(word: &str) -> Option<SoftKind> { 32 match word { 33 "put_away" => Some(SoftKind::PutAway), 34 _ => None, 35 } 36 } 37} 38 39/// One entry of the log of soft deletes. 40#[derive(Clone, Debug, PartialEq, Eq)] 41pub struct SoftAction { 42 /// This holder's own number for it; never reused. 43 pub id: u64, 44 pub kind: SoftKind, 45 /// The identity of the memory it was done to. 46 pub gid: String, 47 /// When it was done, by the clock that will be asked about undoing it. 48 pub at_ms: u64, 49 /// Whether it has been undone. 50 pub undone: bool, 51} 52 53/// Why an undo was refused. 54#[derive(Clone, Copy, Debug, PartialEq, Eq)] 55pub enum UndoRefusal { 56 /// There is no such soft action: the number was never one (a forget is not a soft action), or the memory it was 57 /// done to has been forgotten. 58 Gone, 59 /// It was undone already. 60 Spent, 61 /// The window has closed. 62 Closed, 63} 64 65impl std::fmt::Display for UndoRefusal { 66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 f.write_str(match self { 68 UndoRefusal::Gone => "there is nothing to undo", 69 UndoRefusal::Spent => "it was undone already", 70 UndoRefusal::Closed => "it is too late to undo it", 71 }) 72 } 73} 74 75impl std::error::Error for UndoRefusal {} 76 77impl SoftAction { 78 /// When the window closes. 79 pub fn closes_at_ms(&self) -> u64 { 80 self.at_ms.saturating_add(UNDO_WINDOW_MS) 81 } 82 83 /// How long is left of the window at `now_ms`; never more than the window, so a clock that went back does not 84 /// lengthen it. 85 pub fn remaining_ms(&self, now_ms: u64) -> u64 { 86 self.closes_at_ms().saturating_sub(now_ms).min(UNDO_WINDOW_MS) 87 } 88 89 /// Whether it may be undone at `now_ms`. 90 pub fn permits(&self, now_ms: u64) -> Result<(), UndoRefusal> { 91 if self.undone { 92 Err(UndoRefusal::Spent) 93 } else if now_ms >= self.closes_at_ms() { 94 Err(UndoRefusal::Closed) 95 } else { 96 Ok(()) 97 } 98 } 99} 100 101/// The newest action that can still be undone at `now_ms`: the one the shell offers. 102pub fn newest_open(actions: &[SoftAction], now_ms: u64) -> Option<&SoftAction> { 103 actions.iter().filter(|a| a.permits(now_ms).is_ok()).max_by_key(|a| (a.at_ms, a.id)) 104} 105 106#[cfg(test)] 107mod tests { 108 use super::*; 109 110 fn action(id: u64, at_ms: u64) -> SoftAction { 111 SoftAction { id, kind: SoftKind::PutAway, gid: format!("g{id}"), at_ms, undone: false } 112 } 113 114 #[test] 115 fn the_window_is_eight_seconds_and_closes_exactly_there() { 116 assert_eq!(UNDO_WINDOW_MS, 8_000); 117 let a = action(1, 1_000); 118 assert_eq!(a.permits(1_000), Ok(())); 119 assert_eq!(a.permits(8_999), Ok(())); 120 assert_eq!(a.permits(9_000), Err(UndoRefusal::Closed)); 121 assert_eq!(a.permits(1_000_000), Err(UndoRefusal::Closed)); 122 } 123 124 #[test] 125 fn an_action_that_was_undone_cannot_be_undone_again_even_inside_the_window() { 126 let mut a = action(1, 1_000); 127 a.undone = true; 128 assert_eq!(a.permits(1_001), Err(UndoRefusal::Spent)); 129 } 130 131 #[test] 132 fn a_clock_that_went_back_does_not_lengthen_the_window() { 133 let a = action(1, 50_000); 134 assert_eq!(a.remaining_ms(10_000), UNDO_WINDOW_MS); 135 assert_eq!(a.remaining_ms(53_000), 5_000); 136 assert_eq!(a.remaining_ms(58_000), 0); 137 assert_eq!(a.remaining_ms(99_000), 0); 138 } 139 140 #[test] 141 fn the_offer_is_the_newest_action_still_open() { 142 let mut spent = action(3, 5_000); 143 spent.undone = true; 144 let mut actions = vec![action(1, 1_000), action(2, 4_000), spent]; 145 assert_eq!(newest_open(&actions, 6_000).map(|a| a.id), Some(2), "the newest is spent, so the one before it"); 146 assert_eq!(newest_open(&actions, 12_500).map(|a| a.id), None, "all closed or spent"); 147 actions.push(action(4, 90_000)); 148 assert_eq!(newest_open(&actions, 91_000).map(|a| a.id), Some(4)); 149 assert_eq!(newest_open(&[], 1), None); 150 } 151 152 #[test] 153 fn a_kind_is_written_and_read_by_its_word_only() { 154 assert_eq!(SoftKind::from_word(SoftKind::PutAway.word()), Some(SoftKind::PutAway)); 155 assert_eq!(SoftKind::from_word("forget"), None, "a forget is not a soft action"); 156 } 157}