Skip to main content

gravityfile_ops/
executor.rs

1//! High-level operation executor with unified result handling.
2
3use std::fs;
4use std::path::PathBuf;
5
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9use crate::conflict::ConflictResolution;
10use crate::copy::{CopyOptions, CopyResult, start_copy};
11use crate::create::{CreateResult, start_create_directory, start_create_file};
12use crate::move_op::{MoveOptions, MoveResult, start_move};
13use crate::progress::{OperationComplete, OperationProgress, OperationType};
14use crate::rename::{RenameResult, start_rename};
15use crate::undo::UndoableOperation;
16use crate::{Conflict, OPERATION_CHANNEL_SIZE};
17
18/// Unified result type for all operations.
19#[derive(Debug)]
20pub enum OperationResult {
21    /// Progress update.
22    Progress(OperationProgress),
23    /// A conflict needs resolution.
24    Conflict(Conflict),
25    /// The operation completed.
26    Complete(OperationComplete),
27}
28
29/// Executor for file operations with unified interface.
30#[derive(Debug, Default)]
31pub struct OperationExecutor {
32    /// Default conflict resolution.
33    pub default_resolution: Option<ConflictResolution>,
34    /// Whether to use trash for deletions.
35    pub use_trash: bool,
36}
37
38impl OperationExecutor {
39    /// Create a new executor with default settings.
40    pub fn new() -> Self {
41        Self::default()
42    }
43
44    /// Create an executor that uses trash for deletions.
45    pub fn with_trash() -> Self {
46        Self {
47            use_trash: true,
48            ..Default::default()
49        }
50    }
51
52    /// Set the default conflict resolution.
53    pub fn with_resolution(mut self, resolution: ConflictResolution) -> Self {
54        self.default_resolution = Some(resolution);
55        self
56    }
57
58    /// Execute a copy operation (non-cancellable convenience wrapper).
59    ///
60    /// Creates an internal `CancellationToken` that is never cancelled.
61    /// Use [`copy_with_cancellation`](Self::copy_with_cancellation) when the
62    /// caller needs to abort the operation.
63    pub fn copy(
64        &self,
65        sources: Vec<PathBuf>,
66        destination: PathBuf,
67    ) -> mpsc::Receiver<OperationResult> {
68        self.copy_with_cancellation(sources, destination, CancellationToken::new())
69    }
70
71    /// Execute a copy operation with cancellation support.
72    pub fn copy_with_cancellation(
73        &self,
74        sources: Vec<PathBuf>,
75        destination: PathBuf,
76        token: CancellationToken,
77    ) -> mpsc::Receiver<OperationResult> {
78        let options = CopyOptions {
79            conflict_resolution: self.default_resolution,
80            preserve_timestamps: true,
81        };
82
83        let copy_rx = start_copy(sources, destination, options, token);
84        Self::adapt_copy_results(copy_rx)
85    }
86
87    /// Execute a move operation (non-cancellable convenience wrapper).
88    ///
89    /// Creates an internal `CancellationToken` that is never cancelled.
90    /// Use [`move_to_with_cancellation`](Self::move_to_with_cancellation)
91    /// when the caller needs to abort the operation.
92    pub fn move_to(
93        &self,
94        sources: Vec<PathBuf>,
95        destination: PathBuf,
96    ) -> mpsc::Receiver<OperationResult> {
97        self.move_to_with_cancellation(sources, destination, CancellationToken::new())
98    }
99
100    /// Execute a move operation with cancellation support.
101    pub fn move_to_with_cancellation(
102        &self,
103        sources: Vec<PathBuf>,
104        destination: PathBuf,
105        token: CancellationToken,
106    ) -> mpsc::Receiver<OperationResult> {
107        let options = MoveOptions {
108            conflict_resolution: self.default_resolution,
109        };
110
111        let move_rx = start_move(sources, destination, options, token);
112        Self::adapt_move_results(move_rx)
113    }
114
115    /// Execute a rename operation.
116    pub fn rename(&self, source: PathBuf, new_name: String) -> mpsc::Receiver<OperationResult> {
117        let rename_rx = start_rename(source, new_name);
118        Self::adapt_rename_results(rename_rx)
119    }
120
121    /// Execute a file creation operation.
122    pub fn create_file(&self, path: PathBuf) -> mpsc::Receiver<OperationResult> {
123        let create_rx = start_create_file(path);
124        Self::adapt_create_results(create_rx)
125    }
126
127    /// Execute a directory creation operation.
128    pub fn create_directory(&self, path: PathBuf) -> mpsc::Receiver<OperationResult> {
129        let create_rx = start_create_directory(path);
130        Self::adapt_create_results(create_rx)
131    }
132
133    /// Adapt copy results to unified result type.
134    fn adapt_copy_results(mut rx: mpsc::Receiver<CopyResult>) -> mpsc::Receiver<OperationResult> {
135        let (tx, result_rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
136
137        tokio::spawn(async move {
138            while let Some(result) = rx.recv().await {
139                let unified = match result {
140                    CopyResult::Progress(p) => OperationResult::Progress(p),
141                    CopyResult::Conflict(c) => OperationResult::Conflict(c),
142                    CopyResult::Complete(c) => OperationResult::Complete(c),
143                };
144                if tx.send(unified).await.is_err() {
145                    break;
146                }
147            }
148        });
149
150        result_rx
151    }
152
153    /// Adapt move results to unified result type.
154    ///
155    /// Note: `MoveComplete.moved_pairs` is available to callers who receive
156    /// the raw `MoveResult` channel (e.g., the TUI for undo recording).
157    /// This adapter drops the pairs since `OperationResult` is generic.
158    fn adapt_move_results(mut rx: mpsc::Receiver<MoveResult>) -> mpsc::Receiver<OperationResult> {
159        let (tx, result_rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
160
161        tokio::spawn(async move {
162            while let Some(result) = rx.recv().await {
163                let unified = match result {
164                    MoveResult::Progress(p) => OperationResult::Progress(p),
165                    MoveResult::Conflict(c) => OperationResult::Conflict(c),
166                    MoveResult::Complete(mc) => OperationResult::Complete(mc.inner),
167                };
168                if tx.send(unified).await.is_err() {
169                    break;
170                }
171            }
172        });
173
174        result_rx
175    }
176
177    /// Adapt rename results to unified result type.
178    fn adapt_rename_results(
179        mut rx: mpsc::Receiver<RenameResult>,
180    ) -> mpsc::Receiver<OperationResult> {
181        let (tx, result_rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
182
183        tokio::spawn(async move {
184            while let Some(result) = rx.recv().await {
185                let unified = match result {
186                    RenameResult::Progress(p) => OperationResult::Progress(p),
187                    RenameResult::Complete(c) => OperationResult::Complete(c),
188                };
189                if tx.send(unified).await.is_err() {
190                    break;
191                }
192            }
193        });
194
195        result_rx
196    }
197
198    /// Adapt create results to unified result type.
199    fn adapt_create_results(
200        mut rx: mpsc::Receiver<CreateResult>,
201    ) -> mpsc::Receiver<OperationResult> {
202        let (tx, result_rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
203
204        tokio::spawn(async move {
205            while let Some(result) = rx.recv().await {
206                let unified = match result {
207                    CreateResult::Progress(p) => OperationResult::Progress(p),
208                    CreateResult::Complete(c) => OperationResult::Complete(c),
209                };
210                if tx.send(unified).await.is_err() {
211                    break;
212                }
213            }
214        });
215
216        result_rx
217    }
218}
219
220/// Execute an undo operation.
221///
222/// Returns a receiver for the undo operation's progress.
223pub fn execute_undo(entry: crate::UndoEntry) -> mpsc::Receiver<OperationResult> {
224    let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
225
226    tokio::spawn(async move {
227        match entry.operation {
228            UndoableOperation::FilesMoved { moves } => {
229                // CRIT-1: Reverse each (original→dest) pair back individually.
230                // We rename dest→original for every pair rather than collecting all
231                // sources and supplying one destination directory (which was wrong
232                // when files came from different parent directories).
233                let total = moves.len();
234                let mut progress = OperationProgress::new(OperationType::Move, total, 0);
235                let mut succeeded = 0usize;
236                let mut failed = 0usize;
237
238                for (original, current) in moves {
239                    // current is where the file lives now; original is where it should go back
240                    progress.set_current_file(Some(current.clone()));
241                    let _ = tx.send(OperationResult::Progress(progress.clone())).await;
242
243                    let orig_clone = original.clone();
244                    let cur_clone = current.clone();
245
246                    let result = tokio::task::spawn_blocking(move || {
247                        // Ensure the original parent directory exists
248                        if let Some(parent) = orig_clone.parent() {
249                            fs::create_dir_all(parent)
250                                .map_err(|e| format!("Failed to create parent directory: {}", e))?;
251                        }
252                        // Try rename first (fast path, same filesystem).
253                        // Fall back to copy+delete for cross-filesystem pairs
254                        // (mirrors the forward move_item logic).
255                        match fs::rename(&cur_clone, &orig_clone) {
256                            Ok(()) => Ok(()),
257                            Err(_rename_err) => {
258                                let meta = fs::symlink_metadata(&cur_clone)
259                                    .map_err(|e| format!("Failed to read metadata: {}", e))?;
260                                if meta.is_dir() {
261                                    let mut visited = std::collections::HashSet::new();
262                                    crate::move_op::copy_dir_recursive_pub(
263                                        &cur_clone,
264                                        &orig_clone,
265                                        &mut visited,
266                                    )?;
267                                    fs::remove_dir_all(&cur_clone)
268                                        .map_err(|e| format!("Failed to remove source: {}", e))
269                                } else {
270                                    fs::copy(&cur_clone, &orig_clone)
271                                        .map_err(|e| format!("Failed to copy: {}", e))?;
272                                    fs::remove_file(&cur_clone)
273                                        .map_err(|e| format!("Failed to remove source: {}", e))
274                                }
275                            }
276                        }
277                    })
278                    .await;
279
280                    match result {
281                        Ok(Ok(())) => {
282                            progress.complete_file(0);
283                            succeeded += 1;
284                        }
285                        Ok(Err(e)) => {
286                            progress.add_error(crate::OperationError::new(current, e));
287                            failed += 1;
288                        }
289                        Err(e) => {
290                            progress.add_error(crate::OperationError::new(current, e.to_string()));
291                            failed += 1;
292                        }
293                    }
294                }
295
296                let _ = tx
297                    .send(OperationResult::Complete(OperationComplete {
298                        operation_type: OperationType::Move,
299                        succeeded,
300                        failed,
301                        bytes_processed: 0,
302                        errors: progress.errors,
303                    }))
304                    .await;
305            }
306            UndoableOperation::FilesCopied { created } => {
307                // Delete the copied files
308                let mut progress = OperationProgress::new(OperationType::Delete, created.len(), 0);
309                let mut succeeded = 0;
310                let mut failed = 0;
311
312                for path in created {
313                    progress.set_current_file(Some(path.clone()));
314                    let _ = tx.send(OperationResult::Progress(progress.clone())).await;
315
316                    let result = tokio::task::spawn_blocking(move || {
317                        // Use symlink_metadata to check type without following symlinks
318                        let metadata = fs::symlink_metadata(&path)?;
319                        if metadata.is_symlink() {
320                            fs::remove_file(&path)
321                        } else if metadata.is_dir() {
322                            fs::remove_dir_all(&path)
323                        } else {
324                            fs::remove_file(&path)
325                        }
326                    })
327                    .await;
328
329                    match result {
330                        Ok(Ok(())) => {
331                            succeeded += 1;
332                            progress.complete_file(0);
333                        }
334                        _ => {
335                            failed += 1;
336                        }
337                    }
338                }
339
340                let _ = tx
341                    .send(OperationResult::Complete(OperationComplete {
342                        operation_type: OperationType::Delete,
343                        succeeded,
344                        failed,
345                        bytes_processed: 0,
346                        errors: progress.errors,
347                    }))
348                    .await;
349            }
350            UndoableOperation::FilesDeleted { paths: _ } => {
351                let _ = tx
352                    .send(OperationResult::Complete(OperationComplete {
353                        operation_type: OperationType::Delete,
354                        succeeded: 0,
355                        failed: 0,
356                        bytes_processed: 0,
357                        errors: vec![crate::OperationError::new(
358                            PathBuf::new(),
359                            "Cannot undo trash operations from within the application".to_string(),
360                        )],
361                    }))
362                    .await;
363            }
364            UndoableOperation::FileRenamed {
365                path: current_path,
366                old_path,
367                new_name: _,
368            } => {
369                // HIGH-5: use the stored full old_path to determine the original name.
370                // `current_path` is where the file lives now (after rename).
371                // We rename current_path back to old_path's file_name component.
372                let old_name = old_path
373                    .file_name()
374                    .map(|n| n.to_string_lossy().into_owned())
375                    .unwrap_or_default();
376
377                let mut rename_rx = start_rename(current_path, old_name);
378
379                while let Some(result) = rename_rx.recv().await {
380                    let unified = match result {
381                        RenameResult::Progress(p) => OperationResult::Progress(p),
382                        RenameResult::Complete(c) => OperationResult::Complete(c),
383                    };
384                    if tx.send(unified).await.is_err() {
385                        break;
386                    }
387                }
388            }
389            UndoableOperation::FileCreated { path }
390            | UndoableOperation::DirectoryCreated { path } => {
391                // Delete the created item
392                let mut progress = OperationProgress::new(OperationType::Delete, 1, 0);
393                progress.set_current_file(Some(path.clone()));
394                let _ = tx.send(OperationResult::Progress(progress.clone())).await;
395
396                let path_clone = path.clone();
397                let result = tokio::task::spawn_blocking(move || {
398                    let metadata = fs::symlink_metadata(&path_clone)?;
399                    if metadata.is_symlink() {
400                        fs::remove_file(&path_clone)
401                    } else if metadata.is_dir() {
402                        fs::remove_dir_all(&path_clone)
403                    } else {
404                        fs::remove_file(&path_clone)
405                    }
406                })
407                .await;
408
409                let (succeeded, failed) = match result {
410                    Ok(Ok(())) => (1, 0),
411                    _ => (0, 1),
412                };
413
414                let _ = tx
415                    .send(OperationResult::Complete(OperationComplete {
416                        operation_type: OperationType::Delete,
417                        succeeded,
418                        failed,
419                        bytes_processed: 0,
420                        errors: progress.errors,
421                    }))
422                    .await;
423            }
424        }
425    });
426
427    rx
428}