gravityfile_ops/
undo.rs

1//! Undo log for file operations.
2
3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9/// An entry in the undo log.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct UndoEntry {
12    /// Unique ID for this entry.
13    pub id: u64,
14    /// When the operation was performed.
15    pub timestamp: SystemTime,
16    /// The operation that was performed.
17    pub operation: UndoableOperation,
18    /// Human-readable description.
19    pub description: String,
20}
21
22impl UndoEntry {
23    /// Create a new undo entry.
24    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/// An operation that can be undone.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub enum UndoableOperation {
37    /// Files were moved from one location to another.
38    FilesMoved {
39        /// List of (original_path, new_path) pairs.
40        moves: Vec<(PathBuf, PathBuf)>,
41    },
42    /// Files were copied to a destination.
43    FilesCopied {
44        /// List of created files/directories.
45        created: Vec<PathBuf>,
46    },
47    /// Files were moved to trash.
48    FilesDeleted {
49        /// List of (original_path, trash_path) pairs.
50        /// Only populated if trash was used.
51        trash_entries: Vec<(PathBuf, PathBuf)>,
52    },
53    /// A file or directory was renamed.
54    FileRenamed {
55        /// The path (with new name).
56        path: PathBuf,
57        /// The old name.
58        old_name: String,
59        /// The new name.
60        new_name: String,
61    },
62    /// A file was created.
63    FileCreated {
64        /// Path to the created file.
65        path: PathBuf,
66    },
67    /// A directory was created.
68    DirectoryCreated {
69        /// Path to the created directory.
70        path: PathBuf,
71    },
72}
73
74impl UndoableOperation {
75    /// Get a description of how to undo this operation.
76    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 { trash_entries } => {
85                if trash_entries.is_empty() {
86                    "Cannot undo permanent deletion".to_string()
87                } else {
88                    format!("Restore {} items from trash", trash_entries.len())
89                }
90            }
91            Self::FileRenamed { old_name, .. } => {
92                format!("Rename back to '{}'", old_name)
93            }
94            Self::FileCreated { .. } => "Delete the created file".to_string(),
95            Self::DirectoryCreated { .. } => "Delete the created directory".to_string(),
96        }
97    }
98
99    /// Check if this operation can be undone.
100    pub fn can_undo(&self) -> bool {
101        match self {
102            Self::FilesDeleted { trash_entries } => !trash_entries.is_empty(),
103            _ => true,
104        }
105    }
106}
107
108/// Undo log with configurable maximum depth.
109#[derive(Debug)]
110pub struct UndoLog {
111    entries: VecDeque<UndoEntry>,
112    max_entries: usize,
113    next_id: u64,
114}
115
116impl Default for UndoLog {
117    fn default() -> Self {
118        Self::new(100)
119    }
120}
121
122impl UndoLog {
123    /// Create a new undo log with the specified maximum entries.
124    pub fn new(max_entries: usize) -> Self {
125        Self {
126            entries: VecDeque::with_capacity(max_entries.min(1000)),
127            max_entries,
128            next_id: 0,
129        }
130    }
131
132    /// Record an operation in the undo log.
133    ///
134    /// Returns the ID assigned to this entry.
135    pub fn record(&mut self, operation: UndoableOperation, description: impl Into<String>) -> u64 {
136        let id = self.next_id;
137        self.next_id += 1;
138
139        // Remove oldest entry if at capacity
140        if self.entries.len() >= self.max_entries {
141            self.entries.pop_front();
142        }
143
144        self.entries
145            .push_back(UndoEntry::new(id, operation, description));
146
147        id
148    }
149
150    /// Record a move operation.
151    pub fn record_move(&mut self, moves: Vec<(PathBuf, PathBuf)>) -> u64 {
152        let count = moves.len();
153        self.record(
154            UndoableOperation::FilesMoved { moves },
155            format!("Moved {} items", count),
156        )
157    }
158
159    /// Record a copy operation.
160    pub fn record_copy(&mut self, created: Vec<PathBuf>) -> u64 {
161        let count = created.len();
162        self.record(
163            UndoableOperation::FilesCopied { created },
164            format!("Copied {} items", count),
165        )
166    }
167
168    /// Record a delete operation.
169    pub fn record_delete(&mut self, trash_entries: Vec<(PathBuf, PathBuf)>) -> u64 {
170        let count = trash_entries.len();
171        let desc = if trash_entries.is_empty() {
172            format!("Permanently deleted {} items", count)
173        } else {
174            format!("Moved {} items to trash", count)
175        };
176        self.record(UndoableOperation::FilesDeleted { trash_entries }, desc)
177    }
178
179    /// Record a rename operation.
180    pub fn record_rename(&mut self, path: PathBuf, old_name: String, new_name: String) -> u64 {
181        self.record(
182            UndoableOperation::FileRenamed {
183                path,
184                old_name: old_name.clone(),
185                new_name: new_name.clone(),
186            },
187            format!("Renamed '{}' to '{}'", old_name, new_name),
188        )
189    }
190
191    /// Record a file creation.
192    pub fn record_create_file(&mut self, path: PathBuf) -> u64 {
193        let name = path
194            .file_name()
195            .map(|n| n.to_string_lossy().to_string())
196            .unwrap_or_default();
197        self.record(
198            UndoableOperation::FileCreated { path },
199            format!("Created file '{}'", name),
200        )
201    }
202
203    /// Record a directory creation.
204    pub fn record_create_directory(&mut self, path: PathBuf) -> u64 {
205        let name = path
206            .file_name()
207            .map(|n| n.to_string_lossy().to_string())
208            .unwrap_or_default();
209        self.record(
210            UndoableOperation::DirectoryCreated { path },
211            format!("Created directory '{}'", name),
212        )
213    }
214
215    /// Pop the most recent undoable entry.
216    ///
217    /// Returns None if the log is empty or the most recent operation cannot be undone.
218    pub fn pop(&mut self) -> Option<UndoEntry> {
219        // Find the most recent undoable entry
220        while let Some(entry) = self.entries.pop_back() {
221            if entry.operation.can_undo() {
222                return Some(entry);
223            }
224        }
225        None
226    }
227
228    /// Peek at the most recent entry without removing it.
229    pub fn peek(&self) -> Option<&UndoEntry> {
230        self.entries.back()
231    }
232
233    /// Get the number of entries in the log.
234    pub fn len(&self) -> usize {
235        self.entries.len()
236    }
237
238    /// Check if the log is empty.
239    pub fn is_empty(&self) -> bool {
240        self.entries.is_empty()
241    }
242
243    /// Clear all entries from the log.
244    pub fn clear(&mut self) {
245        self.entries.clear();
246    }
247
248    /// Get an iterator over all entries (oldest first).
249    pub fn iter(&self) -> impl Iterator<Item = &UndoEntry> {
250        self.entries.iter()
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_undo_log_record() {
260        let mut log = UndoLog::new(10);
261
262        let id = log.record_create_file(PathBuf::from("/test/file.txt"));
263        assert_eq!(id, 0);
264        assert_eq!(log.len(), 1);
265
266        let id = log.record_create_directory(PathBuf::from("/test/dir"));
267        assert_eq!(id, 1);
268        assert_eq!(log.len(), 2);
269    }
270
271    #[test]
272    fn test_undo_log_max_entries() {
273        let mut log = UndoLog::new(3);
274
275        log.record_create_file(PathBuf::from("/test/1.txt"));
276        log.record_create_file(PathBuf::from("/test/2.txt"));
277        log.record_create_file(PathBuf::from("/test/3.txt"));
278        assert_eq!(log.len(), 3);
279
280        log.record_create_file(PathBuf::from("/test/4.txt"));
281        assert_eq!(log.len(), 3);
282
283        // First entry should be removed
284        let entry = log.pop().unwrap();
285        assert!(entry.description.contains("4.txt"));
286    }
287
288    #[test]
289    fn test_undo_log_pop() {
290        let mut log = UndoLog::new(10);
291
292        log.record_create_file(PathBuf::from("/test/file.txt"));
293        log.record_rename(
294            PathBuf::from("/test/new.txt"),
295            "old.txt".to_string(),
296            "new.txt".to_string(),
297        );
298
299        let entry = log.pop().unwrap();
300        assert!(matches!(
301            entry.operation,
302            UndoableOperation::FileRenamed { .. }
303        ));
304
305        let entry = log.pop().unwrap();
306        assert!(matches!(
307            entry.operation,
308            UndoableOperation::FileCreated { .. }
309        ));
310
311        assert!(log.pop().is_none());
312    }
313}