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}
18
19pub struct UndoLog {
21 records: Vec<UndoRecord>,
22 max_entries: usize,
23}
24
25impl UndoLog {
26 pub fn new(max_entries: usize) -> Self {
27 Self {
28 records: Vec::new(),
29 max_entries,
30 }
31 }
32
33 pub fn from_jsonl(content: &str, max_entries: usize) -> Self {
35 let records: Vec<UndoRecord> = content
36 .lines()
37 .filter(|line| !line.trim().is_empty())
38 .filter_map(|line| serde_json::from_str(line).ok())
39 .collect();
40
41 Self {
42 records,
43 max_entries,
44 }
45 }
46
47 pub fn to_jsonl(&self) -> String {
49 self.records
50 .iter()
51 .filter_map(|r| serde_json::to_string(r).ok())
52 .collect::<Vec<_>>()
53 .join("\n")
54 + if self.records.is_empty() { "" } else { "\n" }
55 }
56
57 pub fn push(&mut self, record: UndoRecord) {
59 self.records.push(record);
60 self.prune();
61 }
62
63 pub fn pop(&mut self, count: usize) -> Vec<UndoRecord> {
65 let drain_start = self.records.len().saturating_sub(count);
66 self.records.drain(drain_start..).rev().collect()
67 }
68
69 pub fn len(&self) -> usize {
71 self.records.len()
72 }
73
74 pub fn is_empty(&self) -> bool {
75 self.records.is_empty()
76 }
77
78 pub fn recent(&self, count: usize) -> &[UndoRecord] {
80 let start = self.records.len().saturating_sub(count);
81 &self.records[start..]
82 }
83
84 fn prune(&mut self) {
85 if self.records.len() > self.max_entries {
86 let excess = self.records.len() - self.max_entries;
87 self.records.drain(..excess);
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn test_push_and_pop() {
98 let mut log = UndoLog::new(100);
99 log.push(UndoRecord {
100 timestamp: "2026-01-01T00:00:00Z".to_string(),
101 file_path: "test.txt".to_string(),
102 entry: UndoEntry {
103 original_text: "hello".to_string(),
104 },
105 });
106 assert_eq!(log.len(), 1);
107 let popped = log.pop(1);
108 assert_eq!(popped.len(), 1);
109 assert_eq!(popped[0].file_path, "test.txt");
110 assert!(log.is_empty());
111 }
112
113 #[test]
114 fn test_pruning() {
115 let mut log = UndoLog::new(2);
116 for i in 0..5 {
117 log.push(UndoRecord {
118 timestamp: format!("2026-01-0{i}T00:00:00Z"),
119 file_path: format!("file{i}.txt"),
120 entry: UndoEntry {
121 original_text: format!("content{i}"),
122 },
123 });
124 }
125 assert_eq!(log.len(), 2);
126 }
127
128 #[test]
129 fn test_jsonl_roundtrip() {
130 let mut log = UndoLog::new(100);
131 log.push(UndoRecord {
132 timestamp: "2026-01-01T00:00:00Z".to_string(),
133 file_path: "test.txt".to_string(),
134 entry: UndoEntry {
135 original_text: "original".to_string(),
136 },
137 });
138 let jsonl = log.to_jsonl();
139 let loaded = UndoLog::from_jsonl(&jsonl, 100);
140 assert_eq!(loaded.len(), 1);
141 }
142}