undo.rsannotatedundo.rssource157 lines · 5.5 KB · raw

Undoing a soft delete.

When she puts a memory away (a soft delete: it stays, only hidden) the store writes a [SoftAction] and the shell offers "Undo" for [UNDO_WINDOW_MS]. undo(action_id) moves the thing back, and is refused after the window, for an action that was already undone, and for anything that is not a soft action in the log: the parents' permanent forget is never one, and forgetting a memory deletes its actions with it (the store's schema cascades), so there is nothing left to undo and no way to write that down.

This module is the rule and nothing else: a store keeps the log and does the moving.

How long after a soft delete it can be undone: long enough to see the offer, read it or hear it and tap, short enough that the memory is not left in doubt.

13pub const UNDO_WINDOW_MS: u64 = 8_000;

What kind of soft delete an action was. One kind exists today; every new kind of soft delete is a variant here and a way to move it back in the store.

17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum SoftKind {

She put a memory away.

20    PutAway,
21}
23impl SoftKind {

The word the store writes.

25    pub fn word(self) -> &'static str {
26        match self {
27            SoftKind::PutAway => "put_away",
28        }
29    }
31    pub fn from_word(word: &str) -> Option<SoftKind> {
32        match word {
33            "put_away" => Some(SoftKind::PutAway),
34            _ => None,
35        }
36    }
37}

One entry of the log of soft deletes.

40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct SoftAction {

This holder's own number for it; never reused.

43    pub id: u64,
44    pub kind: SoftKind,

The identity of the memory it was done to.

46    pub gid: String,

When it was done, by the clock that will be asked about undoing it.

48    pub at_ms: u64,

Whether it has been undone.

50    pub undone: bool,
51}

Why an undo was refused.

54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum UndoRefusal {

There is no such soft action: the number was never one (a forget is not a soft action), or the memory it was done to has been forgotten.

58    Gone,

It was undone already.

60    Spent,

The window has closed.

62    Closed,
63}
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 {

When the window closes.

79    pub fn closes_at_ms(&self) -> u64 {
80        self.at_ms.saturating_add(UNDO_WINDOW_MS)
81    }

How long is left of the window at now_ms; never more than the window, so a clock that went back does not 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    }

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}

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}
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}