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}