gravityfile_ops/
rename.rs

1//! Rename operation.
2
3use std::fs;
4use std::path::PathBuf;
5
6use tokio::sync::mpsc;
7
8use crate::progress::{OperationComplete, OperationProgress, OperationType};
9use crate::{OperationError, OPERATION_CHANNEL_SIZE};
10
11/// Result sent through the channel during rename operations.
12#[derive(Debug)]
13pub enum RenameResult {
14    /// Progress update.
15    Progress(OperationProgress),
16    /// The operation completed.
17    Complete(OperationComplete),
18}
19
20/// Start an async rename operation.
21///
22/// Renames a single file or directory.
23pub fn start_rename(source: PathBuf, new_name: String) -> mpsc::Receiver<RenameResult> {
24    let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
25
26    tokio::spawn(async move {
27        rename_impl(source, new_name, tx).await;
28    });
29
30    rx
31}
32
33/// Internal implementation of rename operation.
34async fn rename_impl(source: PathBuf, new_name: String, tx: mpsc::Sender<RenameResult>) {
35    let mut progress = OperationProgress::new(OperationType::Rename, 1, 0);
36    progress.set_current_file(Some(source.clone()));
37
38    let _ = tx.send(RenameResult::Progress(progress.clone())).await;
39
40    // Validate the new name
41    if let Err(e) = validate_filename(&new_name) {
42        progress.add_error(OperationError::new(source.clone(), e));
43        let _ = tx
44            .send(RenameResult::Complete(OperationComplete {
45                operation_type: OperationType::Rename,
46                succeeded: 0,
47                failed: 1,
48                bytes_processed: 0,
49                errors: progress.errors,
50            }))
51            .await;
52        return;
53    }
54
55    // Construct the new path
56    let parent = source.parent().unwrap_or(std::path::Path::new(""));
57    let new_path = parent.join(&new_name);
58
59    // Check if target already exists
60    if new_path.exists() && new_path != source {
61        progress.add_error(OperationError::new(
62            source.clone(),
63            format!("'{}' already exists", new_name),
64        ));
65        let _ = tx
66            .send(RenameResult::Complete(OperationComplete {
67                operation_type: OperationType::Rename,
68                succeeded: 0,
69                failed: 1,
70                bytes_processed: 0,
71                errors: progress.errors,
72            }))
73            .await;
74        return;
75    }
76
77    // Perform the rename
78    let source_clone = source.clone();
79    let new_path_clone = new_path.clone();
80
81    let result = tokio::task::spawn_blocking(move || fs::rename(&source_clone, &new_path_clone))
82        .await
83        .map_err(|e| format!("Task failed: {}", e));
84
85    match result {
86        Ok(Ok(())) => {
87            progress.complete_file(0);
88            let _ = tx
89                .send(RenameResult::Complete(OperationComplete {
90                    operation_type: OperationType::Rename,
91                    succeeded: 1,
92                    failed: 0,
93                    bytes_processed: 0,
94                    errors: vec![],
95                }))
96                .await;
97        }
98        Ok(Err(e)) => {
99            progress.add_error(OperationError::new(source, format!("Rename failed: {}", e)));
100            let _ = tx
101                .send(RenameResult::Complete(OperationComplete {
102                    operation_type: OperationType::Rename,
103                    succeeded: 0,
104                    failed: 1,
105                    bytes_processed: 0,
106                    errors: progress.errors,
107                }))
108                .await;
109        }
110        Err(e) => {
111            progress.add_error(OperationError::new(source, e));
112            let _ = tx
113                .send(RenameResult::Complete(OperationComplete {
114                    operation_type: OperationType::Rename,
115                    succeeded: 0,
116                    failed: 1,
117                    bytes_processed: 0,
118                    errors: progress.errors,
119                }))
120                .await;
121        }
122    }
123}
124
125/// Validate a filename for cross-platform compatibility.
126pub fn validate_filename(name: &str) -> Result<(), String> {
127    if name.is_empty() {
128        return Err("Name cannot be empty".into());
129    }
130
131    if name.len() > 255 {
132        return Err("Name is too long (max 255 characters)".into());
133    }
134
135    // Check for invalid characters
136    let invalid_chars = ['/', '\0'];
137    for c in invalid_chars {
138        if name.contains(c) {
139            return Err(format!("Name cannot contain '{}'", c));
140        }
141    }
142
143    // Additional Windows restrictions (good to enforce everywhere for portability)
144    #[cfg(target_os = "windows")]
145    {
146        let windows_invalid = ['\\', ':', '*', '?', '"', '<', '>', '|'];
147        for c in windows_invalid {
148            if name.contains(c) {
149                return Err(format!("Name cannot contain '{}'", c));
150            }
151        }
152
153        // Check for reserved names
154        let reserved = [
155            "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
156            "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
157        ];
158        let upper_name = name.to_uppercase();
159        let base_name = upper_name.split('.').next().unwrap_or("");
160        if reserved.contains(&base_name) {
161            return Err("Reserved filename".into());
162        }
163    }
164
165    // Check for leading/trailing spaces or dots (problematic on Windows)
166    if name.starts_with(' ') || name.ends_with(' ') {
167        return Err("Name cannot start or end with spaces".into());
168    }
169
170    if name.ends_with('.') {
171        return Err("Name cannot end with a dot".into());
172    }
173
174    // Check for . and .. which are reserved
175    if name == "." || name == ".." {
176        return Err("'.' and '..' are reserved names".into());
177    }
178
179    Ok(())
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_validate_filename_valid() {
188        assert!(validate_filename("test.txt").is_ok());
189        assert!(validate_filename("my-file").is_ok());
190        assert!(validate_filename(".hidden").is_ok());
191        assert!(validate_filename("file with spaces").is_ok());
192    }
193
194    #[test]
195    fn test_validate_filename_invalid() {
196        assert!(validate_filename("").is_err());
197        assert!(validate_filename("test/file").is_err());
198        assert!(validate_filename(".").is_err());
199        assert!(validate_filename("..").is_err());
200        assert!(validate_filename("file ").is_err());
201        assert!(validate_filename(" file").is_err());
202        assert!(validate_filename("file.").is_err());
203    }
204}