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
151                    let _ = if dest_path.is_dir() {
152                        fs::remove_dir_all(&dest_path)
153                    } else {
154                        fs::remove_file(&dest_path)
155                    };
156                    dest_path.clone()
157                }
158            }
159        } else {
160            dest_path.clone()
161        };
162
163        // Update progress
164        progress.set_current_file(Some(source.clone()));
165        let _ = tx.send(MoveResult::Progress(progress.clone())).await;
166
167        // Perform the move
168        let source_clone = source.clone();
169        let dest_clone = final_dest.clone();
170
171        let result = tokio::task::spawn_blocking(move || move_item(&source_clone, &dest_clone))
172            .await
173            .map_err(|e| format!("Task failed: {}", e));
174
175        match result {
176            Ok(Ok(bytes)) => {
177                progress.complete_file(bytes);
178                moved_pairs.push((source.clone(), final_dest));
179                succeeded += 1;
180            }
181            Ok(Err(e)) | Err(e) => {
182                progress.add_error(OperationError::new(source.clone(), e));
183                failed += 1;
184            }
185        }
186
187        let _ = tx.send(MoveResult::Progress(progress.clone())).await;
188    }
189
190    // Send completion
191    let _ = tx
192        .send(MoveResult::Complete(OperationComplete {
193            operation_type: OperationType::Move,
194            succeeded,
195            failed,
196            bytes_processed: progress.bytes_processed,
197            errors: progress.errors,
198        }))
199        .await;
200}
201
202/// Move a single item (file or directory).
203fn move_item(source: &PathBuf, dest: &PathBuf) -> Result<u64, String> {
204    // Get size before move
205    let size = get_size(source);
206
207    // Try rename first (fast path for same filesystem)
208    if fs::rename(source, dest).is_ok() {
209        return Ok(size);
210    }
211
212    // Fall back to copy + delete for cross-filesystem moves
213    if source.is_dir() {
214        copy_dir_recursive(source, dest)?;
215        fs::remove_dir_all(source).map_err(|e| format!("Failed to remove source: {}", e))?;
216    } else {
217        fs::copy(source, dest).map_err(|e| format!("Failed to copy: {}", e))?;
218        fs::remove_file(source).map_err(|e| format!("Failed to remove source: {}", e))?;
219    }
220
221    Ok(size)
222}
223
224/// Get the size of a file or directory.
225fn get_size(path: &PathBuf) -> u64 {
226    if path.is_dir() {
227        get_dir_size(path)
228    } else {
229        fs::metadata(path).map(|m| m.len()).unwrap_or(0)
230    }
231}
232
233/// Get the total size of a directory.
234fn get_dir_size(dir: &PathBuf) -> u64 {
235    let mut size = 0u64;
236    if let Ok(entries) = fs::read_dir(dir) {
237        for entry in entries.flatten() {
238            let path = entry.path();
239            if path.is_dir() {
240                size += get_dir_size(&path);
241            } else if let Ok(metadata) = fs::metadata(&path) {
242                size += metadata.len();
243            }
244        }
245    }
246    size
247}
248
249/// Recursively copy a directory (for cross-filesystem moves).
250fn copy_dir_recursive(source: &PathBuf, dest: &PathBuf) -> Result<(), String> {
251    fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
252
253    let entries =
254        fs::read_dir(source).map_err(|e| format!("Failed to read directory: {}", e))?;
255
256    for entry in entries {
257        let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
258        let path = entry.path();
259        let dest_path = dest.join(entry.file_name());
260
261        if path.is_dir() {
262            copy_dir_recursive(&path, &dest_path)?;
263        } else {
264            fs::copy(&path, &dest_path).map_err(|e| format!("Failed to copy file: {}", e))?;
265        }
266    }
267
268    Ok(())
269}