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 fn name() -> &'static str {
61 "move_path"
62 }
63
64 fn kind() -> ToolKind {
65 ToolKind::Move
66 }
67
68 fn initial_title(
69 &self,
70 input: Result<Self::Input, serde_json::Value>,
71 _cx: &mut App,
72 ) -> SharedString {
73 if let Ok(input) = input {
74 let src = MarkdownInlineCode(&input.source_path);
75 let dest = MarkdownInlineCode(&input.destination_path);
76 let src_path = Path::new(&input.source_path);
77 let dest_path = Path::new(&input.destination_path);
78
79 match dest_path
80 .file_name()
81 .and_then(|os_str| os_str.to_os_string().into_string().ok())
82 {
83 Some(filename) if src_path.parent() == dest_path.parent() => {
84 let filename = MarkdownInlineCode(&filename);
85 format!("Rename {src} to {filename}").into()
86 }
87 _ => format!("Move {src} to {dest}").into(),
88 }
89 } else {
90 "Move path".into()
91 }
92 }
93
94 fn run(
95 self: Arc<Self>,
96 input: Self::Input,
97 event_stream: ToolCallEventStream,
98 cx: &mut App,
99 ) -> Task<Result<Self::Output>> {
100 let settings = AgentSettings::get_global(cx);
101
102 let source_decision =
103 decide_permission_from_settings(Self::name(), &input.source_path, settings);
104 if let ToolPermissionDecision::Deny(reason) = source_decision {
105 return Task::ready(Err(anyhow!("{}", reason)));
106 }
107
108 let dest_decision =
109 decide_permission_from_settings(Self::name(), &input.destination_path, settings);
110 if let ToolPermissionDecision::Deny(reason) = dest_decision {
111 return Task::ready(Err(anyhow!("{}", reason)));
112 }
113
114 let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
115 || matches!(dest_decision, ToolPermissionDecision::Confirm);
116
117 let authorize = if needs_confirmation {
118 let src = MarkdownInlineCode(&input.source_path);
119 let dest = MarkdownInlineCode(&input.destination_path);
120 Some(event_stream.authorize(format!("Move {src} to {dest}"), cx))
121 } else {
122 None
123 };
124
125 let rename_task = self.project.update(cx, |project, cx| {
126 match project
127 .find_project_path(&input.source_path, cx)
128 .and_then(|project_path| project.entry_for_path(&project_path, cx))
129 {
130 Some(entity) => match project.find_project_path(&input.destination_path, cx) {
131 Some(project_path) => project.rename_entry(entity.id, project_path, cx),
132 None => Task::ready(Err(anyhow!(
133 "Destination path {} was outside the project.",
134 input.destination_path
135 ))),
136 },
137 None => Task::ready(Err(anyhow!(
138 "Source path {} was not found in the project.",
139 input.source_path
140 ))),
141 }
142 });
143
144 cx.background_spawn(async move {
145 if let Some(authorize) = authorize {
146 authorize.await?;
147 }
148
149 let result = futures::select! {
150 result = rename_task.fuse() => result,
151 _ = event_stream.cancelled_by_user().fuse() => {
152 anyhow::bail!("Move cancelled by user");
153 }
154 };
155 let _ = result.with_context(|| {
156 format!("Moving {} to {}", input.source_path, input.destination_path)
157 })?;
158 Ok(format!(
159 "Moved {} to {}",
160 input.source_path, input.destination_path
161 ))
162 })
163 }
164}