gravityfile_ops/
move_op.rs

1//! Async move operation with progress reporting.
2
3use std::fs;
4use std::path::PathBuf;
5
6use tokio::sync::mpsc;
7
8use crate::conflict::{auto_rename_path, Conflict, ConflictKind, ConflictResolution};
9use crate::progress::{OperationComplete, OperationProgress, OperationType};
10use crate::{OperationError, OPERATION_CHANNEL_SIZE};
11
12/// Result sent through the channel during move operations.
13#[derive(Debug)]
14pub enum MoveResult {
15    /// Progress update.
16    Progress(OperationProgress),
17    /// A conflict was detected that needs resolution.
18    Conflict(Conflict),
19    /// The operation completed.
20    Complete(OperationComplete),
21}
22
23/// Options for move operations.
24#[derive(Debug, Clone, Default)]
25pub struct MoveOptions {
26    /// How to handle conflicts (None means ask for each).
27    pub conflict_resolution: Option<ConflictResolution>,
28}
29
30/// Start an async move operation.
31///
32/// Returns a receiver for progress updates and results.
33pub fn start_move(
34    sources: Vec<PathBuf>,
35    destination: PathBuf,
36    options: MoveOptions,
37) -> mpsc::Receiver<MoveResult> {
38    let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
39
40    if sources.is_empty() {
41        let complete = OperationComplete {
42            operation_type: OperationType::Move,
43            succeeded: 0,
44            failed: 0,
45            bytes_processed: 0,
46            errors: vec![],
47        };
48        tokio::spawn(async move {
49            let _ = tx.send(MoveResult::Complete(complete)).await;
50        });
51        return rx;
52    }
53
54    tokio::spawn(async move {
55        move_impl(sources, destination, options, tx).await;
56    });
57
58    rx
59}
60
61/// Internal implementation of move operation.
62async fn move_impl(
63    sources: Vec<PathBuf>,
64    destination: PathBuf,
65    options: MoveOptions,
66    tx: mpsc::Sender<MoveResult>,
67) {
68    let total_files = sources.len();
69    let mut progress = OperationProgress::new(OperationType::Move, total_files, 0);
70    let global_resolution: Option<ConflictResolution> = options.conflict_resolution;
71    let mut succeeded = 0;
72    let mut failed = 0;
73    let mut moved_pairs: Vec<(PathBuf, PathBuf)> = Vec::new();
74
75    // Ensure destination exists and is a directory
76    if !destination.exists() {
77        if let Err(e) = fs::create_dir_all(&destination) {
78            progress.add_error(OperationError::new(
79                destination.clone(),
80                format!("Failed to create destination: {}", e),
81            ));
82            let _ = tx
83                .send(MoveResult::Complete(OperationComplete {
84                    operation_type: OperationType::Move,
85                    succeeded: 0,
86                    failed: sources.len(),
87                    bytes_processed: 0,
88                    errors: progress.errors.clone(),
89                }))
90                .await;
91            return;
92        }
93    }
94
95    for source in sources {
96        let dest_path = destination.join(source.file_name().unwrap_or_default());
97
98        // Check for self-move (moving directory into itself)
99        if dest_path.starts_with(&source) {
100            let _ = tx
101                .send(MoveResult::Conflict(Conflict::source_is_ancestor(
102                    source.clone(),
103                    dest_path.clone(),
104                )))
105                .await;
106            failed += 1;
107            continue;
108        }
109
110        // Check for conflicts
111        let final_dest = if dest_path.exists() {
112            let conflict_kind = if dest_path.is_dir() {
113                ConflictKind::DirectoryExists
114            } else {
115                ConflictKind::FileExists
116            };
117
118            let resolution = if let Some(res) = global_resolution {
119                res.to_single()
120            } else {
121                let _ = tx
122                    .send(MoveResult::Conflict(Conflict::new(
123                        source.clone(),
124                        dest_path.clone(),
125                        conflict_kind,
126                    )))
127                    .await;
128                ConflictResolution::Skip
129            };
130
131            match resolution {
132                ConflictResolution::Skip | ConflictResolution::SkipAll => {
133                    failed += 1;
134                    continue;
135                }
136                ConflictResolution::Abort => {
137                    let _ = tx
138                        .send(MoveResult::Complete(OperationComplete {
139                            operation_type: OperationType::Move,
140                            succeeded,
141                            failed: failed + 1,
142                            bytes_processed: progress.bytes_processed,
143                            errors: progress.errors.clone(),
144                        }))
145                        .await;
146                    return;
147                }
148                ConflictResolution::AutoRename => auto_rename_path(&dest_path),
149                ConflictResolution::Overwrite | ConflictResolution::OverwriteAll => {
150                    // Remove existing before move - use symlink_metadata to avoid following symlinks
151                    let _ = if let Ok(metadata) = fs::symlink_metadata(&dest_path) {
152                        if metadata.is_symlink() {
153                            fs::remove_file(&dest_path)
154                        } else if metadata.is_dir() {
155                            fs::remove_dir_all(&dest_path)
156                        } else {
157                            fs::remove_file(&dest_path)
158                        }
159                    } else {
160                        Ok(())
161                    };
162                    dest_path.clone()
163                }
164            }
165        } else {
166            dest_path.clone()
167        };
168
169        // Update progress
170        progress.set_current_file(Some(source.clone()));
171        let _ = tx.send(MoveResult::Progress(progress.clone())).await;
172
173        // Perform the move
174        let source_clone = source.clone();
175        let dest_clone = final_dest.clone();
176
177        let result = tokio::task::spawn_blocking(move || move_item(&source_clone, &dest_clone))
178            .await
179            .map_err(|e| format!("Task failed: {}", e));
180
181        match result {
182            Ok(Ok(bytes)) => {
183                progress.complete_file(bytes);
184                moved_pairs.push((source.clone(), final_dest));
185                succeeded += 1;
186            }
187            Ok(Err(e)) | Err(e) => {
188                progress.add_error(OperationError::new(source.clone(), e));
189                failed += 1;
190            }
191        }
192
193        let _ = tx.send(MoveResult::Progress(progress.clone())).await;
194    }
195
196    // Send completion
197    let _ = tx
198        .send(MoveResult::Complete(OperationComplete {
199            operation_type: OperationType::Move,
200            succeeded,
201            failed,
202            bytes_processed: progress.bytes_processed,
203            errors: progress.errors,
204        }))
205        .await;
206}
207
208/// Move a single item (file, directory, or symlink).
209fn move_item(source: &PathBuf, dest: &PathBuf) -> Result<u64, String> {
210    // Get size before move
211    let size = get_size(source);
212
213    // Try rename first (fast path for same filesystem)
214    if fs::rename(source, dest).is_ok() {
215        return Ok(size);
216    }
217
218    // Fall back to copy + delete for cross-filesystem moves
219    // Use symlink_metadata to avoid following symlinks
220    let metadata = fs::symlink_metadata(source).map_err(|e| format!("Failed to read metadata: {}", e))?;
221
222    if metadata.is_symlink() {
223        // For symlinks, read the target and recreate at destination
224        let target = fs::read_link(source).map_err(|e| format!("Failed to read symlink: {}", e))?;
225        #[cfg(unix)]
226        {
227            std::os::unix::fs::symlink(&target, dest).map_err(|e| format!("Failed to create symlink: {}", e))?;
228        }
229        #[cfg(windows)]
230        {
231            if target.is_dir() {
232                std::os::windows::fs::symlink_dir(&target, dest).map_err(|e| format!("Failed to create symlink: {}", e))?;
233            } else {
234                std::os::windows::fs::symlink_file(&target, dest).map_err(|e| format!("Failed to create symlink: {}", e))?;
235            }
236        }
237        fs::remove_file(source).map_err(|e| format!("Failed to remove source symlink: {}", e))?;
238    } else if metadata.is_dir() {
239        copy_dir_recursive(source, dest)?;
240        fs::remove_dir_all(source).map_err(|e| format!("Failed to remove source: {}", e))?;
241    } else {
242        fs::copy(source, dest).map_err(|e| format!("Failed to copy: {}", e))?;
243        fs::remove_file(source).map_err(|e| format!("Failed to remove source: {}", e))?;
244    }
245
246    Ok(size)
247}
248
249/// Get the size of a file or directory.
250fn get_size(path: &PathBuf) -> u64 {
251    if path.is_dir() {
252        get_dir_size(path)
253    } else {
254        fs::metadata(path).map(|m| m.len()).unwrap_or(0)
255    }
256}
257
258/// Get the total size of a directory.
259fn get_dir_size(dir: &PathBuf) -> u64 {
260    let mut size = 0u64;
261    if let Ok(entries) = fs::read_dir(dir) {
262        for entry in entries.flatten() {
263            let path = entry.path();
264            if path.is_dir() {
265                size += get_dir_size(&path);
266            } else if let Ok(metadata) = fs::metadata(&path) {
267                size += metadata.len();
268            }
269        }
270    }
271    size
272}
273
274/// Recursively copy a directory (for cross-filesystem moves).
275fn copy_dir_recursive(source: &PathBuf, dest: &PathBuf) -> Result<(), String> {
276    fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
277
278    let entries =
279        fs::read_dir(source).map_err(|e| format!("Failed to read directory: {}", e))?;
280
281    for entry in entries {
282        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
283        let path = entry.path();
284        let dest_path = dest.join(entry.file_name());
285
286        if path.is_dir() {
287            copy_dir_recursive(&path, &dest_path)?;
288        } else {
289            fs::copy(&path, &dest_path).map_err(|e| format!("Failed to copy file: {}", e))?;
290        }
291    }
292
293    Ok(())
294}