find_replace_file_tool.rs

  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}