1use anyhow::{anyhow, Context as _, Result};
2use assistant_tool::{ActionLog, Tool};
3use gpui::{App, AppContext, Entity, Task};
4use language_model::LanguageModelRequestMessage;
5use project::Project;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::{collections::HashSet, path::PathBuf, sync::Arc};
9
10use crate::replace::replace_exact;
11
12#[derive(Debug, Serialize, Deserialize, JsonSchema)]
13pub struct FindReplaceFileToolInput {
14 /// The path of the file to modify.
15 ///
16 /// WARNING: When specifying which file path need changing, you MUST
17 /// start each path with one of the project's root directories.
18 ///
19 /// The following examples assume we have two root directories in the project:
20 /// - backend
21 /// - frontend
22 ///
23 /// <example>
24 /// `backend/src/main.rs`
25 ///
26 /// Notice how the file path starts with root-1. Without that, the path
27 /// would be ambiguous and the call would fail!
28 /// </example>
29 ///
30 /// <example>
31 /// `frontend/db.js`
32 /// </example>
33 pub path: PathBuf,
34
35 /// A user-friendly description of what's being replaced. This will be shown in the UI.
36 ///
37 /// <example>Fix API endpoint URLs</example>
38 /// <example>Update copyright year</example>
39 pub display_description: String,
40
41 /// The unique string to find in the file. This string cannot be empty;
42 /// if the string is empty, the tool call will fail. Remember, do not use this tool
43 /// to create new files from scratch, or to overwrite existing files! Use a different
44 /// approach if you want to do that.
45 ///
46 /// If this string appears more than once in the file, this tool call will fail,
47 /// so it is absolutely critical that you verify ahead of time that the string
48 /// is unique. You can search within the file to verify this.
49 ///
50 /// To make the string more likely to be unique, include a minimum of 3 lines of context
51 /// before the string you actually want to find, as well as a minimum of 3 lines of
52 /// context after the string you want to find. (These lines of context should appear
53 /// in the `replace` string as well.) If 3 lines of context is not enough to obtain
54 /// a string that appears only once in the file, then double the number of context lines
55 /// until the string becomes unique. (Start with 3 lines before and 3 lines after
56 /// though, because too much context is needlessly costly.)
57 ///
58 /// Do not alter the context lines of code in any way, and make sure to preserve all
59 /// whitespace and indentation for all lines of code. This string must be exactly as
60 /// it appears in the file, because this tool will do a literal find/replace, and if
61 /// even one character in this string is different in any way from how it appears
62 /// in the file, then the tool call will fail.
63 ///
64 /// <example>
65 /// If a file contains this code:
66 ///
67 /// ```rust
68 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
69 /// // Check if user exists first
70 /// let user = database.find_user(user_id)?;
71 ///
72 /// // This is the part we want to modify
73 /// if user.role == "admin" {
74 /// return Ok(true);
75 /// }
76 ///
77 /// // Check other permissions
78 /// check_custom_permissions(user_id)
79 /// }
80 /// ```
81 ///
82 /// Your find string should include at least 3 lines of context before and after the part
83 /// you want to change:
84 ///
85 /// ```
86 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
87 /// // Check if user exists first
88 /// let user = database.find_user(user_id)?;
89 ///
90 /// // This is the part we want to modify
91 /// if user.role == "admin" {
92 /// return Ok(true);
93 /// }
94 ///
95 /// // Check other permissions
96 /// check_custom_permissions(user_id)
97 /// }
98 /// ```
99 ///
100 /// And your replace string might look like:
101 ///
102 /// ```
103 /// fn check_user_permissions(user_id: &str) -> Result<bool> {
104 /// // Check if user exists first
105 /// let user = database.find_user(user_id)?;
106 ///
107 /// // This is the part we want to modify
108 /// if user.role == "admin" || user.role == "superuser" {
109 /// return Ok(true);
110 /// }
111 ///
112 /// // Check other permissions
113 /// check_custom_permissions(user_id)
114 /// }
115 /// ```
116 /// </example>
117 pub find: String,
118
119 /// The string to replace the one unique occurrence of the find string with.
120 pub replace: String,
121}
122
123pub struct FindReplaceFileTool;
124
125impl Tool for FindReplaceFileTool {
126 fn name(&self) -> String {
127 "find-replace-file".into()
128 }
129
130 fn needs_confirmation(&self) -> bool {
131 true
132 }
133
134 fn description(&self) -> String {
135 include_str!("find_replace_tool/description.md").to_string()
136 }
137
138 fn input_schema(&self) -> serde_json::Value {
139 let schema = schemars::schema_for!(FindReplaceFileToolInput);
140 serde_json::to_value(&schema).unwrap()
141 }
142
143 fn ui_text(&self, input: &serde_json::Value) -> String {
144 match serde_json::from_value::<FindReplaceFileToolInput>(input.clone()) {
145 Ok(input) => input.display_description,
146 Err(_) => "Edit file".to_string(),
147 }
148 }
149
150 fn run(
151 self: Arc<Self>,
152 input: serde_json::Value,
153 _messages: &[LanguageModelRequestMessage],
154 project: Entity<Project>,
155 action_log: Entity<ActionLog>,
156 cx: &mut App,
157 ) -> Task<Result<String>> {
158 let input = match serde_json::from_value::<FindReplaceFileToolInput>(input) {
159 Ok(input) => input,
160 Err(err) => return Task::ready(Err(anyhow!(err))),
161 };
162
163 cx.spawn(async move |cx| {
164 let project_path = project.read_with(cx, |project, cx| {
165 project
166 .find_project_path(&input.path, cx)
167 .context("Path not found in project")
168 })??;
169
170 let buffer = project
171 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
172 .await?;
173
174 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
175
176 if input.find.is_empty() {
177 return Err(anyhow!("`find` string cannot be empty. Use a different tool if you want to create a file."));
178 }
179
180 let result = cx
181 .background_spawn(async move {
182 replace_exact(&input.find, &input.replace, &snapshot).await
183 })
184 .await;
185
186 if let Some(diff) = result {
187 buffer.update(cx, |buffer, cx| {
188 let _ = buffer.apply_diff(diff, cx);
189 })?;
190
191 project.update(cx, |project, cx| {
192 project.save_buffer(buffer.clone(), cx)
193 })?.await?;
194
195 action_log.update(cx, |log, cx| {
196 let mut buffers = HashSet::default();
197 buffers.insert(buffer);
198 log.buffer_edited(buffers, cx);
199 })?;
200
201 Ok(format!("Edited {}", input.path.display()))
202 } else {
203 let err = buffer.read_with(cx, |buffer, _cx| {
204 let file_exists = buffer
205 .file()
206 .map_or(false, |file| file.disk_state().exists());
207
208 if !file_exists {
209 anyhow!("{} does not exist", input.path.display())
210 } else if buffer.is_empty() {
211 anyhow!(
212 "{} is empty, so the provided `find` string wasn't found.",
213 input.path.display()
214 )
215 } else {
216 anyhow!("Failed to match the provided `find` string")
217 }
218 })?;
219
220 Err(err)
221 }
222 })
223 }
224}