1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct UndoEntry {
6 pub original_text: String,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct UndoRecord {
13 pub timestamp: String,
15 pub file_path: String,
16 pub entry: UndoEntry,
17 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub encoding: Option<String>,
22}
23
24pub 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 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 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 pub fn push(&mut self, record: UndoRecord) {
64 self.records.push(record);
65 self.prune();
66 }
67
68 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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}