Skip to main content

gravityfile_ops/
move_op.rs

1//! Async move operation with progress reporting.
2
3use std::collections::HashSet;
4use std::fs;
5use std::path::PathBuf;
6
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9
10use crate::conflict::{Conflict, ConflictKind, ConflictResolution, auto_rename_path};
11use crate::progress::{OperationComplete, OperationProgress, OperationType};
12use crate::{OPERATION_CHANNEL_SIZE, OperationError};
13
14/// Result sent through the channel during move operations.
15#[derive(Debug)]
16pub enum MoveResult {
17    /// Progress update.
18    Progress(OperationProgress),
19    /// A conflict was detected that needs resolution.
20    Conflict(Conflict),
21    /// The operation completed.
22    Complete(MoveComplete),
23}
24
25/// Completion result for move operations, including the moved pairs
26/// needed by the undo system.
27#[derive(Debug)]
28pub struct MoveComplete {
29    /// Standard operation completion info.
30    pub inner: OperationComplete,
31    /// Pairs of (original_source, final_destination) for undo recording.
32    pub moved_pairs: Vec<(PathBuf, PathBuf)>,
33}
34
35/// Options for move operations.
36#[derive(Debug, Clone, Default)]
37pub struct MoveOptions {
38    /// How to handle conflicts (None means ask for each).
39    pub conflict_resolution: Option<ConflictResolution>,
40}
41
42/// Start an async move operation.
43///
44/// Returns a receiver for progress updates and results.
45pub fn start_move(
46    sources: Vec<PathBuf>,
47    destination: PathBuf,
48    options: MoveOptions,
49    token: CancellationToken,
50) -> mpsc::Receiver<MoveResult> {
51    let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
52
53    if sources.is_empty() {
54        let complete = MoveComplete {
55            inner: OperationComplete {
56                operation_type: OperationType::Move,
57                succeeded: 0,
58                failed: 0,
59                bytes_processed: 0,
60                errors: vec![],
61            },
62            moved_pairs: vec![],
63        };
64        tokio::spawn(async move {
65            let _ = tx.send(MoveResult::Complete(complete)).await;
66        });
67        return rx;
68    }
69
70    tokio::spawn(async move {
71        move_impl(sources, destination, options, token, tx).await;
72    });
73
74    rx
75}
76
77/// Internal implementation of move operation.
78async fn move_impl(
79    sources: Vec<PathBuf>,
80    destination: PathBuf,
81    options: MoveOptions,
82    token: CancellationToken,
83    tx: mpsc::Sender<MoveResult>,
84) {
85    let total_files = sources.len();
86    let mut progress = OperationProgress::new(OperationType::Move, total_files, 0);
87    let global_resolution: Option<ConflictResolution> = options.conflict_resolution;
88    let mut succeeded = 0;
89    let mut failed = 0;
90    let mut moved_pairs: Vec<(PathBuf, PathBuf)> = Vec::new();
91
92    // Ensure destination exists and is a directory
93    if !destination.exists()
94        && let Err(e) = fs::create_dir_all(&destination)
95    {
96        progress.add_error(OperationError::new(
97            destination.clone(),
98            format!("Failed to create destination: {}", e),
99        ));
100        let _ = tx
101            .send(MoveResult::Complete(MoveComplete {
102                inner: OperationComplete {
103                    operation_type: OperationType::Move,
104                    succeeded: 0,
105                    failed: sources.len(),
106                    bytes_processed: 0,
107                    errors: progress.errors.clone(),
108                },
109                moved_pairs: vec![],
110            }))
111            .await;
112        return;
113    }
114
115    for source in sources {
116        // HIGH-3: check for cancellation before each item
117        if token.is_cancelled() {
118            break;
119        }
120
121        // MED-5: return error when file_name() is None
122        let file_name = match source.file_name() {
123            Some(n) => n.to_owned(),
124            None => {
125                progress.add_error(OperationError::new(
126                    source.clone(),
127                    "Source path has no filename component".to_string(),
128                ));
129                failed += 1;
130                continue;
131            }
132        };
133        let dest_path = destination.join(&file_name);
134
135        // Check for self-move (moving directory into itself)
136        if dest_path.starts_with(&source) {
137            let _ = tx
138                .send(MoveResult::Conflict(Conflict::source_is_ancestor(
139                    source.clone(),
140                    dest_path.clone(),
141                )))
142                .await;
143            failed += 1;
144            continue;
145        }
146
147        // Check for conflicts using symlink_metadata so we see the link itself
148        let dest_meta = fs::symlink_metadata(&dest_path).ok();
149        let final_dest = if dest_meta.is_some() {
150            let conflict_kind = if dest_meta.as_ref().is_some_and(|m| m.is_dir()) {
151                ConflictKind::DirectoryExists
152            } else {
153                ConflictKind::FileExists
154            };
155
156            let resolution = if let Some(res) = global_resolution {
157                res.to_single()
158            } else {
159                let _ = tx
160                    .send(MoveResult::Conflict(Conflict::new(
161                        source.clone(),
162                        dest_path.clone(),
163                        conflict_kind,
164                    )))
165                    .await;
166                ConflictResolution::Skip
167            };
168
169            match resolution {
170                ConflictResolution::Skip | ConflictResolution::SkipAll => {
171                    failed += 1;
172                    continue;
173                }
174                ConflictResolution::Abort => {
175                    let _ = tx
176                        .send(MoveResult::Complete(MoveComplete {
177                            inner: OperationComplete {
178                                operation_type: OperationType::Move,
179                                succeeded,
180                                failed: failed + 1,
181                                bytes_processed: progress.bytes_processed,
182                                errors: progress.errors.clone(),
183                            },
184                            moved_pairs,
185                        }))
186                        .await;
187                    return;
188                }
189                ConflictResolution::AutoRename => auto_rename_path(&dest_path),
190                ConflictResolution::Overwrite | ConflictResolution::OverwriteAll => {
191                    // CRIT-3: handle removal failure — record error and skip this source
192                    let remove_result = if let Some(ref m) = dest_meta {
193                        if m.is_symlink() || !m.is_dir() {
194                            fs::remove_file(&dest_path)
195                        } else {
196                            fs::remove_dir_all(&dest_path)
197                        }
198                    } else {
199                        Ok(())
200                    };
201                    if let Err(e) = remove_result {
202                        progress.add_error(OperationError::new(
203                            dest_path.clone(),
204                            format!("Failed to remove existing destination: {}", e),
205                        ));
206                        failed += 1;
207                        continue;
208                    }
209                    dest_path.clone()
210                }
211            }
212        } else {
213            dest_path.clone()
214        };
215
216        // MED-6: only send progress after the move completes, not before
217        let source_clone = source.clone();
218        let dest_clone = final_dest.clone();
219
220        let result = tokio::task::spawn_blocking(move || move_item(&source_clone, &dest_clone))
221            .await
222            .map_err(|e| format!("Task failed: {}", e));
223
224        match result {
225            Ok(Ok(bytes)) => {
226                progress.set_current_file(Some(source.clone()));
227                progress.complete_file(bytes);
228                moved_pairs.push((source.clone(), final_dest));
229                succeeded += 1;
230                let _ = tx.send(MoveResult::Progress(progress.clone())).await;
231            }
232            Ok(Err(e)) | Err(e) => {
233                progress.add_error(OperationError::new(source.clone(), e));
234                failed += 1;
235            }
236        }
237    }
238
239    // Send completion with the moved pairs for undo recording
240    let _ = tx
241        .send(MoveResult::Complete(MoveComplete {
242            inner: OperationComplete {
243                operation_type: OperationType::Move,
244                succeeded,
245                failed,
246                bytes_processed: progress.bytes_processed,
247                errors: progress.errors,
248            },
249            moved_pairs,
250        }))
251        .await;
252}
253
254/// Move a single item (file, directory, or symlink).
255fn move_item(source: &PathBuf, dest: &PathBuf) -> Result<u64, String> {
256    // HIGH-2: use symlink_metadata so we don't follow symlinks for size
257    let size = get_size(source);
258
259    // Try rename first (fast path for same filesystem)
260    if fs::rename(source, dest).is_ok() {
261        return Ok(size);
262    }
263
264    // Fall back to copy + delete for cross-filesystem moves
265    let metadata =
266        fs::symlink_metadata(source).map_err(|e| format!("Failed to read metadata: {}", e))?;
267
268    if metadata.is_symlink() {
269        // For symlinks, read the target and recreate at destination
270        let target = fs::read_link(source).map_err(|e| format!("Failed to read symlink: {}", e))?;
271        #[cfg(unix)]
272        {
273            std::os::unix::fs::symlink(&target, dest)
274                .map_err(|e| format!("Failed to create symlink: {}", e))?;
275        }
276        #[cfg(windows)]
277        {
278            if target.is_dir() {
279                std::os::windows::fs::symlink_dir(&target, dest)
280                    .map_err(|e| format!("Failed to create symlink: {}", e))?;
281            } else {
282                std::os::windows::fs::symlink_file(&target, dest)
283                    .map_err(|e| format!("Failed to create symlink: {}", e))?;
284            }
285        }
286        fs::remove_file(source).map_err(|e| format!("Failed to remove source symlink: {}", e))?;
287    } else if metadata.is_dir() {
288        if let Err(e) = copy_dir_recursive(source, dest, &mut HashSet::new()) {
289            // Best-effort cleanup of partial destination on copy failure.
290            let _ = fs::remove_dir_all(dest);
291            return Err(e);
292        }
293        if let Err(e) = fs::remove_dir_all(source) {
294            // Copy succeeded but source removal failed — both copies exist.
295            // Clean up the destination to avoid silently doubling disk usage.
296            tracing::warn!(
297                "Source removal failed after cross-fs move; cleaning up destination: {}",
298                e
299            );
300            let _ = fs::remove_dir_all(dest);
301            return Err(format!("Failed to remove source after copy: {}", e));
302        }
303    } else {
304        fs::copy(source, dest).map_err(|e| format!("Failed to copy: {}", e))?;
305        fs::remove_file(source).map_err(|e| format!("Failed to remove source: {}", e))?;
306    }
307
308    Ok(size)
309}
310
311/// Get the size of a file or directory.
312///
313/// HIGH-2: use symlink_metadata; skip symlinks.
314fn get_size(path: &PathBuf) -> u64 {
315    match fs::symlink_metadata(path) {
316        Ok(m) if m.is_symlink() => 0,
317        Ok(m) if m.is_dir() => get_dir_size(path),
318        Ok(m) => m.len(),
319        Err(_) => 0,
320    }
321}
322
323/// Get the total size of a directory.
324///
325/// HIGH-2: use entry.file_type() and symlink_metadata; skip symlinks.
326fn get_dir_size(dir: &PathBuf) -> u64 {
327    let mut size = 0u64;
328    if let Ok(entries) = fs::read_dir(dir) {
329        for entry in entries.flatten() {
330            let Ok(ft) = entry.file_type() else { continue };
331            if ft.is_symlink() {
332                // Skip symlinks in size calculation
333                continue;
334            }
335            let path = entry.path();
336            if ft.is_dir() {
337                size += get_dir_size(&path);
338            } else if let Ok(metadata) = fs::symlink_metadata(&path) {
339                size += metadata.len();
340            }
341        }
342    }
343    size
344}
345
346/// Public wrapper for `copy_dir_recursive` used by the undo path in
347/// `executor.rs` for cross-filesystem move reversal.
348pub fn copy_dir_recursive_pub(
349    source: &PathBuf,
350    dest: &PathBuf,
351    visited: &mut HashSet<u64>,
352) -> Result<(), String> {
353    copy_dir_recursive(source, dest, visited)
354}
355
356/// Recursively copy a directory (for cross-filesystem moves).
357///
358/// Uses `entry.file_type()` (no symlink-follow), handles symlinks as a distinct
359/// branch, and tracks inodes via a visited set to detect loops on Unix.
360fn copy_dir_recursive(
361    source: &PathBuf,
362    dest: &PathBuf,
363    visited: &mut HashSet<u64>,
364) -> Result<(), String> {
365    // Loop detection via inode on Unix
366    #[cfg(unix)]
367    {
368        use std::os::unix::fs::MetadataExt;
369        if let Ok(meta) = fs::symlink_metadata(source) {
370            let inode = meta.ino();
371            if !visited.insert(inode) {
372                return Ok(()); // Already visited — break the loop
373            }
374        }
375    }
376
377    fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
378
379    let entries = fs::read_dir(source).map_err(|e| format!("Failed to read directory: {}", e))?;
380
381    for entry in entries {
382        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
383        // CRIT-2: use entry.file_type() — does NOT follow symlinks
384        let file_type = entry
385            .file_type()
386            .map_err(|e| format!("Failed to read file type: {}", e))?;
387        let path = entry.path();
388        let dest_path = dest.join(entry.file_name());
389
390        if file_type.is_symlink() {
391            // Recreate the symlink at the destination instead of following it
392            let target =
393                fs::read_link(&path).map_err(|e| format!("Failed to read symlink: {}", e))?;
394            #[cfg(unix)]
395            {
396                std::os::unix::fs::symlink(&target, &dest_path)
397                    .map_err(|e| format!("Failed to create symlink: {}", e))?;
398            }
399            #[cfg(windows)]
400            {
401                // Choose symlink_dir vs symlink_file based on what the
402                // original link pointed at, and log failures rather than
403                // silently discarding them.
404                let result = if path.is_dir() {
405                    std::os::windows::fs::symlink_dir(&target, &dest_path)
406                } else {
407                    std::os::windows::fs::symlink_file(&target, &dest_path)
408                };
409                if let Err(e) = result {
410                    tracing::warn!(
411                        "Failed to create symlink {} -> {}: {}",
412                        dest_path.display(),
413                        target.display(),
414                        e
415                    );
416                }
417            }
418        } else if file_type.is_dir() {
419            copy_dir_recursive(&path, &dest_path, visited)?;
420        } else {
421            fs::copy(&path, &dest_path).map_err(|e| format!("Failed to copy file: {}", e))?;
422        }
423    }
424
425    Ok(())
426}