Skip to main content

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 or permanently deleted.
48    FilesDeleted {
49        /// List of paths that were deleted.
50        paths: Vec<PathBuf>,
51    },
52    /// A file or directory was renamed.
53    FileRenamed {
54        /// The full path with the new name (after rename).
55        path: PathBuf,
56        /// The full original path (before rename) — used to undo.
57        // HIGH-5: store full old path instead of bare old_name string
58        old_path: PathBuf,
59        /// The new name (informational).
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 { 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    /// Check if this operation can be undone.
102    pub fn can_undo(&self) -> bool {
103        !matches!(self, Self::FilesDeleted { .. })
104    }
105}
106
107/// Undo log with configurable maximum depth.
108#[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    /// Create a new undo log with the specified maximum entries.
123    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    /// Record an operation in the undo log.
132    ///
133    /// Returns the ID assigned to this entry.
134    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        // Remove oldest entry if at capacity
139        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    /// Record a move operation.
150    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    /// Record a copy operation.
159    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    /// Record a delete operation.
168    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    /// Record a rename operation.
177    ///
178    /// `source` is the full original path before the rename.
179    /// `new_name` is the new name component (used for the description).
180    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    /// Record a file creation.
199    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    /// Record a directory creation.
211    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    /// Pop the most recent undoable entry.
223    ///
224    /// Returns `None` if the log is empty or the back entry cannot be undone.
225    /// Does NOT drain through non-undoable entries — only examines the back.
226    pub fn pop(&mut self) -> Option<UndoEntry> {
227        // HIGH-4: only look at the back entry; don't consume non-undoable ones
228        if self.entries.back()?.operation.can_undo() {
229            self.entries.pop_back()
230        } else {
231            None
232        }
233    }
234
235    /// Peek at the most recent entry without removing it.
236    pub fn peek(&self) -> Option<&UndoEntry> {
237        self.entries.back()
238    }
239
240    /// Get the number of entries in the log.
241    pub fn len(&self) -> usize {
242        self.entries.len()
243    }
244
245    /// Check if the log is empty.
246    pub fn is_empty(&self) -> bool {
247        self.entries.is_empty()
248    }
249
250    /// Clear all entries from the log.
251    pub fn clear(&mut self) {
252        self.entries.clear();
253    }
254
255    /// Get an iterator over all entries (oldest first).
256    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        // First entry should be removed
291        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        // Delete is non-undoable; push it last so it is the back entry
322        log.record_delete(vec![PathBuf::from("/test/other.txt")]);
323
324        // pop() should return None because the back entry is non-undoable
325        assert!(log.pop().is_none());
326        // The undoable create entry must still be present
327        assert_eq!(log.len(), 2);
328    }
329}