Skip to main content

fcp_core/
event_log.rs

1use std::collections::HashMap;
2
3/// Internal entry: either a real event or a checkpoint sentinel.
4#[derive(Debug, Clone)]
5enum Entry<T: Clone> {
6    Event(T),
7    Checkpoint,
8}
9
10/// Generic cursor-based event log with undo/redo and named checkpoints.
11///
12/// Events are appended at the cursor position. The cursor always points
13/// one past the last applied event. Undo moves the cursor back; redo
14/// moves it forward. Appending a new event truncates the redo tail.
15///
16/// Checkpoint sentinels are stored in the log but skipped during
17/// undo/redo traversal.
18pub struct EventLog<T: Clone> {
19    events: Vec<Entry<T>>,
20    cursor: usize,
21    checkpoints: HashMap<String, usize>,
22}
23
24impl<T: Clone> EventLog<T> {
25    /// Creates a new empty EventLog.
26    pub fn new() -> Self {
27        EventLog {
28            events: Vec::new(),
29            cursor: 0,
30            checkpoints: HashMap::new(),
31        }
32    }
33
34    /// Appends an event, truncating any redo history beyond the cursor.
35    pub fn append(&mut self, event: T) {
36        if self.cursor < self.events.len() {
37            self.events.truncate(self.cursor);
38            // Remove checkpoints pointing beyond new length
39            self.checkpoints.retain(|_, idx| *idx <= self.cursor);
40        }
41        self.events.push(Entry::Event(event));
42        self.cursor = self.events.len();
43    }
44
45    /// Creates a named checkpoint at the current cursor position, truncating
46    /// any redo tail first (same semantics as `append`).
47    pub fn checkpoint(&mut self, name: &str) {
48        if self.cursor < self.events.len() {
49            self.events.truncate(self.cursor);
50            // Remove checkpoints pointing beyond new length
51            self.checkpoints.retain(|_, idx| *idx <= self.cursor);
52        }
53        self.checkpoints.insert(name.to_string(), self.cursor);
54        self.events.push(Entry::Checkpoint);
55        self.cursor = self.events.len();
56    }
57
58    /// Undoes up to `count` non-checkpoint events. Returns events in reverse
59    /// order (most recent first) for the caller to reverse-apply.
60    pub fn undo(&mut self, count: usize) -> Vec<T> {
61        let mut result = Vec::new();
62        let mut pos = self.cursor as isize - 1;
63        let mut undone = 0;
64
65        while pos >= 0 && undone < count {
66            if let Entry::Event(ref ev) = self.events[pos as usize] {
67                result.push(ev.clone());
68                undone += 1;
69            }
70            pos -= 1;
71        }
72
73        self.cursor = (pos + 1) as usize;
74        result
75    }
76
77    /// Undoes to a named checkpoint. Returns events in reverse order.
78    /// Returns Err if the checkpoint doesn't exist or is at/beyond cursor.
79    pub fn undo_to(&mut self, name: &str) -> Result<Vec<T>, String> {
80        let target = match self.checkpoints.get(name) {
81            Some(&t) if t < self.cursor => t,
82            _ => return Err(format!("cannot undo to {name:?}")),
83        };
84
85        let mut result = Vec::new();
86        for i in (target..self.cursor).rev() {
87            if let Entry::Event(ref ev) = self.events[i] {
88                result.push(ev.clone());
89            }
90        }
91        self.cursor = target;
92        Ok(result)
93    }
94
95    /// Redoes up to `count` non-checkpoint events. Returns events in forward
96    /// order for the caller to re-apply.
97    pub fn redo(&mut self, count: usize) -> Vec<T> {
98        let mut result = Vec::new();
99        let mut pos = self.cursor;
100        let mut redone = 0;
101
102        while pos < self.events.len() && redone < count {
103            if let Entry::Event(ref ev) = self.events[pos] {
104                result.push(ev.clone());
105                redone += 1;
106            }
107            pos += 1;
108        }
109
110        self.cursor = pos;
111        result
112    }
113
114    /// Returns the last `count` non-checkpoint events (up to cursor) in
115    /// chronological order (oldest first). If count is 0, returns all.
116    pub fn recent(&self, count: usize) -> Vec<T> {
117        let limit = if count == 0 { self.cursor } else { count };
118        let mut result = Vec::new();
119        let mut i = self.cursor as isize - 1;
120        while i >= 0 && result.len() < limit {
121            if let Entry::Event(ref ev) = self.events[i as usize] {
122                result.push(ev.clone());
123            }
124            i -= 1;
125        }
126        result.reverse();
127        result
128    }
129
130    /// Returns the current cursor position (one past last applied event).
131    pub fn cursor(&self) -> usize {
132        self.cursor
133    }
134
135    /// Returns the total number of entries in the log (including checkpoints).
136    pub fn length(&self) -> usize {
137        self.events.len()
138    }
139
140    /// Returns whether there are events before the cursor that can be undone.
141    pub fn can_undo(&self) -> bool {
142        self.events[..self.cursor]
143            .iter()
144            .any(|e| matches!(e, Entry::Event(_)))
145    }
146
147    /// Returns whether there are events after the cursor that can be redone.
148    pub fn can_redo(&self) -> bool {
149        self.cursor < self.events.len()
150    }
151}
152
153impl<T: Clone> Default for EventLog<T> {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_append_and_cursor() {
165        let mut log = EventLog::new();
166        log.append("a");
167        log.append("b");
168        assert_eq!(log.cursor(), 2);
169        assert_eq!(log.length(), 2);
170    }
171
172    #[test]
173    fn test_recent() {
174        let mut log = EventLog::new();
175        log.append("a");
176        log.append("b");
177        log.append("c");
178
179        let got = log.recent(2);
180        assert_eq!(got, vec!["b", "c"]);
181
182        let all = log.recent(0);
183        assert_eq!(all, vec!["a", "b", "c"]);
184    }
185
186    #[test]
187    fn test_undo_most_recent() {
188        let mut log = EventLog::new();
189        log.append("a");
190        log.append("b");
191        let undone = log.undo(1);
192        assert_eq!(undone, vec!["b"]);
193        assert_eq!(log.cursor(), 1);
194    }
195
196    #[test]
197    fn test_undo_multiple() {
198        let mut log = EventLog::new();
199        log.append("a");
200        log.append("b");
201        log.append("c");
202        let undone = log.undo(2);
203        assert_eq!(undone, vec!["c", "b"]);
204        assert_eq!(log.cursor(), 1);
205    }
206
207    #[test]
208    fn test_undo_empty() {
209        let mut log: EventLog<&str> = EventLog::new();
210        let undone = log.undo(1);
211        assert!(undone.is_empty());
212    }
213
214    #[test]
215    fn test_undo_skips_checkpoints() {
216        let mut log = EventLog::new();
217        log.append("a");
218        log.checkpoint("cp1");
219        log.append("b");
220        let undone = log.undo(2);
221        assert_eq!(undone, vec!["b", "a"]);
222    }
223
224    #[test]
225    fn test_redo() {
226        let mut log = EventLog::new();
227        log.append("a");
228        log.append("b");
229        log.undo(2);
230        let redone = log.redo(2);
231        assert_eq!(redone, vec!["a", "b"]);
232        assert_eq!(log.cursor(), 2);
233    }
234
235    #[test]
236    fn test_redo_empty() {
237        let mut log = EventLog::new();
238        log.append("a");
239        let redone = log.redo(1);
240        assert!(redone.is_empty());
241    }
242
243    #[test]
244    fn test_redo_skips_checkpoints() {
245        let mut log = EventLog::new();
246        log.append("a");
247        log.checkpoint("cp1");
248        log.append("b");
249        log.undo(2);
250        let redone = log.redo(2);
251        assert_eq!(redone, vec!["a", "b"]);
252    }
253
254    #[test]
255    fn test_truncate_on_append() {
256        let mut log = EventLog::new();
257        log.append("a");
258        log.append("b");
259        log.undo(1); // cursor at 1, "b" in redo tail
260        log.append("c"); // should truncate "b"
261        assert_eq!(log.length(), 2);
262        let redone = log.redo(1);
263        assert!(redone.is_empty());
264        let all = log.recent(0);
265        assert_eq!(all, vec!["a", "c"]);
266    }
267
268    #[test]
269    fn test_checkpoint_undo_to() {
270        let mut log = EventLog::new();
271        log.append("a");
272        log.checkpoint("v1");
273        log.append("b");
274        log.append("c");
275        let undone = log.undo_to("v1").unwrap();
276        assert_eq!(undone, vec!["c", "b"]);
277        let recent = log.recent(0);
278        assert_eq!(recent, vec!["a"]);
279    }
280
281    #[test]
282    fn test_checkpoint_unknown_name() {
283        let mut log = EventLog::new();
284        log.append("a");
285        let result = log.undo_to("nonexistent");
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_checkpoint_truncates_redo_tail() {
291        // Regression test: checkpoint() must truncate the redo tail exactly
292        // like append() does. Before the fix, checkpoint() jumped the cursor
293        // to events.len() without dropping the un-truncated events after the
294        // cursor, so a subsequent undo would walk events that were never
295        // re-applied and corrupt the model.
296        let mut log = EventLog::new();
297        log.append("a");
298        log.append("b");
299        log.append("c");
300        log.undo(2); // cursor back to 1; "b" and "c" sit in the redo tail
301        log.checkpoint("cp1");
302        assert_eq!(log.length(), 2, "checkpoint should truncate the redo tail before appending its sentinel");
303        assert_eq!(log.recent(0), vec!["a"]);
304        assert!(!log.can_redo(), "redo tail must be gone after checkpoint");
305
306        let undone = log.undo(1);
307        assert_eq!(undone, vec!["a"], "undo after checkpoint must only see truly-applied events");
308    }
309
310    #[test]
311    fn test_checkpoint_removed_on_truncation() {
312        let mut log = EventLog::new();
313        log.append("a");
314        log.checkpoint("v1");
315        log.append("b");
316        log.undo(2); // undo b and a, cursor before checkpoint
317        log.append("x"); // truncates everything including checkpoint
318        let result = log.undo_to("v1");
319        assert!(result.is_err(), "checkpoint should be removed after truncation");
320    }
321
322    #[test]
323    fn test_cursor_starts_at_zero() {
324        let log: EventLog<&str> = EventLog::new();
325        assert_eq!(log.cursor(), 0);
326    }
327
328    #[test]
329    fn test_cursor_advances_on_append() {
330        let mut log = EventLog::new();
331        log.append("a");
332        assert_eq!(log.cursor(), 1);
333        log.append("b");
334        assert_eq!(log.cursor(), 2);
335    }
336
337    #[test]
338    fn test_cursor_moves_back_on_undo() {
339        let mut log = EventLog::new();
340        log.append("a");
341        log.append("b");
342        log.undo(1);
343        assert_eq!(log.cursor(), 1);
344    }
345
346    #[test]
347    fn test_cursor_moves_forward_on_redo() {
348        let mut log = EventLog::new();
349        log.append("a");
350        log.append("b");
351        log.undo(1);
352        log.redo(1);
353        assert_eq!(log.cursor(), 2);
354    }
355
356    #[test]
357    fn test_can_undo() {
358        let mut log: EventLog<&str> = EventLog::new();
359        assert!(!log.can_undo(), "CanUndo() should be false for empty log");
360        log.append("a");
361        assert!(log.can_undo(), "CanUndo() should be true after append");
362    }
363
364    #[test]
365    fn test_can_redo() {
366        let mut log: EventLog<&str> = EventLog::new();
367        assert!(!log.can_redo(), "CanRedo() should be false for empty log");
368        log.append("a");
369        assert!(!log.can_redo(), "CanRedo() should be false at end");
370        log.undo(1);
371        assert!(log.can_redo(), "CanRedo() should be true after undo");
372    }
373}