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