undo.rsannotatedundo.rssource157 lines · 5.5 KB · raw
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}