Skip to main content

logisheets_controller/
checkpoint_manager.rs

1//! Named in-memory checkpoints.
2//!
3//! Independent of `VersionManager`:
4//!   - VersionManager   — linear undo/redo stack, LRU bounded, automatic
5//!                        (every undoable tx records). Drives Ctrl-Z/Y.
6//!   - CheckpointManager — random-access by `label`, AI-driven, persistent
7//!                        within a session unless explicitly deleted.
8//!
9//! They cooperate via `RestoreCheckpoint`: that payload reads a snapshot
10//! out of CheckpointManager AND records the state-swap as a normal
11//! undoable tx on VersionManager, so the user can Ctrl-Z to reverse the
12//! AI's restore.
13//!
14//! Storage cost is small thanks to `imbl`'s persistent data structures —
15//! multiple snapshots share most of their substructure with the live
16//! Status, so each entry's marginal cost is on the order of a few Arc
17//! refcount bumps.
18
19use std::collections::VecDeque;
20
21use logisheets_base::errors::BasicError;
22
23use crate::controller::status::Status;
24
25/// Hard ceiling on number of checkpoints kept simultaneously. When at
26/// capacity, the oldest entry is evicted (FIFO by last save/restore).
27///
28/// 20 is intentionally tight: AI workflows that need more than this
29/// many parallel "where was I" markers in a single session are almost
30/// certainly mis-using the API. Hitting the cap surfaces as a clear
31/// failure rather than a memory leak.
32const MAX_CHECKPOINTS: usize = 20;
33
34/// One named snapshot.
35#[derive(Debug, Clone)]
36pub struct CheckpointEntry {
37    pub label: String,
38    /// Caller-supplied human-readable description. Optional — purely
39    /// for round-trip display, not used for routing.
40    pub description: Option<String>,
41    /// Snapshot of the workbook state at save time. Restoring replaces
42    /// the controller's live `Status` with a clone of this.
43    pub status: Status,
44}
45
46/// Session-scoped checkpoint store.
47#[derive(Debug)]
48pub struct CheckpointManager {
49    /// Entries keyed by label. Using a Vec with linear scan keeps the
50    /// data layout simple at our scale (≤20 entries) and lets us
51    /// preserve insertion order for FIFO eviction.
52    entries: VecDeque<CheckpointEntry>,
53}
54
55impl Default for CheckpointManager {
56    fn default() -> Self {
57        Self {
58            entries: VecDeque::new(),
59        }
60    }
61}
62
63impl CheckpointManager {
64    /// Store a snapshot under `label`. If the label already exists, the
65    /// new snapshot overwrites it (and moves to the front of the FIFO
66    /// order — treat re-saving as a "touch"). Returns the entry's
67    /// position-from-newest, for observability.
68    pub fn save(
69        &mut self,
70        label: String,
71        description: Option<String>,
72        status: Status,
73    ) -> &CheckpointEntry {
74        // Remove existing entry with this label (so save overrides).
75        self.entries.retain(|e| e.label != label);
76
77        // FIFO evict if at capacity. Drop from the back (oldest end).
78        while self.entries.len() >= MAX_CHECKPOINTS {
79            self.entries.pop_back();
80        }
81
82        self.entries.push_front(CheckpointEntry {
83            label,
84            description,
85            status,
86        });
87        self.entries.front().expect("just pushed")
88    }
89
90    /// Look up a snapshot's Status without consuming it. Returns
91    /// `Err(BasicError::CheckpointNotFound)` if the label is unknown
92    /// (fail-loud per design).
93    pub fn get(&self, label: &str) -> Result<&Status, BasicError> {
94        self.entries
95            .iter()
96            .find(|e| e.label == label)
97            .map(|e| &e.status)
98            .ok_or_else(|| BasicError::CheckpointNotFound(label.to_string()))
99    }
100
101    /// Remove a checkpoint by label. Returns `true` if it existed.
102    pub fn delete(&mut self, label: &str) -> bool {
103        let before = self.entries.len();
104        self.entries.retain(|e| e.label != label);
105        self.entries.len() != before
106    }
107
108    /// List all checkpoints, newest-first. Excludes the `Status`
109    /// payload — callers only need the labels and metadata.
110    pub fn list(&self) -> Vec<CheckpointMeta> {
111        self.entries
112            .iter()
113            .map(|e| CheckpointMeta {
114                label: e.label.clone(),
115                description: e.description.clone(),
116            })
117            .collect()
118    }
119
120    /// Total entries currently held.
121    pub fn len(&self) -> usize {
122        self.entries.len()
123    }
124
125    /// True when empty.
126    pub fn is_empty(&self) -> bool {
127        self.entries.is_empty()
128    }
129
130    /// Drop every checkpoint. Used on file save/load — checkpoints are
131    /// session state, not file state.
132    pub fn clear(&mut self) {
133        self.entries.clear();
134    }
135}
136
137/// Lightweight metadata returned by `list`. The full `Status` clone is
138/// kept inside the manager.
139#[derive(Debug, Clone)]
140pub struct CheckpointMeta {
141    pub label: String,
142    pub description: Option<String>,
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn empty_status() -> Status {
150        Status::default()
151    }
152
153    #[test]
154    fn save_then_get_returns_snapshot() {
155        let mut m = CheckpointManager::default();
156        m.save("a".into(), Some("first".into()), empty_status());
157        assert!(m.get("a").is_ok());
158    }
159
160    #[test]
161    fn get_missing_returns_err() {
162        let m = CheckpointManager::default();
163        let err = m.get("nope").unwrap_err();
164        match err {
165            BasicError::CheckpointNotFound(s) => assert_eq!(s, "nope"),
166            _ => panic!("expected CheckpointNotFound"),
167        }
168    }
169
170    #[test]
171    fn save_with_existing_label_overwrites() {
172        let mut m = CheckpointManager::default();
173        m.save("a".into(), Some("v1".into()), empty_status());
174        m.save("a".into(), Some("v2".into()), empty_status());
175        assert_eq!(m.len(), 1);
176        assert_eq!(m.list()[0].description.as_deref(), Some("v2"));
177    }
178
179    #[test]
180    fn list_is_newest_first() {
181        let mut m = CheckpointManager::default();
182        m.save("a".into(), None, empty_status());
183        m.save("b".into(), None, empty_status());
184        m.save("c".into(), None, empty_status());
185        let labels: Vec<_> = m.list().iter().map(|e| e.label.clone()).collect();
186        assert_eq!(labels, vec!["c", "b", "a"]);
187    }
188
189    #[test]
190    fn delete_removes_entry() {
191        let mut m = CheckpointManager::default();
192        m.save("a".into(), None, empty_status());
193        assert!(m.delete("a"));
194        assert!(!m.delete("a")); // second delete no-ops
195        assert!(m.is_empty());
196    }
197
198    #[test]
199    fn eviction_drops_oldest_when_full() {
200        let mut m = CheckpointManager::default();
201        for i in 0..(MAX_CHECKPOINTS + 5) {
202            m.save(format!("k{}", i), None, empty_status());
203        }
204        assert_eq!(m.len(), MAX_CHECKPOINTS);
205        // The oldest 5 should have been evicted.
206        assert!(m.get("k0").is_err());
207        assert!(m.get("k4").is_err());
208        assert!(m.get("k5").is_ok());
209        // Newest still there.
210        assert!(m.get(&format!("k{}", MAX_CHECKPOINTS + 4)).is_ok());
211    }
212
213    #[test]
214    fn resave_moves_to_front_so_no_eviction() {
215        let mut m = CheckpointManager::default();
216        m.save("keep".into(), None, empty_status());
217        for i in 0..MAX_CHECKPOINTS {
218            m.save(format!("k{}", i), None, empty_status());
219            // Refresh "keep" right before each new entry — should
220            // bubble it back to the front so it survives.
221            if i % 2 == 0 {
222                m.save("keep".into(), None, empty_status());
223            }
224        }
225        assert!(m.get("keep").is_ok());
226    }
227}