Skip to main content

mixtape_tools/filesystem/
move_file.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs;
5
6/// Input for moving/renaming a file
7#[derive(Debug, Deserialize, JsonSchema)]
8pub struct MoveFileInput {
9    /// Source path (file or directory to move)
10    pub source: PathBuf,
11
12    /// Destination path (where to move the file/directory)
13    pub destination: PathBuf,
14}
15
16/// Tool for moving or renaming files and directories
17pub struct MoveFileTool {
18    base_path: PathBuf,
19}
20
21impl Default for MoveFileTool {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl MoveFileTool {
28    /// Creates a new tool using the current working directory as the base path.
29    ///
30    /// Equivalent to `Default::default()`.
31    ///
32    /// # Panics
33    ///
34    /// Panics if the current working directory cannot be determined.
35    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
36    pub fn new() -> Self {
37        Self {
38            base_path: std::env::current_dir().expect("Failed to get current working directory"),
39        }
40    }
41
42    /// Creates a new tool using the current working directory as the base path.
43    ///
44    /// Returns an error if the current working directory cannot be determined.
45    pub fn try_new() -> std::io::Result<Self> {
46        Ok(Self {
47            base_path: std::env::current_dir()?,
48        })
49    }
50
51    /// Creates a tool with a custom base directory.
52    ///
53    /// All file operations will be constrained to this directory.
54    pub fn with_base_path(base_path: PathBuf) -> Self {
55        Self { base_path }
56    }
57}
58
59impl Tool for MoveFileTool {
60    type Input = MoveFileInput;
61
62    fn name(&self) -> &str {
63        "move_file"
64    }
65
66    fn description(&self) -> &str {
67        "Move or rename a file or directory to a new location."
68    }
69
70    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
71        // Validate both source and destination are within base directory
72        let source_path = validate_path(&self.base_path, &input.source)?;
73        let dest_path = validate_path(&self.base_path, &input.destination)?;
74
75        // Create parent directories for destination if they don't exist
76        if let Some(parent) = dest_path.parent() {
77            if !parent.exists() {
78                fs::create_dir_all(parent).await.map_err(|e| {
79                    ToolError::from(format!("Failed to create parent directories: {}", e))
80                })?;
81            }
82        }
83
84        fs::rename(&source_path, &dest_path)
85            .await
86            .map_err(|e| ToolError::from(format!("Failed to move file: {}", e)))?;
87
88        Ok(format!(
89            "Successfully moved {} to {}",
90            input.source.display(),
91            input.destination.display()
92        )
93        .into())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::fs;
101    use tempfile::TempDir;
102
103    #[test]
104    fn test_tool_metadata() {
105        let tool: MoveFileTool = Default::default();
106        assert_eq!(tool.name(), "move_file");
107        assert!(!tool.description().is_empty());
108
109        let tool2 = MoveFileTool::new();
110        assert_eq!(tool2.name(), "move_file");
111    }
112
113    #[test]
114    fn test_try_new() {
115        let tool = MoveFileTool::try_new();
116        assert!(tool.is_ok());
117    }
118
119    #[test]
120    fn test_format_methods() {
121        let tool = MoveFileTool::new();
122        let params = serde_json::json!({"source": "a.txt", "destination": "b.txt"});
123
124        assert!(!tool.format_input_plain(&params).is_empty());
125        assert!(!tool.format_input_ansi(&params).is_empty());
126        assert!(!tool.format_input_markdown(&params).is_empty());
127
128        let result = ToolResult::from("Successfully moved");
129        assert!(!tool.format_output_plain(&result).is_empty());
130        assert!(!tool.format_output_ansi(&result).is_empty());
131        assert!(!tool.format_output_markdown(&result).is_empty());
132    }
133
134    #[tokio::test]
135    async fn test_move_file() {
136        let temp_dir = TempDir::new().unwrap();
137        let source = temp_dir.path().join("source.txt");
138        fs::write(&source, "content").unwrap();
139
140        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
141        let input = MoveFileInput {
142            source: PathBuf::from("source.txt"),
143            destination: PathBuf::from("dest.txt"),
144        };
145
146        let result = tool.execute(input).await.unwrap();
147        assert!(result.as_text().contains("Successfully moved"));
148        assert!(!source.exists());
149        assert!(temp_dir.path().join("dest.txt").exists());
150    }
151
152    #[tokio::test]
153    async fn test_rename_directory() {
154        let temp_dir = TempDir::new().unwrap();
155        let source = temp_dir.path().join("old_dir");
156        fs::create_dir(&source).unwrap();
157
158        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
159        let input = MoveFileInput {
160            source: PathBuf::from("old_dir"),
161            destination: PathBuf::from("new_dir"),
162        };
163
164        let result = tool.execute(input).await.unwrap();
165        assert!(result.as_text().contains("Successfully moved"));
166        assert!(!source.exists());
167        assert!(temp_dir.path().join("new_dir").exists());
168    }
169
170    // ===== Error Path Tests =====
171
172    #[tokio::test]
173    async fn test_move_file_source_not_found() {
174        let temp_dir = TempDir::new().unwrap();
175        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
176
177        let input = MoveFileInput {
178            source: PathBuf::from("nonexistent.txt"),
179            destination: PathBuf::from("dest.txt"),
180        };
181
182        let result = tool.execute(input).await;
183        assert!(result.is_err(), "Should fail when source doesn't exist");
184    }
185
186    #[tokio::test]
187    async fn test_move_file_rejects_source_path_traversal() {
188        let temp_dir = TempDir::new().unwrap();
189        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
190
191        let input = MoveFileInput {
192            source: PathBuf::from("../../../etc/passwd"),
193            destination: PathBuf::from("stolen.txt"),
194        };
195
196        let result = tool.execute(input).await;
197        assert!(result.is_err(), "Should reject source path traversal");
198    }
199
200    #[tokio::test]
201    async fn test_move_file_rejects_dest_path_traversal() {
202        let temp_dir = TempDir::new().unwrap();
203        fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
204
205        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
206
207        let input = MoveFileInput {
208            source: PathBuf::from("source.txt"),
209            destination: PathBuf::from("../../../tmp/escaped.txt"),
210        };
211
212        let result = tool.execute(input).await;
213        assert!(result.is_err(), "Should reject destination path traversal");
214    }
215
216    #[tokio::test]
217    async fn test_move_file_with_absolute_paths_inside_base() {
218        let temp_dir = TempDir::new().unwrap();
219        let source = temp_dir.path().join("source.txt");
220        let dest = temp_dir.path().join("dest.txt");
221        fs::write(&source, "content").unwrap();
222
223        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
224
225        let input = MoveFileInput {
226            source: source.clone(),
227            destination: dest.clone(),
228        };
229
230        let result = tool.execute(input).await;
231        assert!(result.is_ok(), "Should allow absolute paths within base");
232        assert!(!source.exists());
233        assert!(dest.exists());
234    }
235
236    #[tokio::test]
237    async fn test_move_file_rejects_absolute_dest_outside_base() {
238        // Security test: absolute destination path escaping base directory
239        let temp_dir = TempDir::new().unwrap();
240        let other_dir = TempDir::new().unwrap();
241
242        let source = temp_dir.path().join("source.txt");
243        fs::write(&source, "content").unwrap();
244
245        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
246
247        // Try to move to an absolute path outside base
248        let input = MoveFileInput {
249            source: PathBuf::from("source.txt"),
250            destination: other_dir.path().join("stolen.txt"),
251        };
252
253        let result = tool.execute(input).await;
254        assert!(
255            result.is_err(),
256            "Should reject absolute destination outside base"
257        );
258        assert!(
259            result.unwrap_err().to_string().contains("escapes"),
260            "Error should mention escaping base directory"
261        );
262
263        // Source should still exist (move was rejected)
264        assert!(source.exists(), "Source should not be moved");
265    }
266
267    #[tokio::test]
268    async fn test_move_file_to_existing_subdir() {
269        // Test destination with existing parent that's within base
270        let temp_dir = TempDir::new().unwrap();
271        fs::create_dir(temp_dir.path().join("subdir")).unwrap();
272        fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
273
274        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
275
276        let input = MoveFileInput {
277            source: PathBuf::from("source.txt"),
278            destination: PathBuf::from("subdir/moved.txt"),
279        };
280
281        let result = tool.execute(input).await;
282        assert!(result.is_ok(), "Should allow moving to existing subdir");
283        assert!(temp_dir.path().join("subdir/moved.txt").exists());
284    }
285
286    #[tokio::test]
287    async fn test_move_file_creates_parent_directories() {
288        let temp_dir = TempDir::new().unwrap();
289        fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
290
291        let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
292
293        // Destination has non-existent parent directories
294        let input = MoveFileInput {
295            source: PathBuf::from("source.txt"),
296            destination: PathBuf::from("nonexistent/subdir/moved.txt"),
297        };
298
299        let result = tool.execute(input).await;
300        assert!(
301            result.is_ok(),
302            "Should create parent directories automatically"
303        );
304
305        // Verify the file was moved
306        let dest_path = temp_dir.path().join("nonexistent/subdir/moved.txt");
307        assert!(dest_path.exists(), "File should exist at destination");
308        assert!(
309            !temp_dir.path().join("source.txt").exists(),
310            "Source should no longer exist"
311        );
312
313        // Verify content is preserved
314        let content = std::fs::read_to_string(&dest_path).unwrap();
315        assert_eq!(content, "content");
316    }
317}