1use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UndoEntry {
12 pub id: u64,
14 pub timestamp: SystemTime,
16 pub operation: UndoableOperation,
18 pub description: String,
20}
21
22impl UndoEntry {
23 pub fn new(id: u64, operation: UndoableOperation, description: impl Into<String>) -> Self {
25 Self {
26 id,
27 timestamp: SystemTime::now(),
28 operation,
29 description: description.into(),
30 }
31 }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum UndoableOperation {
37 FilesMoved {
39 moves: Vec<(PathBuf, PathBuf)>,
41 },
42 FilesCopied {
44 created: Vec<PathBuf>,
46 },
47 FilesDeleted {
49 paths: Vec<PathBuf>,
51 },
52 FileRenamed {
54 path: PathBuf,
56 old_path: PathBuf,
59 new_name: String,
61 },
62 FileCreated {
64 path: PathBuf,
66 },
67 DirectoryCreated {
69 path: PathBuf,
71 },
72}
73
74impl UndoableOperation {
75 pub fn undo_description(&self) -> String {
77 match self {
78 Self::FilesMoved { moves } => {
79 format!("Move {} items back to original location", moves.len())
80 }
81 Self::FilesCopied { created } => {
82 format!("Delete {} copied items", created.len())
83 }
84 Self::FilesDeleted { paths } => {
85 format!("Deleted {} items (recover via OS trash)", paths.len())
86 }
87 Self::FileRenamed { old_path, .. } => {
88 format!(
89 "Rename back to '{}'",
90 old_path
91 .file_name()
92 .map(|n| n.to_string_lossy().into_owned())
93 .unwrap_or_default()
94 )
95 }
96 Self::FileCreated { .. } => "Delete the created file".to_string(),
97 Self::DirectoryCreated { .. } => "Delete the created directory".to_string(),
98 }
99 }
100
101 pub fn can_undo(&self) -> bool {
103 !matches!(self, Self::FilesDeleted { .. })
104 }
105}
106
107#[derive(Debug)]
109pub struct UndoLog {
110 entries: VecDeque<UndoEntry>,
111 max_entries: usize,
112 next_id: u64,
113}
114
115impl Default for UndoLog {
116 fn default() -> Self {
117 Self::new(100)
118 }
119}
120
121impl UndoLog {
122 pub fn new(max_entries: usize) -> Self {
124 Self {
125 entries: VecDeque::with_capacity(max_entries.min(1000)),
126 max_entries,
127 next_id: 0,
128 }
129 }
130
131 pub fn record(&mut self, operation: UndoableOperation, description: impl Into<String>) -> u64 {
135 let id = self.next_id;
136 self.next_id += 1;
137
138 if self.entries.len() >= self.max_entries {
140 self.entries.pop_front();
141 }
142
143 self.entries
144 .push_back(UndoEntry::new(id, operation, description));
145
146 id
147 }
148
149 pub fn record_move(&mut self, moves: Vec<(PathBuf, PathBuf)>) -> u64 {
151 let count = moves.len();
152 self.record(
153 UndoableOperation::FilesMoved { moves },
154 format!("Moved {} items", count),
155 )
156 }
157
158 pub fn record_copy(&mut self, created: Vec<PathBuf>) -> u64 {
160 let count = created.len();
161 self.record(
162 UndoableOperation::FilesCopied { created },
163 format!("Copied {} items", count),
164 )
165 }
166
167 pub fn record_delete(&mut self, paths: Vec<PathBuf>) -> u64 {
169 let count = paths.len();
170 self.record(
171 UndoableOperation::FilesDeleted { paths },
172 format!("Deleted {} items", count),
173 )
174 }
175
176 pub fn record_rename(&mut self, source: PathBuf, new_name: String) -> u64 {
181 let parent = source.parent().unwrap_or(std::path::Path::new(""));
182 let new_path = parent.join(&new_name);
183 let old_name = source
184 .file_name()
185 .map(|n| n.to_string_lossy().into_owned())
186 .unwrap_or_default();
187 let description = format!("Renamed '{}' to '{}'", old_name, new_name);
188 self.record(
189 UndoableOperation::FileRenamed {
190 path: new_path,
191 old_path: source,
192 new_name,
193 },
194 description,
195 )
196 }
197
198 pub fn record_create_file(&mut self, path: PathBuf) -> u64 {
200 let name = path
201 .file_name()
202 .map(|n| n.to_string_lossy().to_string())
203 .unwrap_or_default();
204 self.record(
205 UndoableOperation::FileCreated { path },
206 format!("Created file '{}'", name),
207 )
208 }
209
210 pub fn record_create_directory(&mut self, path: PathBuf) -> u64 {
212 let name = path
213 .file_name()
214 .map(|n| n.to_string_lossy().to_string())
215 .unwrap_or_default();
216 self.record(
217 UndoableOperation::DirectoryCreated { path },
218 format!("Created directory '{}'", name),
219 )
220 }
221
222 pub fn pop(&mut self) -> Option<UndoEntry> {
227 if self.entries.back()?.operation.can_undo() {
229 self.entries.pop_back()
230 } else {
231 None
232 }
233 }
234
235 pub fn peek(&self) -> Option<&UndoEntry> {
237 self.entries.back()
238 }
239
240 pub fn len(&self) -> usize {
242 self.entries.len()
243 }
244
245 pub fn is_empty(&self) -> bool {
247 self.entries.is_empty()
248 }
249
250 pub fn clear(&mut self) {
252 self.entries.clear();
253 }
254
255 pub fn iter(&self) -> impl Iterator<Item = &UndoEntry> {
257 self.entries.iter()
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_undo_log_record() {
267 let mut log = UndoLog::new(10);
268
269 let id = log.record_create_file(PathBuf::from("/test/file.txt"));
270 assert_eq!(id, 0);
271 assert_eq!(log.len(), 1);
272
273 let id = log.record_create_directory(PathBuf::from("/test/dir"));
274 assert_eq!(id, 1);
275 assert_eq!(log.len(), 2);
276 }
277
278 #[test]
279 fn test_undo_log_max_entries() {
280 let mut log = UndoLog::new(3);
281
282 log.record_create_file(PathBuf::from("/test/1.txt"));
283 log.record_create_file(PathBuf::from("/test/2.txt"));
284 log.record_create_file(PathBuf::from("/test/3.txt"));
285 assert_eq!(log.len(), 3);
286
287 log.record_create_file(PathBuf::from("/test/4.txt"));
288 assert_eq!(log.len(), 3);
289
290 let entry = log.pop().unwrap();
292 assert!(entry.description.contains("4.txt"));
293 }
294
295 #[test]
296 fn test_undo_log_pop() {
297 let mut log = UndoLog::new(10);
298
299 log.record_create_file(PathBuf::from("/test/file.txt"));
300 log.record_rename(PathBuf::from("/test/old.txt"), "new.txt".to_string());
301
302 let entry = log.pop().unwrap();
303 assert!(matches!(
304 entry.operation,
305 UndoableOperation::FileRenamed { .. }
306 ));
307
308 let entry = log.pop().unwrap();
309 assert!(matches!(
310 entry.operation,
311 UndoableOperation::FileCreated { .. }
312 ));
313
314 assert!(log.pop().is_none());
315 }
316
317 #[test]
318 fn test_pop_does_not_drain_non_undoable() {
319 let mut log = UndoLog::new(10);
320 log.record_create_file(PathBuf::from("/test/file.txt"));
321 log.record_delete(vec![PathBuf::from("/test/other.txt")]);
323
324 assert!(log.pop().is_none());
326 assert_eq!(log.len(), 2);
328 }
329}