mixtape_tools/filesystem/
move_file.rs1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs;
5
6#[derive(Debug, Deserialize, JsonSchema)]
8pub struct MoveFileInput {
9 pub source: PathBuf,
11
12 pub destination: PathBuf,
14}
15
16pub 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 pub fn new() -> Self {
37 Self {
38 base_path: std::env::current_dir().expect("Failed to get current working directory"),
39 }
40 }
41
42 pub fn try_new() -> std::io::Result<Self> {
46 Ok(Self {
47 base_path: std::env::current_dir()?,
48 })
49 }
50
51 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 let source_path = validate_path(&self.base_path, &input.source)?;
73 let dest_path = validate_path(&self.base_path, &input.destination)?;
74
75 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(¶ms).is_empty());
125 assert!(!tool.format_input_ansi(¶ms).is_empty());
126 assert!(!tool.format_input_markdown(¶ms).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 #[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 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 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 assert!(source.exists(), "Source should not be moved");
265 }
266
267 #[tokio::test]
268 async fn test_move_file_to_existing_subdir() {
269 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 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 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 let content = std::fs::read_to_string(&dest_path).unwrap();
315 assert_eq!(content, "content");
316 }
317}