vtcode-core 0.177.1

Core library for VT Code - a Rust-based terminal coding agent
Documentation
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 {
    /// Shared validation for move and copy operations.
    ///
    /// Validates source and destination paths, checks exclusion rules,
    /// verifies source existence, and ensures the destination parent directory exists.
    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");
        }
    }

    /// Execute basic directory listing
    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)
    }

    /// Delete a file or directory (with recursive flag).
    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();
        // An empty/"." path joins to the workspace root itself, and the
        // canonical containment check below passes for the root — a recursive
        // delete would erase the entire workspace. Reject it explicitly.
        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,
        }))
    }

    /// Move a file or directory.
    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),
        }))
    }

    /// Copy a file or directory.
    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."));
            }
            // Simple recursive copy
            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),
        }))
    }
}