Skip to main content

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::{OPERATION_CHANNEL_SIZE, OperationError};
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 — use symlink_metadata so we see the link itself
60    // LOW-5: replace new_path.exists() (follows symlinks) with symlink_metadata check
61    if new_path.symlink_metadata().is_ok() && new_path != source {
62        progress.add_error(OperationError::new(
63            source.clone(),
64            format!("'{}' already exists", new_name),
65        ));
66        let _ = tx
67            .send(RenameResult::Complete(OperationComplete {
68                operation_type: OperationType::Rename,
69                succeeded: 0,
70                failed: 1,
71                bytes_processed: 0,
72                errors: progress.errors,
73            }))
74            .await;
75        return;
76    }
77
78    // Perform the rename
79    let source_clone = source.clone();
80    let new_path_clone = new_path.clone();
81
82    let result = tokio::task::spawn_blocking(move || fs::rename(&source_clone, &new_path_clone))
83        .await
84        .map_err(|e| format!("Task failed: {}", e));
85
86    match result {
87        Ok(Ok(())) => {
88            progress.complete_file(0);
89            let _ = tx
90                .send(RenameResult::Complete(OperationComplete {
91                    operation_type: OperationType::Rename,
92                    succeeded: 1,
93                    failed: 0,
94                    bytes_processed: 0,
95                    errors: vec![],
96                }))
97                .await;
98        }
99        Ok(Err(e)) => {
100            progress.add_error(OperationError::new(source, format!("Rename failed: {}", e)));
101            let _ = tx
102                .send(RenameResult::Complete(OperationComplete {
103                    operation_type: OperationType::Rename,
104                    succeeded: 0,
105                    failed: 1,
106                    bytes_processed: 0,
107                    errors: progress.errors,
108                }))
109                .await;
110        }
111        Err(e) => {
112            progress.add_error(OperationError::new(source, e));
113            let _ = tx
114                .send(RenameResult::Complete(OperationComplete {
115                    operation_type: OperationType::Rename,
116                    succeeded: 0,
117                    failed: 1,
118                    bytes_processed: 0,
119                    errors: progress.errors,
120                }))
121                .await;
122        }
123    }
124}
125
126/// Validate a filename for cross-platform compatibility.
127pub fn validate_filename(name: &str) -> Result<(), String> {
128    if name.is_empty() {
129        return Err("Name cannot be empty".into());
130    }
131
132    // Most filesystems (ext4, APFS, NTFS) enforce a 255-byte limit on
133    // filenames, not a 255-character limit. A name like "界".repeat(255)
134    // is 255 characters but 765 bytes and will be rejected by the OS.
135    if name.len() > 255 {
136        return Err("Name is too long (max 255 bytes)".into());
137    }
138
139    // Check for invalid characters
140    let invalid_chars = ['/', '\0'];
141    for c in invalid_chars {
142        if name.contains(c) {
143            return Err(format!("Name cannot contain '{}'", c));
144        }
145    }
146
147    // Additional Windows restrictions (good to enforce everywhere for portability)
148    #[cfg(target_os = "windows")]
149    {
150        let windows_invalid = ['\\', ':', '*', '?', '"', '<', '>', '|'];
151        for c in windows_invalid {
152            if name.contains(c) {
153                return Err(format!("Name cannot contain '{}'", c));
154            }
155        }
156
157        // Check for reserved names
158        let reserved = [
159            "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
160            "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
161        ];
162        let upper_name = name.to_uppercase();
163        let base_name = upper_name.split('.').next().unwrap_or("");
164        if reserved.contains(&base_name) {
165            return Err("Reserved filename".into());
166        }
167    }
168
169    // Check for leading/trailing spaces or dots (problematic on Windows)
170    if name.starts_with(' ') || name.ends_with(' ') {
171        return Err("Name cannot start or end with spaces".into());
172    }
173
174    if name.ends_with('.') {
175        return Err("Name cannot end with a dot".into());
176    }
177
178    // Check for . and .. which are reserved
179    if name == "." || name == ".." {
180        return Err("'.' and '..' are reserved names".into());
181    }
182
183    Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_validate_filename_valid() {
192        assert!(validate_filename("test.txt").is_ok());
193        assert!(validate_filename("my-file").is_ok());
194        assert!(validate_filename(".hidden").is_ok());
195        assert!(validate_filename("file with spaces").is_ok());
196    }
197
198    #[test]
199    fn test_validate_filename_invalid() {
200        assert!(validate_filename("").is_err());
201        assert!(validate_filename("test/file").is_err());
202        assert!(validate_filename(".").is_err());
203        assert!(validate_filename("..").is_err());
204        assert!(validate_filename("file ").is_err());
205        assert!(validate_filename(" file").is_err());
206        assert!(validate_filename("file.").is_err());
207    }
208}