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