1use crate::{replace::replace_with_flexible_indent, schema::json_schema_for};
2use anyhow::{Context as _, Result, anyhow};
3use assistant_tool::{ActionLog, Tool, ToolResult};
4use gpui::{App, AppContext, AsyncApp, Entity, Task};
5use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
6use project::Project;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::{path::PathBuf, sync::Arc};
10use ui::IconName;
11
12use crate::replace::replace_exact;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct EditFileToolInput {
16 /// The full path of the file to modify in the project.
17 ///
18 /// WARNING: When specifying which file path need changing, you MUST
19 /// start each path with one of the project's root directories.
20 ///
21 /// The following examples assume we have two root directories in the project:
22 /// - backend
23 /// - frontend
24 ///
25 /// <example>
26 /// `backend/src/main.rs`
27 ///
28 /// Notice how the file path starts with root-1. Without that, the path
29 /// would be ambiguous and the call would fail!
30 /// </example>
31 ///
32 /// <example>
33 /// `frontend/db.js`
34 /// </example>
35 pub path: PathBuf,
36
37 /// A user-friendly markdown description of what's being replaced. This will be shown in the UI.
38 ///
39 /// <example>Fix API endpoint URLs</example>
40 /// <example>Update copyright year in `page_footer`</example>
41 pub display_description: String,
42
43 /// The text to replace.
44 pub old_string: String,
45
46 /// The text to replace it with.
47 pub new_string: String,
48}
49
50pub struct EditFileTool;
51
52impl Tool for EditFileTool {
53 fn name(&self) -> String {
54 "edit_file".into()
55 }
56
57 fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
58 false
59 }
60
61 fn description(&self) -> String {
62 include_str!("edit_file_tool/description.md").to_string()
63 }
64
65 fn icon(&self) -> IconName {
66 IconName::Pencil
67 }
68
69 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
70 json_schema_for::<EditFileToolInput>(format)
71 }
72
73 fn ui_text(&self, input: &serde_json::Value) -> String {
74 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
75 Ok(input) => input.display_description,
76 Err(_) => "Edit file".to_string(),
77 }
78 }
79
80 fn run(
81 self: Arc<Self>,
82 input: serde_json::Value,
83 _messages: &[LanguageModelRequestMessage],
84 project: Entity<Project>,
85 action_log: Entity<ActionLog>,
86 cx: &mut App,
87 ) -> ToolResult {
88 let input = match serde_json::from_value::<EditFileToolInput>(input) {
89 Ok(input) => input,
90 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
91 };
92
93 cx.spawn(async move |cx: &mut AsyncApp| {
94 let project_path = project.read_with(cx, |project, cx| {
95 project
96 .find_project_path(&input.path, cx)
97 .context("Path not found in project")
98 })??;
99
100 let buffer = project
101 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
102 .await?;
103
104 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
105
106 if input.old_string.is_empty() {
107 return Err(anyhow!("`old_string` cannot be empty. Use a different tool if you want to create a file."));
108 }
109
110 if input.old_string == input.new_string {
111 return Err(anyhow!("The `old_string` and `new_string` are identical, so no changes would be made."));
112 }
113
114 let result = cx
115 .background_spawn(async move {
116 // Try to match exactly
117 let diff = replace_exact(&input.old_string, &input.new_string, &snapshot)
118 .await
119 // If that fails, try being flexible about indentation
120 .or_else(|| replace_with_flexible_indent(&input.old_string, &input.new_string, &snapshot))?;
121
122 if diff.edits.is_empty() {
123 return None;
124 }
125
126 let old_text = snapshot.text();
127
128 Some((old_text, diff))
129 })
130 .await;
131
132 let Some((old_text, diff)) = result else {
133 let err = buffer.read_with(cx, |buffer, _cx| {
134 let file_exists = buffer
135 .file()
136 .map_or(false, |file| file.disk_state().exists());
137
138 if !file_exists {
139 anyhow!("{} does not exist", input.path.display())
140 } else if buffer.is_empty() {
141 anyhow!(
142 "{} is empty, so the provided `old_string` wasn't found.",
143 input.path.display()
144 )
145 } else {
146 anyhow!("Failed to match the provided `old_string`")
147 }
148 })?;
149
150 return Err(err)
151 };
152
153 let snapshot = cx.update(|cx| {
154 action_log.update(cx, |log, cx| {
155 log.buffer_read(buffer.clone(), cx)
156 });
157 let snapshot = buffer.update(cx, |buffer, cx| {
158 buffer.finalize_last_transaction();
159 buffer.apply_diff(diff, cx);
160 buffer.finalize_last_transaction();
161 buffer.snapshot()
162 });
163 action_log.update(cx, |log, cx| {
164 log.buffer_edited(buffer.clone(), cx)
165 });
166 snapshot
167 })?;
168
169 project.update( cx, |project, cx| {
170 project.save_buffer(buffer, cx)
171 })?.await?;
172
173 let diff_str = cx.background_spawn(async move {
174 let new_text = snapshot.text();
175 language::unified_diff(&old_text, &new_text)
176 }).await;
177
178
179 Ok(format!("Edited {}:\n\n```diff\n{}\n```", input.path.display(), diff_str))
180
181 }).into()
182 }
183}