use super::FileOpsTool;
use crate::tools::error_helpers::{deserialize_tool_args, with_file_context, with_path_context};
use crate::tools::traits::FileTool;
use crate::tools::types::{CopyInput, CreateInput, DeleteInput, MoveInput};
use crate::utils::file_utils::ensure_dir_exists;
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use tracing::info;
use vtcode_commons::canonicalize_async;
impl FileOpsTool {
async fn validate_move_copy_args(
&self,
path: &str,
destination: &str,
operation: &str,
) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
let from_path = self.normalize_and_validate_user_path(path).await?;
let to_path = self.normalize_and_validate_user_path(destination).await?;
if self.should_exclude(&from_path).await {
return Err(anyhow!("Error: Path '{path}' is excluded by .vtcodegitignore and cannot be {operation}."));
}
if self.should_exclude(&to_path).await {
return Err(anyhow!("Error: Destination '{destination}' is excluded by .vtcodegitignore."));
}
if from_path == to_path || to_path.starts_with(&from_path) || from_path.starts_with(&to_path) {
return Err(anyhow!("Source and destination must have distinct, non-overlapping identities"));
}
if !tokio::fs::try_exists(&from_path).await? {
return Err(anyhow!("Source path '{path}' does not exist"));
}
if let Some(parent) = to_path.parent() {
ensure_dir_exists(parent).await?;
}
Ok((from_path, to_path))
}
async fn require_existing_tree(&self, root: &std::path::Path) -> Result<()> {
match self.inspect_existing_tree(root).await {
Err(error)
if self.edited_file_monitor.source_inspection().mode()
== vtcode_config::core::agent::InspectionMode::Warn =>
{
tracing::warn!(path = %root.display(), %error, "Source tree inspection unavailable");
Ok(())
}
result => result,
}
}
async fn inspect_existing_tree(&self, root: &std::path::Path) -> Result<()> {
let mut pending = vec![root.to_path_buf()];
while let Some(path) = pending.pop() {
let metadata = tokio::fs::symlink_metadata(&path).await?;
if metadata.is_symlink() {
return Err(anyhow!("Use explicit file operations for symlink targets"));
}
if metadata.is_dir() {
let mut entries = tokio::fs::read_dir(&path).await?;
while let Some(entry) = entries.next_entry().await? {
pending.push(entry.path());
}
} else {
self.edited_file_monitor
.source_inspection()
.require_existing_file(&path)
.await?;
}
}
Ok(())
}
async fn refresh_agent_write(&self, path: &std::path::Path) {
if let Err(error) = self.edited_file_monitor.record_agent_write(path).await {
tracing::warn!(path = %path.display(), %error, "Failed to refresh edited-file snapshot after filesystem operation");
}
}
pub async fn create_file(&self, args: Value) -> Result<Value> {
let input: CreateInput = deserialize_tool_args(&args, "create_file")?;
let CreateInput { path, content, encoding } = input;
let file_path = self.normalize_and_validate_user_path(&path).await?;
if self.should_exclude(&file_path).await {
return Err(anyhow!("Error: Path '{path}' is excluded by .vtcodegitignore"));
}
if tokio::fs::try_exists(&file_path).await? {
return Err(anyhow!(
"Error: File '{path}' already exists. Use write_file with mode='overwrite' to replace it."
));
}
if let Some(parent) = file_path.parent() {
ensure_dir_exists(parent).await?;
}
let mut payload = json!({
"path": path,
"content": content,
"mode": "fail_if_exists"
});
if let Some(encoding) = encoding {
payload["encoding"] = Value::String(encoding);
}
let mut result = self.write_file(payload).await?;
if let Some(map) = result.as_object_mut() {
map.insert("created".to_string(), Value::Bool(true));
}
Ok(result)
}
pub async fn delete_file(&self, args: Value) -> Result<Value> {
let input: DeleteInput = deserialize_tool_args(&args, "delete_file")?;
let DeleteInput { path, recursive, force } = input;
let trimmed = path.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == "./" || trimmed == "/" {
return Err(anyhow!("Error: Path '{path}' refers to the workspace root and cannot be deleted."));
}
let target_path = self.workspace_root.join(&path);
let exists = with_path_context(tokio::fs::try_exists(&target_path).await, "check if exists", &path)?;
if !exists {
if force {
return Ok(json!({
"success": true,
"deleted": false,
"skipped": true,
"reason": "not_found",
"path": path,
}));
}
return Err(anyhow!("Error: Path '{path}' does not exist. Provide force=true to ignore missing files."));
}
let canonical = with_path_context(canonicalize_async(&target_path).await, "resolve canonical path for", &path)?;
if !canonical.starts_with(self.canonical_workspace_root()) {
return Err(anyhow!("Error: Path '{path}' resolves outside the workspace and cannot be deleted."));
}
if canonical == *self.canonical_workspace_root() {
return Err(anyhow!("Error: Path '{path}' refers to the workspace root and cannot be deleted."));
}
if self.should_exclude(&canonical).await {
return Err(anyhow!("Error: Path '{path}' is excluded by .vtcodegitignore and cannot be deleted."));
}
let _lease = self.edited_file_monitor.acquire_mutation(&canonical).await;
if self.edited_file_monitor.source_inspection().mode() != vtcode_config::core::agent::InspectionMode::Off {
self.require_existing_tree(&canonical).await?;
}
let metadata = with_path_context(tokio::fs::metadata(&canonical).await, "read metadata for", &path)?;
let deleted_kind = if metadata.is_dir() {
if !recursive {
return Err(anyhow!("Error: '{path}' is a directory. Pass recursive=true to remove directories."));
}
with_path_context(tokio::fs::remove_dir_all(&canonical).await, "remove directory", &path)?;
"directory"
} else {
with_path_context(tokio::fs::remove_file(&canonical).await, "remove file", &path)?;
"file"
};
self.refresh_agent_write(&canonical).await;
Ok(json!({
"success": true,
"deleted": true,
"path": self.workspace_relative_display(&canonical),
"kind": deleted_kind,
}))
}
pub async fn move_file(&self, args: Value) -> Result<Value> {
let input: MoveInput = deserialize_tool_args(&args, "move_file")?;
let MoveInput { path, destination, force } = input;
let (from_path, to_path) = self.validate_move_copy_args(&path, &destination, "moved").await?;
let mut paths = vec![from_path.clone(), to_path.clone()];
paths.sort();
paths.dedup();
let mut leases = Vec::new();
for path in paths {
leases.push(self.edited_file_monitor.acquire_mutation(&path).await);
}
if tokio::fs::try_exists(&to_path).await?
&& self.edited_file_monitor.source_inspection().mode() != vtcode_config::core::agent::InspectionMode::Off
{
self.require_existing_tree(&to_path).await?;
}
if tokio::fs::try_exists(&to_path).await? && !force {
return Err(anyhow!("Destination path '{destination}' already exists. Use force=true to overwrite."));
}
tokio::fs::rename(&from_path, &to_path)
.await
.with_context(|| format!("Failed to move '{path}' to '{destination}'"))?;
if let Err(error) = self.edited_file_monitor.record_agent_move(&from_path, &to_path).await {
tracing::warn!(source = %from_path.display(), destination = %to_path.display(), %error, "Failed to refresh edited-file snapshots after move");
}
if let Ok(content) = tokio::fs::read_to_string(&to_path).await {
self.edited_file_monitor.source_inspection().record_transfer(
&from_path,
&to_path,
&content,
crate::tools::source_inspection::call_key(crate::config::constants::tools::MOVE_FILE, &args),
true,
);
}
info!(from = %path, to = %destination, "Moved successfully");
Ok(json!({
"success": true,
"from": self.workspace_relative_display(&from_path),
"to": self.workspace_relative_display(&to_path),
}))
}
pub async fn copy_file(&self, args: Value) -> Result<Value> {
let input: CopyInput = deserialize_tool_args(&args, "copy_file")?;
let CopyInput { path, destination, recursive } = input;
let (from_path, to_path) = self.validate_move_copy_args(&path, &destination, "copied").await?;
let mut paths = vec![from_path.clone(), to_path.clone()];
paths.sort();
paths.dedup();
let mut leases = Vec::new();
for path in paths {
leases.push(self.edited_file_monitor.acquire_mutation(&path).await);
}
if tokio::fs::try_exists(&to_path).await?
&& self.edited_file_monitor.source_inspection().mode() != vtcode_config::core::agent::InspectionMode::Off
{
self.require_existing_tree(&to_path).await?;
}
let metadata = tokio::fs::metadata(&from_path).await?;
if metadata.is_dir() {
if !recursive {
return Err(anyhow!("Path '{path}' is a directory. Use recursive=true to copy directories."));
}
use vtcode_commons::walk::build_walker_single_threaded;
for entry in build_walker_single_threaded(&from_path).build().filter_map(|e| e.ok()) {
let entry_path = entry.path();
let relative = entry_path.strip_prefix(&from_path).unwrap_or(entry_path);
let target = vtcode_commons::paths::ensure_path_within_workspace_resolved(
&to_path.join(relative),
self.canonical_workspace_root(),
)
.await?;
let source = vtcode_commons::paths::ensure_path_within_workspace_resolved(
entry_path,
self.canonical_workspace_root(),
)
.await?;
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
ensure_dir_exists(&target).await?;
} else {
with_file_context(tokio::fs::copy(&source, &target).await, "copy", entry_path)?;
self.refresh_agent_write(&target).await;
}
}
} else {
tokio::fs::copy(&from_path, &to_path)
.await
.with_context(|| format!("Failed to copy '{path}' to '{destination}'"))?;
self.refresh_agent_write(&to_path).await;
}
info!(from = %path, to = %destination, "Copied successfully");
if metadata.is_file()
&& let Ok(content) = tokio::fs::read_to_string(&to_path).await
{
self.edited_file_monitor.source_inspection().record_transfer(
&from_path,
&to_path,
&content,
crate::tools::source_inspection::call_key(crate::config::constants::tools::COPY_FILE, &args),
false,
);
}
Ok(json!({
"success": true,
"from": self.workspace_relative_display(&from_path),
"to": self.workspace_relative_display(&to_path),
}))
}
}