use crate::filesystem::validate_path;
use crate::prelude::*;
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct MoveFileInput {
pub source: PathBuf,
pub destination: PathBuf,
}
pub struct MoveFileTool {
base_path: PathBuf,
}
impl Default for MoveFileTool {
fn default() -> Self {
Self::new()
}
}
impl MoveFileTool {
pub fn new() -> Self {
Self {
base_path: std::env::current_dir().expect("Failed to get current working directory"),
}
}
pub fn try_new() -> std::io::Result<Self> {
Ok(Self {
base_path: std::env::current_dir()?,
})
}
pub fn with_base_path(base_path: PathBuf) -> Self {
Self { base_path }
}
}
impl Tool for MoveFileTool {
type Input = MoveFileInput;
fn name(&self) -> &str {
"move_file"
}
fn description(&self) -> &str {
"Move or rename a file or directory to a new location."
}
async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
let source_path = validate_path(&self.base_path, &input.source)?;
let dest_path = validate_path(&self.base_path, &input.destination)?;
if let Some(parent) = dest_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).await.map_err(|e| {
ToolError::from(format!("Failed to create parent directories: {}", e))
})?;
}
}
fs::rename(&source_path, &dest_path)
.await
.map_err(|e| ToolError::from(format!("Failed to move file: {}", e)))?;
Ok(format!(
"Successfully moved {} to {}",
input.source.display(),
input.destination.display()
)
.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_tool_metadata() {
let tool: MoveFileTool = Default::default();
assert_eq!(tool.name(), "move_file");
assert!(!tool.description().is_empty());
let tool2 = MoveFileTool::new();
assert_eq!(tool2.name(), "move_file");
}
#[test]
fn test_try_new() {
let tool = MoveFileTool::try_new();
assert!(tool.is_ok());
}
#[test]
fn test_format_methods() {
let tool = MoveFileTool::new();
let params = serde_json::json!({"source": "a.txt", "destination": "b.txt"});
assert!(!tool.format_input_plain(¶ms).is_empty());
assert!(!tool.format_input_ansi(¶ms).is_empty());
assert!(!tool.format_input_markdown(¶ms).is_empty());
let result = ToolResult::from("Successfully moved");
assert!(!tool.format_output_plain(&result).is_empty());
assert!(!tool.format_output_ansi(&result).is_empty());
assert!(!tool.format_output_markdown(&result).is_empty());
}
#[tokio::test]
async fn test_move_file() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source.txt");
fs::write(&source, "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("source.txt"),
destination: PathBuf::from("dest.txt"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("Successfully moved"));
assert!(!source.exists());
assert!(temp_dir.path().join("dest.txt").exists());
}
#[tokio::test]
async fn test_rename_directory() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("old_dir");
fs::create_dir(&source).unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("old_dir"),
destination: PathBuf::from("new_dir"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("Successfully moved"));
assert!(!source.exists());
assert!(temp_dir.path().join("new_dir").exists());
}
#[tokio::test]
async fn test_move_file_source_not_found() {
let temp_dir = TempDir::new().unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("nonexistent.txt"),
destination: PathBuf::from("dest.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_err(), "Should fail when source doesn't exist");
}
#[tokio::test]
async fn test_move_file_rejects_source_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("../../../etc/passwd"),
destination: PathBuf::from("stolen.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_err(), "Should reject source path traversal");
}
#[tokio::test]
async fn test_move_file_rejects_dest_path_traversal() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("source.txt"),
destination: PathBuf::from("../../../tmp/escaped.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_err(), "Should reject destination path traversal");
}
#[tokio::test]
async fn test_move_file_with_absolute_paths_inside_base() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source.txt");
let dest = temp_dir.path().join("dest.txt");
fs::write(&source, "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: source.clone(),
destination: dest.clone(),
};
let result = tool.execute(input).await;
assert!(result.is_ok(), "Should allow absolute paths within base");
assert!(!source.exists());
assert!(dest.exists());
}
#[tokio::test]
async fn test_move_file_rejects_absolute_dest_outside_base() {
let temp_dir = TempDir::new().unwrap();
let other_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source.txt");
fs::write(&source, "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("source.txt"),
destination: other_dir.path().join("stolen.txt"),
};
let result = tool.execute(input).await;
assert!(
result.is_err(),
"Should reject absolute destination outside base"
);
assert!(
result.unwrap_err().to_string().contains("escapes"),
"Error should mention escaping base directory"
);
assert!(source.exists(), "Source should not be moved");
}
#[tokio::test]
async fn test_move_file_to_existing_subdir() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("subdir")).unwrap();
fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("source.txt"),
destination: PathBuf::from("subdir/moved.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_ok(), "Should allow moving to existing subdir");
assert!(temp_dir.path().join("subdir/moved.txt").exists());
}
#[tokio::test]
async fn test_move_file_creates_parent_directories() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("source.txt"), "content").unwrap();
let tool = MoveFileTool::with_base_path(temp_dir.path().to_path_buf());
let input = MoveFileInput {
source: PathBuf::from("source.txt"),
destination: PathBuf::from("nonexistent/subdir/moved.txt"),
};
let result = tool.execute(input).await;
assert!(
result.is_ok(),
"Should create parent directories automatically"
);
let dest_path = temp_dir.path().join("nonexistent/subdir/moved.txt");
assert!(dest_path.exists(), "File should exist at destination");
assert!(
!temp_dir.path().join("source.txt").exists(),
"Source should no longer exist"
);
let content = std::fs::read_to_string(&dest_path).unwrap();
assert_eq!(content, "content");
}
}