Skip to main content

ripsed_core/
undo.rs

1use serde::{Deserialize, Serialize};
2
3/// An entry in the undo log, storing enough information to reverse an operation.
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct UndoEntry {
6    /// The full original text before the operation.
7    pub original_text: String,
8}
9
10/// A record in the persistent undo log file (.ripsed/undo.jsonl).
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct UndoRecord {
13    /// Unix epoch seconds as a decimal string (e.g., `"1711550400"`).
14    pub timestamp: String,
15    pub file_path: String,
16    pub entry: UndoEntry,
17    /// Source-encoding tag of the file when it was read (e.g. `"utf-16le"`).
18    /// `None` (and absent in pre-existing logs) means plain UTF-8. Restoring
19    /// re-encodes `original_text` with this so undo is byte-exact.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub encoding: Option<String>,
22}
23
24/// Manages the undo log.
25pub struct UndoLog {
26    records: Vec<UndoRecord>,
27    max_entries: usize,
28}
29
30impl UndoLog {
31    pub fn new(max_entries: usize) -> Self {
32        Self {
33            records: Vec::new(),
34            max_entries,
35        }
36    }
37
38    /// Load undo log from JSONL content.
39    pub fn from_jsonl(content: &str, max_entries: usize) -> Self {
40        let records: Vec<UndoRecord> = content
41            .lines()
42            .filter(|line| !line.trim().is_empty())
43            .filter_map(|line| serde_json::from_str(line).ok())
44            .collect();
45
46        Self {
47            records,
48            max_entries,
49        }
50    }
51
52    /// Serialize the log to JSONL format.
53    pub fn to_jsonl(&self) -> String {
54        self.records
55            .iter()
56            .filter_map(|r| serde_json::to_string(r).ok())
57            .collect::<Vec<_>>()
58            .join("\n")
59            + if self.records.is_empty() { "" } else { "\n" }
60    }
61
62    /// Append a new undo record.
63    pub fn push(&mut self, record: UndoRecord) {
64        self.records.push(record);
65        self.prune();
66    }
67
68    /// Remove the last N records and return them (for undo).
69    pub fn pop(&mut self, count: usize) -> Vec<UndoRecord> {
70        let drain_start = self.records.len().saturating_sub(count);
71        self.records.drain(drain_start..).rev().collect()
72    }
73
74    /// Number of entries in the log.
75    pub fn len(&self) -> usize {
76        self.records.len()
77    }
78
79    pub fn is_empty(&self) -> bool {
80        self.records.is_empty()
81    }
82
83    /// Get recent entries for display.
84    pub fn recent(&self, count: usize) -> &[UndoRecord] {
85        let start = self.records.len().saturating_sub(count);
86        &self.records[start..]
87    }
88
89    fn prune(&mut self) {
90        if self.records.len() > self.max_entries {
91            let excess = self.records.len() - self.max_entries;
92            self.records.drain(..excess);
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_push_and_pop() {
103        let mut log = UndoLog::new(100);
104        log.push(UndoRecord {
105            encoding: None,
106            timestamp: "2026-01-01T00:00:00Z".to_string(),
107            file_path: "test.txt".to_string(),
108            entry: UndoEntry {
109                original_text: "hello".to_string(),
110            },
111        });
112        assert_eq!(log.len(), 1);
113        let popped = log.pop(1);
114        assert_eq!(popped.len(), 1);
115        assert_eq!(popped[0].file_path, "test.txt");
116        assert!(log.is_empty());
117    }
118
119    #[test]
120    fn test_pruning() {
121        let mut log = UndoLog::new(2);
122        for i in 0..5 {
123            log.push(UndoRecord {
124                encoding: None,
125                timestamp: format!("2026-01-0{i}T00:00:00Z"),
126                file_path: format!("file{i}.txt"),
127                entry: UndoEntry {
128                    original_text: format!("content{i}"),
129                },
130            });
131        }
132        assert_eq!(log.len(), 2);
133    }
134
135    #[test]
136    fn test_jsonl_roundtrip() {
137        let mut log = UndoLog::new(100);
138        log.push(UndoRecord {
139            encoding: None,
140            timestamp: "2026-01-01T00:00:00Z".to_string(),
141            file_path: "test.txt".to_string(),
142            entry: UndoEntry {
143                original_text: "original".to_string(),
144            },
145        });
146        let jsonl = log.to_jsonl();
147        let loaded = UndoLog::from_jsonl(&jsonl, 100);
148        assert_eq!(loaded.len(), 1);
149    }
150
151    // ---- Adversarial edge-case tests ----
152
153    fn record(path: &str, text: &str) -> UndoRecord {
154        UndoRecord {
155            encoding: None,
156            timestamp: "2026-01-01T00:00:00Z".to_string(),
157            file_path: path.to_string(),
158            entry: UndoEntry {
159                original_text: text.to_string(),
160            },
161        }
162    }
163
164    /// **Adversarial**: `pop(0)` must be a no-op — returning an empty Vec
165    /// and leaving the log unchanged. Agents call this to roll back "the
166    /// last 0 operations" when they compute a dynamic count, and a buggy
167    /// implementation could e.g. drain the whole log.
168    #[test]
169    fn pop_zero_is_noop() {
170        let mut log = UndoLog::new(10);
171        log.push(record("a.txt", "A"));
172        log.push(record("b.txt", "B"));
173        let popped = log.pop(0);
174        assert!(popped.is_empty(), "pop(0) should return empty");
175        assert_eq!(log.len(), 2, "pop(0) should not drain the log");
176    }
177
178    /// **Adversarial**: `pop(n)` with n > log length must drain all
179    /// records without panicking (saturating subtraction).
180    #[test]
181    fn pop_exceeds_length_drains_all() {
182        let mut log = UndoLog::new(10);
183        log.push(record("a.txt", "A"));
184        log.push(record("b.txt", "B"));
185        let popped = log.pop(100);
186        assert_eq!(popped.len(), 2);
187        assert!(log.is_empty());
188    }
189
190    /// **Adversarial**: `pop` must return records in REVERSE insertion
191    /// order (newest first). Undo replays in this order to correctly
192    /// reverse a sequence of operations — reversed ordering is critical
193    /// when the same file was edited multiple times.
194    #[test]
195    fn pop_returns_newest_first() {
196        let mut log = UndoLog::new(10);
197        log.push(record("a.txt", "first"));
198        log.push(record("a.txt", "second"));
199        log.push(record("a.txt", "third"));
200
201        let popped = log.pop(3);
202        assert_eq!(popped[0].entry.original_text, "third");
203        assert_eq!(popped[1].entry.original_text, "second");
204        assert_eq!(popped[2].entry.original_text, "first");
205    }
206
207    /// **Adversarial**: When the log exceeds `max_entries`, pruning must
208    /// remove the OLDEST entries, preserving the most recent N. A buggy
209    /// implementation could drop the newest (which are the most likely to
210    /// be undone) or a random subset.
211    #[test]
212    fn prune_drops_oldest_not_newest() {
213        let mut log = UndoLog::new(3);
214        for i in 0..10 {
215            log.push(record(&format!("file_{i}.txt"), &format!("v{i}")));
216        }
217        assert_eq!(log.len(), 3);
218        // The three remaining should be files 7, 8, 9.
219        let latest_three = log.recent(3);
220        assert_eq!(latest_three[0].file_path, "file_7.txt");
221        assert_eq!(latest_three[1].file_path, "file_8.txt");
222        assert_eq!(latest_three[2].file_path, "file_9.txt");
223    }
224
225    /// **Adversarial**: `from_jsonl` must silently skip malformed lines
226    /// (corrupted log file recovery) but still load the good ones.
227    /// Blowing up on a single bad line would brick all undo.
228    #[test]
229    fn from_jsonl_skips_malformed_lines() {
230        let good = serde_json::to_string(&record("a.txt", "A")).unwrap();
231        let bad = "{{{ not valid json";
232        let also_good = serde_json::to_string(&record("b.txt", "B")).unwrap();
233        let mixed = format!("{good}\n{bad}\n{also_good}\n");
234
235        let log = UndoLog::from_jsonl(&mixed, 100);
236        assert_eq!(
237            log.len(),
238            2,
239            "malformed lines should be skipped, good ones kept"
240        );
241    }
242
243    /// **Adversarial**: `from_jsonl` must tolerate blank lines, CRLF line
244    /// endings, and leading/trailing whitespace without crashing or
245    /// miscounting records.
246    #[test]
247    fn from_jsonl_tolerates_blank_and_crlf_lines() {
248        let good = serde_json::to_string(&record("a.txt", "A")).unwrap();
249        let mixed = format!("\n{good}\r\n\r\n");
250        let log = UndoLog::from_jsonl(&mixed, 100);
251        assert_eq!(log.len(), 1);
252    }
253
254    /// **Adversarial**: serializing an empty log produces an empty string
255    /// (not a lone newline). Round-trip through `from_jsonl` preserves
256    /// emptiness. This guards against a subtle bug where writing a lone
257    /// `\n` would create a truncated-looking log file.
258    #[test]
259    fn empty_log_jsonl_is_empty_string() {
260        let log = UndoLog::new(100);
261        assert_eq!(log.to_jsonl(), "");
262        let reparsed = UndoLog::from_jsonl("", 100);
263        assert!(reparsed.is_empty());
264    }
265
266    /// **Adversarial**: after pushing and popping, the log should behave
267    /// as if the popped records were never pushed for the purpose of
268    /// subsequent `recent()` / `len()` calls. A bug could mark records
269    /// as popped without actually removing them.
270    #[test]
271    fn pop_actually_removes_from_log() {
272        let mut log = UndoLog::new(10);
273        log.push(record("a.txt", "A"));
274        log.push(record("b.txt", "B"));
275        let _ = log.pop(1);
276        assert_eq!(log.len(), 1);
277        let remaining = log.recent(1);
278        assert_eq!(remaining[0].file_path, "a.txt");
279    }
280}