move_path_tool.rs

  1use crate::{
  2    AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
  3};
  4use agent_client_protocol::ToolKind;
  5use agent_settings::AgentSettings;
  6use anyhow::{Context as _, Result, anyhow};
  7use futures::FutureExt as _;
  8use gpui::{App, AppContext, Entity, SharedString, Task};
  9use project::Project;
 10use schemars::JsonSchema;
 11use serde::{Deserialize, Serialize};
 12use settings::Settings;
 13use std::{path::Path, sync::Arc};
 14use util::markdown::MarkdownInlineCode;
 15
 16/// Moves or rename a file or directory in the project, and returns confirmation that the move succeeded.
 17///
 18/// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move.
 19///
 20/// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all.
 21#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 22pub struct MovePathToolInput {
 23    /// The source path of the file or directory to move/rename.
 24    ///
 25    /// <example>
 26    /// If the project has the following files:
 27    ///
 28    /// - directory1/a/something.txt
 29    /// - directory2/a/things.txt
 30    /// - directory3/a/other.txt
 31    ///
 32    /// You can move the first file by providing a source_path of "directory1/a/something.txt"
 33    /// </example>
 34    pub source_path: String,
 35
 36    /// The destination path where the file or directory should be moved/renamed to.
 37    /// If the paths are the same except for the filename, then this will be a rename.
 38    ///
 39    /// <example>
 40    /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
 41    /// provide a destination_path of "directory2/b/renamed.txt"
 42    /// </example>
 43    pub destination_path: String,
 44}
 45
 46pub struct MovePathTool {
 47    project: Entity<Project>,
 48}
 49
 50impl MovePathTool {
 51    pub fn new(project: Entity<Project>) -> Self {
 52        Self { project }
 53    }
 54}
 55
 56impl AgentTool for MovePathTool {
 57    type Input = MovePathToolInput;
 58    type Output = String;
 59
 60    const NAME: &'static str = "move_path";
 61
 62    fn kind() -> ToolKind {
 63        ToolKind::Move
 64    }
 65
 66    fn initial_title(
 67        &self,
 68        input: Result<Self::Input, serde_json::Value>,
 69        _cx: &mut App,
 70    ) -> SharedString {
 71        if let Ok(input) = input {
 72            let src = MarkdownInlineCode(&input.source_path);
 73            let dest = MarkdownInlineCode(&input.destination_path);
 74            let src_path = Path::new(&input.source_path);
 75            let dest_path = Path::new(&input.destination_path);
 76
 77            match dest_path
 78                .file_name()
 79                .and_then(|os_str| os_str.to_os_string().into_string().ok())
 80            {
 81                Some(filename) if src_path.parent() == dest_path.parent() => {
 82                    let filename = MarkdownInlineCode(&filename);
 83                    format!("Rename {src} to {filename}").into()
 84                }
 85                _ => format!("Move {src} to {dest}").into(),
 86            }
 87        } else {
 88            "Move path".into()
 89        }
 90    }
 91
 92    fn run(
 93        self: Arc<Self>,
 94        input: Self::Input,
 95        event_stream: ToolCallEventStream,
 96        cx: &mut App,
 97    ) -> Task<Result<Self::Output>> {
 98        let settings = AgentSettings::get_global(cx);
 99
100        let source_decision =
101            decide_permission_from_settings(Self::NAME, &input.source_path, settings);
102        if let ToolPermissionDecision::Deny(reason) = source_decision {
103            return Task::ready(Err(anyhow!("{}", reason)));
104        }
105
106        let dest_decision =
107            decide_permission_from_settings(Self::NAME, &input.destination_path, settings);
108        if let ToolPermissionDecision::Deny(reason) = dest_decision {
109            return Task::ready(Err(anyhow!("{}", reason)));
110        }
111
112        let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
113            || matches!(dest_decision, ToolPermissionDecision::Confirm);
114
115        let authorize = if needs_confirmation {
116            let src = MarkdownInlineCode(&input.source_path);
117            let dest = MarkdownInlineCode(&input.destination_path);
118            let context = crate::ToolPermissionContext {
119                tool_name: Self::NAME.to_string(),
120                input_value: input.source_path.clone(),
121            };
122            Some(event_stream.authorize(format!("Move {src} to {dest}"), context, cx))
123        } else {
124            None
125        };
126
127        let rename_task = self.project.update(cx, |project, cx| {
128            match project
129                .find_project_path(&input.source_path, cx)
130                .and_then(|project_path| project.entry_for_path(&project_path, cx))
131            {
132                Some(entity) => match project.find_project_path(&input.destination_path, cx) {
133                    Some(project_path) => project.rename_entry(entity.id, project_path, cx),
134                    None => Task::ready(Err(anyhow!(
135                        "Destination path {} was outside the project.",
136                        input.destination_path
137                    ))),
138                },
139                None => Task::ready(Err(anyhow!(
140                    "Source path {} was not found in the project.",
141                    input.source_path
142                ))),
143            }
144        });
145
146        cx.background_spawn(async move {
147            if let Some(authorize) = authorize {
148                authorize.await?;
149            }
150
151            let result = futures::select! {
152                result = rename_task.fuse() => result,
153                _ = event_stream.cancelled_by_user().fuse() => {
154                    anyhow::bail!("Move cancelled by user");
155                }
156            };
157            let _ = result.with_context(|| {
158                format!("Moving {} to {}", input.source_path, input.destination_path)
159            })?;
160            Ok(format!(
161                "Moved {} to {}",
162                input.source_path, input.destination_path
163            ))
164        })
165    }
166}