1use crate::{
2 AgentTool, Templates, Thread, ToolCallEventStream, ToolPermissionDecision,
3 decide_permission_from_settings,
4 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
5};
6use acp_thread::Diff;
7use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};
8use anyhow::{Context as _, Result, anyhow};
9use cloud_llm_client::CompletionIntent;
10use collections::HashSet;
11use futures::{FutureExt as _, StreamExt as _};
12use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
13use indoc::formatdoc;
14use language::language_settings::{self, FormatOnSave};
15use language::{LanguageRegistry, ToPoint};
16use language_model::LanguageModelToolResultContent;
17use paths;
18use project::lsp_store::{FormatTrigger, LspFormatTarget};
19use project::{Project, ProjectPath};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use settings::Settings;
23use std::ffi::OsStr;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use ui::SharedString;
27use util::ResultExt;
28use util::rel_path::RelPath;
29
30const DEFAULT_UI_TEXT: &str = "Editing file";
31
32/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
33///
34/// Before using this tool:
35///
36/// 1. Use the `read_file` tool to understand the file's contents and context
37///
38/// 2. Verify the directory path is correct (only applicable when creating new files):
39/// - Use the `list_directory` tool to verify the parent directory exists and is the correct location
40#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
41pub struct EditFileToolInput {
42 /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.
43 ///
44 /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.
45 ///
46 /// NEVER mention the file path in this description.
47 ///
48 /// <example>Fix API endpoint URLs</example>
49 /// <example>Update copyright year in `page_footer`</example>
50 ///
51 /// Make sure to include this field before all the others in the input object so that we can display it immediately.
52 pub display_description: String,
53
54 /// The full path of the file to create or modify in the project.
55 ///
56 /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.
57 ///
58 /// The following examples assume we have two root directories in the project:
59 /// - /a/b/backend
60 /// - /c/d/frontend
61 ///
62 /// <example>
63 /// `backend/src/main.rs`
64 ///
65 /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
66 /// </example>
67 ///
68 /// <example>
69 /// `frontend/db.js`
70 /// </example>
71 pub path: PathBuf,
72 /// The mode of operation on the file. Possible values:
73 /// - 'edit': Make granular edits to an existing file.
74 /// - 'create': Create a new file if it doesn't exist.
75 /// - 'overwrite': Replace the entire contents of an existing file.
76 ///
77 /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.
78 pub mode: EditFileMode,
79}
80
81#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
82struct EditFileToolPartialInput {
83 #[serde(default)]
84 path: String,
85 #[serde(default)]
86 display_description: String,
87}
88
89#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
90#[serde(rename_all = "lowercase")]
91#[schemars(inline)]
92pub enum EditFileMode {
93 Edit,
94 Create,
95 Overwrite,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
99pub struct EditFileToolOutput {
100 #[serde(alias = "original_path")]
101 input_path: PathBuf,
102 new_text: String,
103 old_text: Arc<String>,
104 #[serde(default)]
105 diff: String,
106 #[serde(alias = "raw_output")]
107 edit_agent_output: EditAgentOutput,
108}
109
110impl From<EditFileToolOutput> for LanguageModelToolResultContent {
111 fn from(output: EditFileToolOutput) -> Self {
112 if output.diff.is_empty() {
113 "No edits were made.".into()
114 } else {
115 format!(
116 "Edited {}:\n\n```diff\n{}\n```",
117 output.input_path.display(),
118 output.diff
119 )
120 .into()
121 }
122 }
123}
124
125pub struct EditFileTool {
126 thread: WeakEntity<Thread>,
127 language_registry: Arc<LanguageRegistry>,
128 project: Entity<Project>,
129 templates: Arc<Templates>,
130}
131
132impl EditFileTool {
133 pub fn new(
134 project: Entity<Project>,
135 thread: WeakEntity<Thread>,
136 language_registry: Arc<LanguageRegistry>,
137 templates: Arc<Templates>,
138 ) -> Self {
139 Self {
140 project,
141 thread,
142 language_registry,
143 templates,
144 }
145 }
146
147 fn authorize(
148 &self,
149 input: &EditFileToolInput,
150 event_stream: &ToolCallEventStream,
151 cx: &mut App,
152 ) -> Task<Result<()>> {
153 let path_str = input.path.to_string_lossy();
154 let settings = agent_settings::AgentSettings::get_global(cx);
155 let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
156
157 match decision {
158 ToolPermissionDecision::Allow => return Task::ready(Ok(())),
159 ToolPermissionDecision::Deny(reason) => {
160 return Task::ready(Err(anyhow!("{}", reason)));
161 }
162 ToolPermissionDecision::Confirm => {}
163 }
164
165 // If any path component matches the local settings folder, then this could affect
166 // the editor in ways beyond the project source, so prompt.
167 let local_settings_folder = paths::local_settings_folder_name();
168 let path = Path::new(&input.path);
169 if path.components().any(|component| {
170 component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
171 }) {
172 return event_stream.authorize(
173 format!("{} (local settings)", input.display_description),
174 cx,
175 );
176 }
177
178 // It's also possible that the global config dir is configured to be inside the project,
179 // so check for that edge case too.
180 // TODO this is broken when remoting
181 if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
182 && canonical_path.starts_with(paths::config_dir())
183 {
184 return event_stream.authorize(
185 format!("{} (global settings)", input.display_description),
186 cx,
187 );
188 }
189
190 // Check if path is inside the global config directory
191 // First check if it's already inside project - if not, try to canonicalize
192 let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {
193 thread.project().read(cx).find_project_path(&input.path, cx)
194 }) else {
195 return Task::ready(Err(anyhow!("thread was dropped")));
196 };
197
198 // If the path is inside the project, and it's not one of the above edge cases,
199 // then no confirmation is necessary. Otherwise, confirmation is necessary.
200 if project_path.is_some() {
201 Task::ready(Ok(()))
202 } else {
203 event_stream.authorize(&input.display_description, cx)
204 }
205 }
206}
207
208impl AgentTool for EditFileTool {
209 type Input = EditFileToolInput;
210 type Output = EditFileToolOutput;
211
212 fn name() -> &'static str {
213 "edit_file"
214 }
215
216 fn kind() -> acp::ToolKind {
217 acp::ToolKind::Edit
218 }
219
220 fn initial_title(
221 &self,
222 input: Result<Self::Input, serde_json::Value>,
223 cx: &mut App,
224 ) -> SharedString {
225 match input {
226 Ok(input) => self
227 .project
228 .read(cx)
229 .find_project_path(&input.path, cx)
230 .and_then(|project_path| {
231 self.project
232 .read(cx)
233 .short_full_path_for_project_path(&project_path, cx)
234 })
235 .unwrap_or(input.path.to_string_lossy().into_owned())
236 .into(),
237 Err(raw_input) => {
238 if let Some(input) =
239 serde_json::from_value::<EditFileToolPartialInput>(raw_input).ok()
240 {
241 let path = input.path.trim();
242 if !path.is_empty() {
243 return self
244 .project
245 .read(cx)
246 .find_project_path(&input.path, cx)
247 .and_then(|project_path| {
248 self.project
249 .read(cx)
250 .short_full_path_for_project_path(&project_path, cx)
251 })
252 .unwrap_or(input.path)
253 .into();
254 }
255
256 let description = input.display_description.trim();
257 if !description.is_empty() {
258 return description.to_string().into();
259 }
260 }
261
262 DEFAULT_UI_TEXT.into()
263 }
264 }
265 }
266
267 fn run(
268 self: Arc<Self>,
269 input: Self::Input,
270 event_stream: ToolCallEventStream,
271 cx: &mut App,
272 ) -> Task<Result<Self::Output>> {
273 let Ok(project) = self
274 .thread
275 .read_with(cx, |thread, _cx| thread.project().clone())
276 else {
277 return Task::ready(Err(anyhow!("thread was dropped")));
278 };
279 let project_path = match resolve_path(&input, project.clone(), cx) {
280 Ok(path) => path,
281 Err(err) => return Task::ready(Err(anyhow!(err))),
282 };
283 let abs_path = project.read(cx).absolute_path(&project_path, cx);
284 if let Some(abs_path) = abs_path.clone() {
285 event_stream.update_fields(
286 ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),
287 );
288 }
289
290 let authorize = self.authorize(&input, &event_stream, cx);
291 cx.spawn(async move |cx: &mut AsyncApp| {
292 authorize.await?;
293
294 let (request, model, action_log) = self.thread.update(cx, |thread, cx| {
295 let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);
296 (request, thread.model().cloned(), thread.action_log().clone())
297 })?;
298 let request = request?;
299 let model = model.context("No language model configured")?;
300
301 let edit_format = EditFormat::from_model(model.clone())?;
302 let edit_agent = EditAgent::new(
303 model,
304 project.clone(),
305 action_log.clone(),
306 self.templates.clone(),
307 edit_format,
308 );
309
310 let buffer = project
311 .update(cx, |project, cx| {
312 project.open_buffer(project_path.clone(), cx)
313 })
314 .await?;
315
316 // Check if the file has been modified since the agent last read it
317 if let Some(abs_path) = abs_path.as_ref() {
318 let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.update(cx, |thread, cx| {
319 let last_read = thread.file_read_times.get(abs_path).copied();
320 let current = buffer.read(cx).file().and_then(|file| file.disk_state().mtime());
321 let dirty = buffer.read(cx).is_dirty();
322 let has_save = thread.has_tool("save_file");
323 let has_restore = thread.has_tool("restore_file_from_disk");
324 (last_read, current, dirty, has_save, has_restore)
325 })?;
326
327 // Check for unsaved changes first - these indicate modifications we don't know about
328 if is_dirty {
329 let message = match (has_save_tool, has_restore_tool) {
330 (true, true) => {
331 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
332 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
333 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
334 }
335 (true, false) => {
336 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
337 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \
338 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."
339 }
340 (false, true) => {
341 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \
342 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \
343 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."
344 }
345 (false, false) => {
346 "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \
347 then ask them to save or revert the file manually and inform you when it's ok to proceed."
348 }
349 };
350 anyhow::bail!("{}", message);
351 }
352
353 // Check if the file was modified on disk since we last read it
354 if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {
355 // MTime can be unreliable for comparisons, so our newtype intentionally
356 // doesn't support comparing them. If the mtime at all different
357 // (which could be because of a modification or because e.g. system clock changed),
358 // we pessimistically assume it was modified.
359 if current != last_read {
360 anyhow::bail!(
361 "The file {} has been modified since you last read it. \
362 Please read the file again to get the current state before editing it.",
363 input.path.display()
364 );
365 }
366 }
367 }
368
369 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
370 event_stream.update_diff(diff.clone());
371 let _finalize_diff = util::defer({
372 let diff = diff.downgrade();
373 let mut cx = cx.clone();
374 move || {
375 diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
376 }
377 });
378
379 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
380 let old_text = cx
381 .background_spawn({
382 let old_snapshot = old_snapshot.clone();
383 async move { Arc::new(old_snapshot.text()) }
384 })
385 .await;
386
387
388 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
389 edit_agent.edit(
390 buffer.clone(),
391 input.display_description.clone(),
392 &request,
393 cx,
394 )
395 } else {
396 edit_agent.overwrite(
397 buffer.clone(),
398 input.display_description.clone(),
399 &request,
400 cx,
401 )
402 };
403
404 let mut hallucinated_old_text = false;
405 let mut ambiguous_ranges = Vec::new();
406 let mut emitted_location = false;
407 loop {
408 let event = futures::select! {
409 event = events.next().fuse() => match event {
410 Some(event) => event,
411 None => break,
412 },
413 _ = event_stream.cancelled_by_user().fuse() => {
414 anyhow::bail!("Edit cancelled by user");
415 }
416 };
417 match event {
418 EditAgentOutputEvent::Edited(range) => {
419 if !emitted_location {
420 let line = Some(buffer.update(cx, |buffer, _cx| {
421 range.start.to_point(&buffer.snapshot()).row
422 }));
423 if let Some(abs_path) = abs_path.clone() {
424 event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));
425 }
426 emitted_location = true;
427 }
428 },
429 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
430 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
431 EditAgentOutputEvent::ResolvingEditRange(range) => {
432 diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx));
433 // if !emitted_location {
434 // let line = buffer.update(cx, |buffer, _cx| {
435 // range.start.to_point(&buffer.snapshot()).row
436 // }).ok();
437 // if let Some(abs_path) = abs_path.clone() {
438 // event_stream.update_fields(ToolCallUpdateFields {
439 // locations: Some(vec![ToolCallLocation { path: abs_path, line }]),
440 // ..Default::default()
441 // });
442 // }
443 // }
444 }
445 }
446 }
447
448 // If format_on_save is enabled, format the buffer
449 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
450 let settings = language_settings::language_settings(
451 buffer.language().map(|l| l.name()),
452 buffer.file(),
453 cx,
454 );
455 settings.format_on_save != FormatOnSave::Off
456 });
457
458 let edit_agent_output = output.await?;
459
460 if format_on_save_enabled {
461 action_log.update(cx, |log, cx| {
462 log.buffer_edited(buffer.clone(), cx);
463 });
464
465 let format_task = project.update(cx, |project, cx| {
466 project.format(
467 HashSet::from_iter([buffer.clone()]),
468 LspFormatTarget::Buffers,
469 false, // Don't push to history since the tool did it.
470 FormatTrigger::Save,
471 cx,
472 )
473 });
474 format_task.await.log_err();
475 }
476
477 project
478 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
479 .await?;
480
481 action_log.update(cx, |log, cx| {
482 log.buffer_edited(buffer.clone(), cx);
483 });
484
485 // Update the recorded read time after a successful edit so consecutive edits work
486 if let Some(abs_path) = abs_path.as_ref() {
487 if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {
488 buffer.file().and_then(|file| file.disk_state().mtime())
489 }) {
490 self.thread.update(cx, |thread, _| {
491 thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);
492 })?;
493 }
494 }
495
496 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
497 let (new_text, unified_diff) = cx
498 .background_spawn({
499 let new_snapshot = new_snapshot.clone();
500 let old_text = old_text.clone();
501 async move {
502 let new_text = new_snapshot.text();
503 let diff = language::unified_diff(&old_text, &new_text);
504 (new_text, diff)
505 }
506 })
507 .await;
508
509 let input_path = input.path.display();
510 if unified_diff.is_empty() {
511 anyhow::ensure!(
512 !hallucinated_old_text,
513 formatdoc! {"
514 Some edits were produced but none of them could be applied.
515 Read the relevant sections of {input_path} again so that
516 I can perform the requested edits.
517 "}
518 );
519 anyhow::ensure!(
520 ambiguous_ranges.is_empty(),
521 {
522 let line_numbers = ambiguous_ranges
523 .iter()
524 .map(|range| range.start.to_string())
525 .collect::<Vec<_>>()
526 .join(", ");
527 formatdoc! {"
528 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
529 relevant sections of {input_path} again and extend <old_text> so
530 that I can perform the requested edits.
531 "}
532 }
533 );
534 }
535
536 Ok(EditFileToolOutput {
537 input_path: input.path,
538 new_text,
539 old_text,
540 diff: unified_diff,
541 edit_agent_output,
542 })
543 })
544 }
545
546 fn replay(
547 &self,
548 _input: Self::Input,
549 output: Self::Output,
550 event_stream: ToolCallEventStream,
551 cx: &mut App,
552 ) -> Result<()> {
553 event_stream.update_diff(cx.new(|cx| {
554 Diff::finalized(
555 output.input_path.to_string_lossy().into_owned(),
556 Some(output.old_text.to_string()),
557 output.new_text,
558 self.language_registry.clone(),
559 cx,
560 )
561 }));
562 Ok(())
563 }
564}
565
566/// Validate that the file path is valid, meaning:
567///
568/// - For `edit` and `overwrite`, the path must point to an existing file.
569/// - For `create`, the file must not already exist, but it's parent dir must exist.
570fn resolve_path(
571 input: &EditFileToolInput,
572 project: Entity<Project>,
573 cx: &mut App,
574) -> Result<ProjectPath> {
575 let project = project.read(cx);
576
577 match input.mode {
578 EditFileMode::Edit | EditFileMode::Overwrite => {
579 let path = project
580 .find_project_path(&input.path, cx)
581 .context("Can't edit file: path not found")?;
582
583 let entry = project
584 .entry_for_path(&path, cx)
585 .context("Can't edit file: path not found")?;
586
587 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
588 Ok(path)
589 }
590
591 EditFileMode::Create => {
592 if let Some(path) = project.find_project_path(&input.path, cx) {
593 anyhow::ensure!(
594 project.entry_for_path(&path, cx).is_none(),
595 "Can't create file: file already exists"
596 );
597 }
598
599 let parent_path = input
600 .path
601 .parent()
602 .context("Can't create file: incorrect path")?;
603
604 let parent_project_path = project.find_project_path(&parent_path, cx);
605
606 let parent_entry = parent_project_path
607 .as_ref()
608 .and_then(|path| project.entry_for_path(path, cx))
609 .context("Can't create file: parent directory doesn't exist")?;
610
611 anyhow::ensure!(
612 parent_entry.is_dir(),
613 "Can't create file: parent is not a directory"
614 );
615
616 let file_name = input
617 .path
618 .file_name()
619 .and_then(|file_name| file_name.to_str())
620 .and_then(|file_name| RelPath::unix(file_name).ok())
621 .context("Can't create file: invalid filename")?;
622
623 let new_file_path = parent_project_path.map(|parent| ProjectPath {
624 path: parent.path.join(file_name),
625 ..parent
626 });
627
628 new_file_path.context("Can't create file")
629 }
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::{ContextServerRegistry, Templates};
637 use fs::Fs;
638 use gpui::{TestAppContext, UpdateGlobal};
639 use language_model::fake_provider::FakeLanguageModel;
640 use prompt_store::ProjectContext;
641 use serde_json::json;
642 use settings::SettingsStore;
643 use util::{path, rel_path::rel_path};
644
645 #[gpui::test]
646 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
647 init_test(cx);
648
649 let fs = project::FakeFs::new(cx.executor());
650 fs.insert_tree("/root", json!({})).await;
651 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
652 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
653 let context_server_registry =
654 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
655 let model = Arc::new(FakeLanguageModel::default());
656 let thread = cx.new(|cx| {
657 Thread::new(
658 project.clone(),
659 cx.new(|_cx| ProjectContext::default()),
660 context_server_registry,
661 Templates::new(),
662 Some(model),
663 cx,
664 )
665 });
666 let result = cx
667 .update(|cx| {
668 let input = EditFileToolInput {
669 display_description: "Some edit".into(),
670 path: "root/nonexistent_file.txt".into(),
671 mode: EditFileMode::Edit,
672 };
673 Arc::new(EditFileTool::new(
674 project,
675 thread.downgrade(),
676 language_registry,
677 Templates::new(),
678 ))
679 .run(input, ToolCallEventStream::test().0, cx)
680 })
681 .await;
682 assert_eq!(
683 result.unwrap_err().to_string(),
684 "Can't edit file: path not found"
685 );
686 }
687
688 #[gpui::test]
689 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
690 let mode = &EditFileMode::Create;
691
692 let result = test_resolve_path(mode, "root/new.txt", cx);
693 assert_resolved_path_eq(result.await, rel_path("new.txt"));
694
695 let result = test_resolve_path(mode, "new.txt", cx);
696 assert_resolved_path_eq(result.await, rel_path("new.txt"));
697
698 let result = test_resolve_path(mode, "dir/new.txt", cx);
699 assert_resolved_path_eq(result.await, rel_path("dir/new.txt"));
700
701 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
702 assert_eq!(
703 result.await.unwrap_err().to_string(),
704 "Can't create file: file already exists"
705 );
706
707 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
708 assert_eq!(
709 result.await.unwrap_err().to_string(),
710 "Can't create file: parent directory doesn't exist"
711 );
712 }
713
714 #[gpui::test]
715 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
716 let mode = &EditFileMode::Edit;
717
718 let path_with_root = "root/dir/subdir/existing.txt";
719 let path_without_root = "dir/subdir/existing.txt";
720 let result = test_resolve_path(mode, path_with_root, cx);
721 assert_resolved_path_eq(result.await, rel_path(path_without_root));
722
723 let result = test_resolve_path(mode, path_without_root, cx);
724 assert_resolved_path_eq(result.await, rel_path(path_without_root));
725
726 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
727 assert_eq!(
728 result.await.unwrap_err().to_string(),
729 "Can't edit file: path not found"
730 );
731
732 let result = test_resolve_path(mode, "root/dir", cx);
733 assert_eq!(
734 result.await.unwrap_err().to_string(),
735 "Can't edit file: path is a directory"
736 );
737 }
738
739 async fn test_resolve_path(
740 mode: &EditFileMode,
741 path: &str,
742 cx: &mut TestAppContext,
743 ) -> anyhow::Result<ProjectPath> {
744 init_test(cx);
745
746 let fs = project::FakeFs::new(cx.executor());
747 fs.insert_tree(
748 "/root",
749 json!({
750 "dir": {
751 "subdir": {
752 "existing.txt": "hello"
753 }
754 }
755 }),
756 )
757 .await;
758 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
759
760 let input = EditFileToolInput {
761 display_description: "Some edit".into(),
762 path: path.into(),
763 mode: mode.clone(),
764 };
765
766 cx.update(|cx| resolve_path(&input, project, cx))
767 }
768
769 #[track_caller]
770 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &RelPath) {
771 let actual = path.expect("Should return valid path").path;
772 assert_eq!(actual.as_ref(), expected);
773 }
774
775 #[gpui::test]
776 async fn test_format_on_save(cx: &mut TestAppContext) {
777 init_test(cx);
778
779 let fs = project::FakeFs::new(cx.executor());
780 fs.insert_tree("/root", json!({"src": {}})).await;
781
782 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
783
784 // Set up a Rust language with LSP formatting support
785 let rust_language = Arc::new(language::Language::new(
786 language::LanguageConfig {
787 name: "Rust".into(),
788 matcher: language::LanguageMatcher {
789 path_suffixes: vec!["rs".to_string()],
790 ..Default::default()
791 },
792 ..Default::default()
793 },
794 None,
795 ));
796
797 // Register the language and fake LSP
798 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
799 language_registry.add(rust_language);
800
801 let mut fake_language_servers = language_registry.register_fake_lsp(
802 "Rust",
803 language::FakeLspAdapter {
804 capabilities: lsp::ServerCapabilities {
805 document_formatting_provider: Some(lsp::OneOf::Left(true)),
806 ..Default::default()
807 },
808 ..Default::default()
809 },
810 );
811
812 // Create the file
813 fs.save(
814 path!("/root/src/main.rs").as_ref(),
815 &"initial content".into(),
816 language::LineEnding::Unix,
817 )
818 .await
819 .unwrap();
820
821 // Open the buffer to trigger LSP initialization
822 let buffer = project
823 .update(cx, |project, cx| {
824 project.open_local_buffer(path!("/root/src/main.rs"), cx)
825 })
826 .await
827 .unwrap();
828
829 // Register the buffer with language servers
830 let _handle = project.update(cx, |project, cx| {
831 project.register_buffer_with_language_servers(&buffer, cx)
832 });
833
834 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
835 const FORMATTED_CONTENT: &str =
836 "This file was formatted by the fake formatter in the test.\n";
837
838 // Get the fake language server and set up formatting handler
839 let fake_language_server = fake_language_servers.next().await.unwrap();
840 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
841 |_, _| async move {
842 Ok(Some(vec![lsp::TextEdit {
843 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
844 new_text: FORMATTED_CONTENT.to_string(),
845 }]))
846 }
847 });
848
849 let context_server_registry =
850 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
851 let model = Arc::new(FakeLanguageModel::default());
852 let thread = cx.new(|cx| {
853 Thread::new(
854 project.clone(),
855 cx.new(|_cx| ProjectContext::default()),
856 context_server_registry,
857 Templates::new(),
858 Some(model.clone()),
859 cx,
860 )
861 });
862
863 // First, test with format_on_save enabled
864 cx.update(|cx| {
865 SettingsStore::update_global(cx, |store, cx| {
866 store.update_user_settings(cx, |settings| {
867 settings.project.all_languages.defaults.format_on_save = Some(FormatOnSave::On);
868 settings.project.all_languages.defaults.formatter =
869 Some(language::language_settings::FormatterList::default());
870 });
871 });
872 });
873
874 // Have the model stream unformatted content
875 let edit_result = {
876 let edit_task = cx.update(|cx| {
877 let input = EditFileToolInput {
878 display_description: "Create main function".into(),
879 path: "root/src/main.rs".into(),
880 mode: EditFileMode::Overwrite,
881 };
882 Arc::new(EditFileTool::new(
883 project.clone(),
884 thread.downgrade(),
885 language_registry.clone(),
886 Templates::new(),
887 ))
888 .run(input, ToolCallEventStream::test().0, cx)
889 });
890
891 // Stream the unformatted content
892 cx.executor().run_until_parked();
893 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
894 model.end_last_completion_stream();
895
896 edit_task.await
897 };
898 assert!(edit_result.is_ok());
899
900 // Wait for any async operations (e.g. formatting) to complete
901 cx.executor().run_until_parked();
902
903 // Read the file to verify it was formatted automatically
904 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
905 assert_eq!(
906 // Ignore carriage returns on Windows
907 new_content.replace("\r\n", "\n"),
908 FORMATTED_CONTENT,
909 "Code should be formatted when format_on_save is enabled"
910 );
911
912 let stale_buffer_count = thread
913 .read_with(cx, |thread, _cx| thread.action_log.clone())
914 .read_with(cx, |log, cx| log.stale_buffers(cx).count());
915
916 assert_eq!(
917 stale_buffer_count, 0,
918 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
919 This causes the agent to think the file was modified externally when it was just formatted.",
920 stale_buffer_count
921 );
922
923 // Next, test with format_on_save disabled
924 cx.update(|cx| {
925 SettingsStore::update_global(cx, |store, cx| {
926 store.update_user_settings(cx, |settings| {
927 settings.project.all_languages.defaults.format_on_save =
928 Some(FormatOnSave::Off);
929 });
930 });
931 });
932
933 // Stream unformatted edits again
934 let edit_result = {
935 let edit_task = cx.update(|cx| {
936 let input = EditFileToolInput {
937 display_description: "Update main function".into(),
938 path: "root/src/main.rs".into(),
939 mode: EditFileMode::Overwrite,
940 };
941 Arc::new(EditFileTool::new(
942 project.clone(),
943 thread.downgrade(),
944 language_registry,
945 Templates::new(),
946 ))
947 .run(input, ToolCallEventStream::test().0, cx)
948 });
949
950 // Stream the unformatted content
951 cx.executor().run_until_parked();
952 model.send_last_completion_stream_text_chunk(UNFORMATTED_CONTENT.to_string());
953 model.end_last_completion_stream();
954
955 edit_task.await
956 };
957 assert!(edit_result.is_ok());
958
959 // Wait for any async operations (e.g. formatting) to complete
960 cx.executor().run_until_parked();
961
962 // Verify the file was not formatted
963 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
964 assert_eq!(
965 // Ignore carriage returns on Windows
966 new_content.replace("\r\n", "\n"),
967 UNFORMATTED_CONTENT,
968 "Code should not be formatted when format_on_save is disabled"
969 );
970 }
971
972 #[gpui::test]
973 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
974 init_test(cx);
975
976 let fs = project::FakeFs::new(cx.executor());
977 fs.insert_tree("/root", json!({"src": {}})).await;
978
979 // Create a simple file with trailing whitespace
980 fs.save(
981 path!("/root/src/main.rs").as_ref(),
982 &"initial content".into(),
983 language::LineEnding::Unix,
984 )
985 .await
986 .unwrap();
987
988 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
989 let context_server_registry =
990 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
991 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
992 let model = Arc::new(FakeLanguageModel::default());
993 let thread = cx.new(|cx| {
994 Thread::new(
995 project.clone(),
996 cx.new(|_cx| ProjectContext::default()),
997 context_server_registry,
998 Templates::new(),
999 Some(model.clone()),
1000 cx,
1001 )
1002 });
1003
1004 // First, test with remove_trailing_whitespace_on_save enabled
1005 cx.update(|cx| {
1006 SettingsStore::update_global(cx, |store, cx| {
1007 store.update_user_settings(cx, |settings| {
1008 settings
1009 .project
1010 .all_languages
1011 .defaults
1012 .remove_trailing_whitespace_on_save = Some(true);
1013 });
1014 });
1015 });
1016
1017 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1018 "fn main() { \n println!(\"Hello!\"); \n}\n";
1019
1020 // Have the model stream content that contains trailing whitespace
1021 let edit_result = {
1022 let edit_task = cx.update(|cx| {
1023 let input = EditFileToolInput {
1024 display_description: "Create main function".into(),
1025 path: "root/src/main.rs".into(),
1026 mode: EditFileMode::Overwrite,
1027 };
1028 Arc::new(EditFileTool::new(
1029 project.clone(),
1030 thread.downgrade(),
1031 language_registry.clone(),
1032 Templates::new(),
1033 ))
1034 .run(input, ToolCallEventStream::test().0, cx)
1035 });
1036
1037 // Stream the content with trailing whitespace
1038 cx.executor().run_until_parked();
1039 model.send_last_completion_stream_text_chunk(
1040 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1041 );
1042 model.end_last_completion_stream();
1043
1044 edit_task.await
1045 };
1046 assert!(edit_result.is_ok());
1047
1048 // Wait for any async operations (e.g. formatting) to complete
1049 cx.executor().run_until_parked();
1050
1051 // Read the file to verify trailing whitespace was removed automatically
1052 assert_eq!(
1053 // Ignore carriage returns on Windows
1054 fs.load(path!("/root/src/main.rs").as_ref())
1055 .await
1056 .unwrap()
1057 .replace("\r\n", "\n"),
1058 "fn main() {\n println!(\"Hello!\");\n}\n",
1059 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1060 );
1061
1062 // Next, test with remove_trailing_whitespace_on_save disabled
1063 cx.update(|cx| {
1064 SettingsStore::update_global(cx, |store, cx| {
1065 store.update_user_settings(cx, |settings| {
1066 settings
1067 .project
1068 .all_languages
1069 .defaults
1070 .remove_trailing_whitespace_on_save = Some(false);
1071 });
1072 });
1073 });
1074
1075 // Stream edits again with trailing whitespace
1076 let edit_result = {
1077 let edit_task = cx.update(|cx| {
1078 let input = EditFileToolInput {
1079 display_description: "Update main function".into(),
1080 path: "root/src/main.rs".into(),
1081 mode: EditFileMode::Overwrite,
1082 };
1083 Arc::new(EditFileTool::new(
1084 project.clone(),
1085 thread.downgrade(),
1086 language_registry,
1087 Templates::new(),
1088 ))
1089 .run(input, ToolCallEventStream::test().0, cx)
1090 });
1091
1092 // Stream the content with trailing whitespace
1093 cx.executor().run_until_parked();
1094 model.send_last_completion_stream_text_chunk(
1095 CONTENT_WITH_TRAILING_WHITESPACE.to_string(),
1096 );
1097 model.end_last_completion_stream();
1098
1099 edit_task.await
1100 };
1101 assert!(edit_result.is_ok());
1102
1103 // Wait for any async operations (e.g. formatting) to complete
1104 cx.executor().run_until_parked();
1105
1106 // Verify the file still has trailing whitespace
1107 // Read the file again - it should still have trailing whitespace
1108 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1109 assert_eq!(
1110 // Ignore carriage returns on Windows
1111 final_content.replace("\r\n", "\n"),
1112 CONTENT_WITH_TRAILING_WHITESPACE,
1113 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1114 );
1115 }
1116
1117 #[gpui::test]
1118 async fn test_authorize(cx: &mut TestAppContext) {
1119 init_test(cx);
1120 let fs = project::FakeFs::new(cx.executor());
1121 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1122 let context_server_registry =
1123 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1124 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1125 let model = Arc::new(FakeLanguageModel::default());
1126 let thread = cx.new(|cx| {
1127 Thread::new(
1128 project.clone(),
1129 cx.new(|_cx| ProjectContext::default()),
1130 context_server_registry,
1131 Templates::new(),
1132 Some(model.clone()),
1133 cx,
1134 )
1135 });
1136 let tool = Arc::new(EditFileTool::new(
1137 project.clone(),
1138 thread.downgrade(),
1139 language_registry,
1140 Templates::new(),
1141 ));
1142 fs.insert_tree("/root", json!({})).await;
1143
1144 // Test 1: Path with .zed component should require confirmation
1145 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1146 let _auth = cx.update(|cx| {
1147 tool.authorize(
1148 &EditFileToolInput {
1149 display_description: "test 1".into(),
1150 path: ".zed/settings.json".into(),
1151 mode: EditFileMode::Edit,
1152 },
1153 &stream_tx,
1154 cx,
1155 )
1156 });
1157
1158 let event = stream_rx.expect_authorization().await;
1159 assert_eq!(
1160 event.tool_call.fields.title,
1161 Some("test 1 (local settings)".into())
1162 );
1163
1164 // Test 2: Path outside project should require confirmation
1165 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1166 let _auth = cx.update(|cx| {
1167 tool.authorize(
1168 &EditFileToolInput {
1169 display_description: "test 2".into(),
1170 path: "/etc/hosts".into(),
1171 mode: EditFileMode::Edit,
1172 },
1173 &stream_tx,
1174 cx,
1175 )
1176 });
1177
1178 let event = stream_rx.expect_authorization().await;
1179 assert_eq!(event.tool_call.fields.title, Some("test 2".into()));
1180
1181 // Test 3: Relative path without .zed should not require confirmation
1182 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1183 cx.update(|cx| {
1184 tool.authorize(
1185 &EditFileToolInput {
1186 display_description: "test 3".into(),
1187 path: "root/src/main.rs".into(),
1188 mode: EditFileMode::Edit,
1189 },
1190 &stream_tx,
1191 cx,
1192 )
1193 })
1194 .await
1195 .unwrap();
1196 assert!(stream_rx.try_next().is_err());
1197
1198 // Test 4: Path with .zed in the middle should require confirmation
1199 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1200 let _auth = cx.update(|cx| {
1201 tool.authorize(
1202 &EditFileToolInput {
1203 display_description: "test 4".into(),
1204 path: "root/.zed/tasks.json".into(),
1205 mode: EditFileMode::Edit,
1206 },
1207 &stream_tx,
1208 cx,
1209 )
1210 });
1211 let event = stream_rx.expect_authorization().await;
1212 assert_eq!(
1213 event.tool_call.fields.title,
1214 Some("test 4 (local settings)".into())
1215 );
1216
1217 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1218 cx.update(|cx| {
1219 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1220 settings.always_allow_tool_actions = true;
1221 agent_settings::AgentSettings::override_global(settings, cx);
1222 });
1223
1224 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1225 cx.update(|cx| {
1226 tool.authorize(
1227 &EditFileToolInput {
1228 display_description: "test 5.1".into(),
1229 path: ".zed/settings.json".into(),
1230 mode: EditFileMode::Edit,
1231 },
1232 &stream_tx,
1233 cx,
1234 )
1235 })
1236 .await
1237 .unwrap();
1238 assert!(stream_rx.try_next().is_err());
1239
1240 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1241 cx.update(|cx| {
1242 tool.authorize(
1243 &EditFileToolInput {
1244 display_description: "test 5.2".into(),
1245 path: "/etc/hosts".into(),
1246 mode: EditFileMode::Edit,
1247 },
1248 &stream_tx,
1249 cx,
1250 )
1251 })
1252 .await
1253 .unwrap();
1254 assert!(stream_rx.try_next().is_err());
1255 }
1256
1257 #[gpui::test]
1258 async fn test_authorize_global_config(cx: &mut TestAppContext) {
1259 init_test(cx);
1260 let fs = project::FakeFs::new(cx.executor());
1261 fs.insert_tree("/project", json!({})).await;
1262 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1263 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1264 let context_server_registry =
1265 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1266 let model = Arc::new(FakeLanguageModel::default());
1267 let thread = cx.new(|cx| {
1268 Thread::new(
1269 project.clone(),
1270 cx.new(|_cx| ProjectContext::default()),
1271 context_server_registry,
1272 Templates::new(),
1273 Some(model.clone()),
1274 cx,
1275 )
1276 });
1277 let tool = Arc::new(EditFileTool::new(
1278 project.clone(),
1279 thread.downgrade(),
1280 language_registry,
1281 Templates::new(),
1282 ));
1283
1284 // Test global config paths - these should require confirmation if they exist and are outside the project
1285 let test_cases = vec![
1286 (
1287 "/etc/hosts",
1288 true,
1289 "System file should require confirmation",
1290 ),
1291 (
1292 "/usr/local/bin/script",
1293 true,
1294 "System bin file should require confirmation",
1295 ),
1296 (
1297 "project/normal_file.rs",
1298 false,
1299 "Normal project file should not require confirmation",
1300 ),
1301 ];
1302
1303 for (path, should_confirm, description) in test_cases {
1304 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1305 let auth = cx.update(|cx| {
1306 tool.authorize(
1307 &EditFileToolInput {
1308 display_description: "Edit file".into(),
1309 path: path.into(),
1310 mode: EditFileMode::Edit,
1311 },
1312 &stream_tx,
1313 cx,
1314 )
1315 });
1316
1317 if should_confirm {
1318 stream_rx.expect_authorization().await;
1319 } else {
1320 auth.await.unwrap();
1321 assert!(
1322 stream_rx.try_next().is_err(),
1323 "Failed for case: {} - path: {} - expected no confirmation but got one",
1324 description,
1325 path
1326 );
1327 }
1328 }
1329 }
1330
1331 #[gpui::test]
1332 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1333 init_test(cx);
1334 let fs = project::FakeFs::new(cx.executor());
1335
1336 // Create multiple worktree directories
1337 fs.insert_tree(
1338 "/workspace/frontend",
1339 json!({
1340 "src": {
1341 "main.js": "console.log('frontend');"
1342 }
1343 }),
1344 )
1345 .await;
1346 fs.insert_tree(
1347 "/workspace/backend",
1348 json!({
1349 "src": {
1350 "main.rs": "fn main() {}"
1351 }
1352 }),
1353 )
1354 .await;
1355 fs.insert_tree(
1356 "/workspace/shared",
1357 json!({
1358 ".zed": {
1359 "settings.json": "{}"
1360 }
1361 }),
1362 )
1363 .await;
1364
1365 // Create project with multiple worktrees
1366 let project = Project::test(
1367 fs.clone(),
1368 [
1369 path!("/workspace/frontend").as_ref(),
1370 path!("/workspace/backend").as_ref(),
1371 path!("/workspace/shared").as_ref(),
1372 ],
1373 cx,
1374 )
1375 .await;
1376 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1377 let context_server_registry =
1378 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1379 let model = Arc::new(FakeLanguageModel::default());
1380 let thread = cx.new(|cx| {
1381 Thread::new(
1382 project.clone(),
1383 cx.new(|_cx| ProjectContext::default()),
1384 context_server_registry.clone(),
1385 Templates::new(),
1386 Some(model.clone()),
1387 cx,
1388 )
1389 });
1390 let tool = Arc::new(EditFileTool::new(
1391 project.clone(),
1392 thread.downgrade(),
1393 language_registry,
1394 Templates::new(),
1395 ));
1396
1397 // Test files in different worktrees
1398 let test_cases = vec![
1399 ("frontend/src/main.js", false, "File in first worktree"),
1400 ("backend/src/main.rs", false, "File in second worktree"),
1401 (
1402 "shared/.zed/settings.json",
1403 true,
1404 ".zed file in third worktree",
1405 ),
1406 ("/etc/hosts", true, "Absolute path outside all worktrees"),
1407 (
1408 "../outside/file.txt",
1409 true,
1410 "Relative path outside worktrees",
1411 ),
1412 ];
1413
1414 for (path, should_confirm, description) in test_cases {
1415 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1416 let auth = cx.update(|cx| {
1417 tool.authorize(
1418 &EditFileToolInput {
1419 display_description: "Edit file".into(),
1420 path: path.into(),
1421 mode: EditFileMode::Edit,
1422 },
1423 &stream_tx,
1424 cx,
1425 )
1426 });
1427
1428 if should_confirm {
1429 stream_rx.expect_authorization().await;
1430 } else {
1431 auth.await.unwrap();
1432 assert!(
1433 stream_rx.try_next().is_err(),
1434 "Failed for case: {} - path: {} - expected no confirmation but got one",
1435 description,
1436 path
1437 );
1438 }
1439 }
1440 }
1441
1442 #[gpui::test]
1443 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
1444 init_test(cx);
1445 let fs = project::FakeFs::new(cx.executor());
1446 fs.insert_tree(
1447 "/project",
1448 json!({
1449 ".zed": {
1450 "settings.json": "{}"
1451 },
1452 "src": {
1453 ".zed": {
1454 "local.json": "{}"
1455 }
1456 }
1457 }),
1458 )
1459 .await;
1460 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1461 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1462 let context_server_registry =
1463 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1464 let model = Arc::new(FakeLanguageModel::default());
1465 let thread = cx.new(|cx| {
1466 Thread::new(
1467 project.clone(),
1468 cx.new(|_cx| ProjectContext::default()),
1469 context_server_registry.clone(),
1470 Templates::new(),
1471 Some(model.clone()),
1472 cx,
1473 )
1474 });
1475 let tool = Arc::new(EditFileTool::new(
1476 project.clone(),
1477 thread.downgrade(),
1478 language_registry,
1479 Templates::new(),
1480 ));
1481
1482 // Test edge cases
1483 let test_cases = vec![
1484 // Empty path - find_project_path returns Some for empty paths
1485 ("", false, "Empty path is treated as project root"),
1486 // Root directory
1487 ("/", true, "Root directory should be outside project"),
1488 // Parent directory references - find_project_path resolves these
1489 (
1490 "project/../other",
1491 true,
1492 "Path with .. that goes outside of root directory",
1493 ),
1494 (
1495 "project/./src/file.rs",
1496 false,
1497 "Path with . should work normally",
1498 ),
1499 // Windows-style paths (if on Windows)
1500 #[cfg(target_os = "windows")]
1501 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
1502 #[cfg(target_os = "windows")]
1503 ("project\\src\\main.rs", false, "Windows-style project path"),
1504 ];
1505
1506 for (path, should_confirm, description) in test_cases {
1507 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1508 let auth = cx.update(|cx| {
1509 tool.authorize(
1510 &EditFileToolInput {
1511 display_description: "Edit file".into(),
1512 path: path.into(),
1513 mode: EditFileMode::Edit,
1514 },
1515 &stream_tx,
1516 cx,
1517 )
1518 });
1519
1520 cx.run_until_parked();
1521
1522 if should_confirm {
1523 stream_rx.expect_authorization().await;
1524 } else {
1525 assert!(
1526 stream_rx.try_next().is_err(),
1527 "Failed for case: {} - path: {} - expected no confirmation but got one",
1528 description,
1529 path
1530 );
1531 auth.await.unwrap();
1532 }
1533 }
1534 }
1535
1536 #[gpui::test]
1537 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
1538 init_test(cx);
1539 let fs = project::FakeFs::new(cx.executor());
1540 fs.insert_tree(
1541 "/project",
1542 json!({
1543 "existing.txt": "content",
1544 ".zed": {
1545 "settings.json": "{}"
1546 }
1547 }),
1548 )
1549 .await;
1550 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1551 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1552 let context_server_registry =
1553 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1554 let model = Arc::new(FakeLanguageModel::default());
1555 let thread = cx.new(|cx| {
1556 Thread::new(
1557 project.clone(),
1558 cx.new(|_cx| ProjectContext::default()),
1559 context_server_registry.clone(),
1560 Templates::new(),
1561 Some(model.clone()),
1562 cx,
1563 )
1564 });
1565 let tool = Arc::new(EditFileTool::new(
1566 project.clone(),
1567 thread.downgrade(),
1568 language_registry,
1569 Templates::new(),
1570 ));
1571
1572 // Test different EditFileMode values
1573 let modes = vec![
1574 EditFileMode::Edit,
1575 EditFileMode::Create,
1576 EditFileMode::Overwrite,
1577 ];
1578
1579 for mode in modes {
1580 // Test .zed path with different modes
1581 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1582 let _auth = cx.update(|cx| {
1583 tool.authorize(
1584 &EditFileToolInput {
1585 display_description: "Edit settings".into(),
1586 path: "project/.zed/settings.json".into(),
1587 mode: mode.clone(),
1588 },
1589 &stream_tx,
1590 cx,
1591 )
1592 });
1593
1594 stream_rx.expect_authorization().await;
1595
1596 // Test outside path with different modes
1597 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1598 let _auth = cx.update(|cx| {
1599 tool.authorize(
1600 &EditFileToolInput {
1601 display_description: "Edit file".into(),
1602 path: "/outside/file.txt".into(),
1603 mode: mode.clone(),
1604 },
1605 &stream_tx,
1606 cx,
1607 )
1608 });
1609
1610 stream_rx.expect_authorization().await;
1611
1612 // Test normal path with different modes
1613 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1614 cx.update(|cx| {
1615 tool.authorize(
1616 &EditFileToolInput {
1617 display_description: "Edit file".into(),
1618 path: "project/normal.txt".into(),
1619 mode: mode.clone(),
1620 },
1621 &stream_tx,
1622 cx,
1623 )
1624 })
1625 .await
1626 .unwrap();
1627 assert!(stream_rx.try_next().is_err());
1628 }
1629 }
1630
1631 #[gpui::test]
1632 async fn test_initial_title_with_partial_input(cx: &mut TestAppContext) {
1633 init_test(cx);
1634 let fs = project::FakeFs::new(cx.executor());
1635 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1636 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
1637 let context_server_registry =
1638 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1639 let model = Arc::new(FakeLanguageModel::default());
1640 let thread = cx.new(|cx| {
1641 Thread::new(
1642 project.clone(),
1643 cx.new(|_cx| ProjectContext::default()),
1644 context_server_registry,
1645 Templates::new(),
1646 Some(model.clone()),
1647 cx,
1648 )
1649 });
1650 let tool = Arc::new(EditFileTool::new(
1651 project,
1652 thread.downgrade(),
1653 language_registry,
1654 Templates::new(),
1655 ));
1656
1657 cx.update(|cx| {
1658 // ...
1659 assert_eq!(
1660 tool.initial_title(
1661 Err(json!({
1662 "path": "src/main.rs",
1663 "display_description": "",
1664 "old_string": "old code",
1665 "new_string": "new code"
1666 })),
1667 cx
1668 ),
1669 "src/main.rs"
1670 );
1671 assert_eq!(
1672 tool.initial_title(
1673 Err(json!({
1674 "path": "",
1675 "display_description": "Fix error handling",
1676 "old_string": "old code",
1677 "new_string": "new code"
1678 })),
1679 cx
1680 ),
1681 "Fix error handling"
1682 );
1683 assert_eq!(
1684 tool.initial_title(
1685 Err(json!({
1686 "path": "src/main.rs",
1687 "display_description": "Fix error handling",
1688 "old_string": "old code",
1689 "new_string": "new code"
1690 })),
1691 cx
1692 ),
1693 "src/main.rs"
1694 );
1695 assert_eq!(
1696 tool.initial_title(
1697 Err(json!({
1698 "path": "",
1699 "display_description": "",
1700 "old_string": "old code",
1701 "new_string": "new code"
1702 })),
1703 cx
1704 ),
1705 DEFAULT_UI_TEXT
1706 );
1707 assert_eq!(
1708 tool.initial_title(Err(serde_json::Value::Null), cx),
1709 DEFAULT_UI_TEXT
1710 );
1711 });
1712 }
1713
1714 #[gpui::test]
1715 async fn test_diff_finalization(cx: &mut TestAppContext) {
1716 init_test(cx);
1717 let fs = project::FakeFs::new(cx.executor());
1718 fs.insert_tree("/", json!({"main.rs": ""})).await;
1719
1720 let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await;
1721 let languages = project.read_with(cx, |project, _cx| project.languages().clone());
1722 let context_server_registry =
1723 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1724 let model = Arc::new(FakeLanguageModel::default());
1725 let thread = cx.new(|cx| {
1726 Thread::new(
1727 project.clone(),
1728 cx.new(|_cx| ProjectContext::default()),
1729 context_server_registry.clone(),
1730 Templates::new(),
1731 Some(model.clone()),
1732 cx,
1733 )
1734 });
1735
1736 // Ensure the diff is finalized after the edit completes.
1737 {
1738 let tool = Arc::new(EditFileTool::new(
1739 project.clone(),
1740 thread.downgrade(),
1741 languages.clone(),
1742 Templates::new(),
1743 ));
1744 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1745 let edit = cx.update(|cx| {
1746 tool.run(
1747 EditFileToolInput {
1748 display_description: "Edit file".into(),
1749 path: path!("/main.rs").into(),
1750 mode: EditFileMode::Edit,
1751 },
1752 stream_tx,
1753 cx,
1754 )
1755 });
1756 stream_rx.expect_update_fields().await;
1757 let diff = stream_rx.expect_diff().await;
1758 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1759 cx.run_until_parked();
1760 model.end_last_completion_stream();
1761 edit.await.unwrap();
1762 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1763 }
1764
1765 // Ensure the diff is finalized if an error occurs while editing.
1766 {
1767 model.forbid_requests();
1768 let tool = Arc::new(EditFileTool::new(
1769 project.clone(),
1770 thread.downgrade(),
1771 languages.clone(),
1772 Templates::new(),
1773 ));
1774 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1775 let edit = cx.update(|cx| {
1776 tool.run(
1777 EditFileToolInput {
1778 display_description: "Edit file".into(),
1779 path: path!("/main.rs").into(),
1780 mode: EditFileMode::Edit,
1781 },
1782 stream_tx,
1783 cx,
1784 )
1785 });
1786 stream_rx.expect_update_fields().await;
1787 let diff = stream_rx.expect_diff().await;
1788 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1789 edit.await.unwrap_err();
1790 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1791 model.allow_requests();
1792 }
1793
1794 // Ensure the diff is finalized if the tool call gets dropped.
1795 {
1796 let tool = Arc::new(EditFileTool::new(
1797 project.clone(),
1798 thread.downgrade(),
1799 languages.clone(),
1800 Templates::new(),
1801 ));
1802 let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
1803 let edit = cx.update(|cx| {
1804 tool.run(
1805 EditFileToolInput {
1806 display_description: "Edit file".into(),
1807 path: path!("/main.rs").into(),
1808 mode: EditFileMode::Edit,
1809 },
1810 stream_tx,
1811 cx,
1812 )
1813 });
1814 stream_rx.expect_update_fields().await;
1815 let diff = stream_rx.expect_diff().await;
1816 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_))));
1817 drop(edit);
1818 cx.run_until_parked();
1819 diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_))));
1820 }
1821 }
1822
1823 #[gpui::test]
1824 async fn test_file_read_times_tracking(cx: &mut TestAppContext) {
1825 init_test(cx);
1826
1827 let fs = project::FakeFs::new(cx.executor());
1828 fs.insert_tree(
1829 "/root",
1830 json!({
1831 "test.txt": "original content"
1832 }),
1833 )
1834 .await;
1835 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1836 let context_server_registry =
1837 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1838 let model = Arc::new(FakeLanguageModel::default());
1839 let thread = cx.new(|cx| {
1840 Thread::new(
1841 project.clone(),
1842 cx.new(|_cx| ProjectContext::default()),
1843 context_server_registry,
1844 Templates::new(),
1845 Some(model.clone()),
1846 cx,
1847 )
1848 });
1849 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1850
1851 // Initially, file_read_times should be empty
1852 let is_empty = thread.read_with(cx, |thread, _| thread.file_read_times.is_empty());
1853 assert!(is_empty, "file_read_times should start empty");
1854
1855 // Create read tool
1856 let read_tool = Arc::new(crate::ReadFileTool::new(
1857 thread.downgrade(),
1858 project.clone(),
1859 action_log,
1860 ));
1861
1862 // Read the file to record the read time
1863 cx.update(|cx| {
1864 read_tool.clone().run(
1865 crate::ReadFileToolInput {
1866 path: "root/test.txt".to_string(),
1867 start_line: None,
1868 end_line: None,
1869 },
1870 ToolCallEventStream::test().0,
1871 cx,
1872 )
1873 })
1874 .await
1875 .unwrap();
1876
1877 // Verify that file_read_times now contains an entry for the file
1878 let has_entry = thread.read_with(cx, |thread, _| {
1879 thread.file_read_times.len() == 1
1880 && thread
1881 .file_read_times
1882 .keys()
1883 .any(|path| path.ends_with("test.txt"))
1884 });
1885 assert!(
1886 has_entry,
1887 "file_read_times should contain an entry after reading the file"
1888 );
1889
1890 // Read the file again - should update the entry
1891 cx.update(|cx| {
1892 read_tool.clone().run(
1893 crate::ReadFileToolInput {
1894 path: "root/test.txt".to_string(),
1895 start_line: None,
1896 end_line: None,
1897 },
1898 ToolCallEventStream::test().0,
1899 cx,
1900 )
1901 })
1902 .await
1903 .unwrap();
1904
1905 // Should still have exactly one entry
1906 let has_one_entry = thread.read_with(cx, |thread, _| thread.file_read_times.len() == 1);
1907 assert!(
1908 has_one_entry,
1909 "file_read_times should still have one entry after re-reading"
1910 );
1911 }
1912
1913 fn init_test(cx: &mut TestAppContext) {
1914 cx.update(|cx| {
1915 let settings_store = SettingsStore::test(cx);
1916 cx.set_global(settings_store);
1917 });
1918 }
1919
1920 #[gpui::test]
1921 async fn test_consecutive_edits_work(cx: &mut TestAppContext) {
1922 init_test(cx);
1923
1924 let fs = project::FakeFs::new(cx.executor());
1925 fs.insert_tree(
1926 "/root",
1927 json!({
1928 "test.txt": "original content"
1929 }),
1930 )
1931 .await;
1932 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1933 let context_server_registry =
1934 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
1935 let model = Arc::new(FakeLanguageModel::default());
1936 let thread = cx.new(|cx| {
1937 Thread::new(
1938 project.clone(),
1939 cx.new(|_cx| ProjectContext::default()),
1940 context_server_registry,
1941 Templates::new(),
1942 Some(model.clone()),
1943 cx,
1944 )
1945 });
1946 let languages = project.read_with(cx, |project, _| project.languages().clone());
1947 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
1948
1949 let read_tool = Arc::new(crate::ReadFileTool::new(
1950 thread.downgrade(),
1951 project.clone(),
1952 action_log,
1953 ));
1954 let edit_tool = Arc::new(EditFileTool::new(
1955 project.clone(),
1956 thread.downgrade(),
1957 languages,
1958 Templates::new(),
1959 ));
1960
1961 // Read the file first
1962 cx.update(|cx| {
1963 read_tool.clone().run(
1964 crate::ReadFileToolInput {
1965 path: "root/test.txt".to_string(),
1966 start_line: None,
1967 end_line: None,
1968 },
1969 ToolCallEventStream::test().0,
1970 cx,
1971 )
1972 })
1973 .await
1974 .unwrap();
1975
1976 // First edit should work
1977 let edit_result = {
1978 let edit_task = cx.update(|cx| {
1979 edit_tool.clone().run(
1980 EditFileToolInput {
1981 display_description: "First edit".into(),
1982 path: "root/test.txt".into(),
1983 mode: EditFileMode::Edit,
1984 },
1985 ToolCallEventStream::test().0,
1986 cx,
1987 )
1988 });
1989
1990 cx.executor().run_until_parked();
1991 model.send_last_completion_stream_text_chunk(
1992 "<old_text>original content</old_text><new_text>modified content</new_text>"
1993 .to_string(),
1994 );
1995 model.end_last_completion_stream();
1996
1997 edit_task.await
1998 };
1999 assert!(
2000 edit_result.is_ok(),
2001 "First edit should succeed, got error: {:?}",
2002 edit_result.as_ref().err()
2003 );
2004
2005 // Second edit should also work because the edit updated the recorded read time
2006 let edit_result = {
2007 let edit_task = cx.update(|cx| {
2008 edit_tool.clone().run(
2009 EditFileToolInput {
2010 display_description: "Second edit".into(),
2011 path: "root/test.txt".into(),
2012 mode: EditFileMode::Edit,
2013 },
2014 ToolCallEventStream::test().0,
2015 cx,
2016 )
2017 });
2018
2019 cx.executor().run_until_parked();
2020 model.send_last_completion_stream_text_chunk(
2021 "<old_text>modified content</old_text><new_text>further modified content</new_text>".to_string(),
2022 );
2023 model.end_last_completion_stream();
2024
2025 edit_task.await
2026 };
2027 assert!(
2028 edit_result.is_ok(),
2029 "Second consecutive edit should succeed, got error: {:?}",
2030 edit_result.as_ref().err()
2031 );
2032 }
2033
2034 #[gpui::test]
2035 async fn test_external_modification_detected(cx: &mut TestAppContext) {
2036 init_test(cx);
2037
2038 let fs = project::FakeFs::new(cx.executor());
2039 fs.insert_tree(
2040 "/root",
2041 json!({
2042 "test.txt": "original content"
2043 }),
2044 )
2045 .await;
2046 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2047 let context_server_registry =
2048 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2049 let model = Arc::new(FakeLanguageModel::default());
2050 let thread = cx.new(|cx| {
2051 Thread::new(
2052 project.clone(),
2053 cx.new(|_cx| ProjectContext::default()),
2054 context_server_registry,
2055 Templates::new(),
2056 Some(model.clone()),
2057 cx,
2058 )
2059 });
2060 let languages = project.read_with(cx, |project, _| project.languages().clone());
2061 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2062
2063 let read_tool = Arc::new(crate::ReadFileTool::new(
2064 thread.downgrade(),
2065 project.clone(),
2066 action_log,
2067 ));
2068 let edit_tool = Arc::new(EditFileTool::new(
2069 project.clone(),
2070 thread.downgrade(),
2071 languages,
2072 Templates::new(),
2073 ));
2074
2075 // Read the file first
2076 cx.update(|cx| {
2077 read_tool.clone().run(
2078 crate::ReadFileToolInput {
2079 path: "root/test.txt".to_string(),
2080 start_line: None,
2081 end_line: None,
2082 },
2083 ToolCallEventStream::test().0,
2084 cx,
2085 )
2086 })
2087 .await
2088 .unwrap();
2089
2090 // Simulate external modification - advance time and save file
2091 cx.background_executor
2092 .advance_clock(std::time::Duration::from_secs(2));
2093 fs.save(
2094 path!("/root/test.txt").as_ref(),
2095 &"externally modified content".into(),
2096 language::LineEnding::Unix,
2097 )
2098 .await
2099 .unwrap();
2100
2101 // Reload the buffer to pick up the new mtime
2102 let project_path = project
2103 .read_with(cx, |project, cx| {
2104 project.find_project_path("root/test.txt", cx)
2105 })
2106 .expect("Should find project path");
2107 let buffer = project
2108 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2109 .await
2110 .unwrap();
2111 buffer
2112 .update(cx, |buffer, cx| buffer.reload(cx))
2113 .await
2114 .unwrap();
2115
2116 cx.executor().run_until_parked();
2117
2118 // Try to edit - should fail because file was modified externally
2119 let result = cx
2120 .update(|cx| {
2121 edit_tool.clone().run(
2122 EditFileToolInput {
2123 display_description: "Edit after external change".into(),
2124 path: "root/test.txt".into(),
2125 mode: EditFileMode::Edit,
2126 },
2127 ToolCallEventStream::test().0,
2128 cx,
2129 )
2130 })
2131 .await;
2132
2133 assert!(
2134 result.is_err(),
2135 "Edit should fail after external modification"
2136 );
2137 let error_msg = result.unwrap_err().to_string();
2138 assert!(
2139 error_msg.contains("has been modified since you last read it"),
2140 "Error should mention file modification, got: {}",
2141 error_msg
2142 );
2143 }
2144
2145 #[gpui::test]
2146 async fn test_dirty_buffer_detected(cx: &mut TestAppContext) {
2147 init_test(cx);
2148
2149 let fs = project::FakeFs::new(cx.executor());
2150 fs.insert_tree(
2151 "/root",
2152 json!({
2153 "test.txt": "original content"
2154 }),
2155 )
2156 .await;
2157 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
2158 let context_server_registry =
2159 cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
2160 let model = Arc::new(FakeLanguageModel::default());
2161 let thread = cx.new(|cx| {
2162 Thread::new(
2163 project.clone(),
2164 cx.new(|_cx| ProjectContext::default()),
2165 context_server_registry,
2166 Templates::new(),
2167 Some(model.clone()),
2168 cx,
2169 )
2170 });
2171 let languages = project.read_with(cx, |project, _| project.languages().clone());
2172 let action_log = thread.read_with(cx, |thread, _| thread.action_log().clone());
2173
2174 let read_tool = Arc::new(crate::ReadFileTool::new(
2175 thread.downgrade(),
2176 project.clone(),
2177 action_log,
2178 ));
2179 let edit_tool = Arc::new(EditFileTool::new(
2180 project.clone(),
2181 thread.downgrade(),
2182 languages,
2183 Templates::new(),
2184 ));
2185
2186 // Read the file first
2187 cx.update(|cx| {
2188 read_tool.clone().run(
2189 crate::ReadFileToolInput {
2190 path: "root/test.txt".to_string(),
2191 start_line: None,
2192 end_line: None,
2193 },
2194 ToolCallEventStream::test().0,
2195 cx,
2196 )
2197 })
2198 .await
2199 .unwrap();
2200
2201 // Open the buffer and make it dirty by editing without saving
2202 let project_path = project
2203 .read_with(cx, |project, cx| {
2204 project.find_project_path("root/test.txt", cx)
2205 })
2206 .expect("Should find project path");
2207 let buffer = project
2208 .update(cx, |project, cx| project.open_buffer(project_path, cx))
2209 .await
2210 .unwrap();
2211
2212 // Make an in-memory edit to the buffer (making it dirty)
2213 buffer.update(cx, |buffer, cx| {
2214 let end_point = buffer.max_point();
2215 buffer.edit([(end_point..end_point, " added text")], None, cx);
2216 });
2217
2218 // Verify buffer is dirty
2219 let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
2220 assert!(is_dirty, "Buffer should be dirty after in-memory edit");
2221
2222 // Try to edit - should fail because buffer has unsaved changes
2223 let result = cx
2224 .update(|cx| {
2225 edit_tool.clone().run(
2226 EditFileToolInput {
2227 display_description: "Edit with dirty buffer".into(),
2228 path: "root/test.txt".into(),
2229 mode: EditFileMode::Edit,
2230 },
2231 ToolCallEventStream::test().0,
2232 cx,
2233 )
2234 })
2235 .await;
2236
2237 assert!(result.is_err(), "Edit should fail when buffer is dirty");
2238 let error_msg = result.unwrap_err().to_string();
2239 assert!(
2240 error_msg.contains("This file has unsaved changes."),
2241 "Error should mention unsaved changes, got: {}",
2242 error_msg
2243 );
2244 assert!(
2245 error_msg.contains("keep or discard"),
2246 "Error should ask whether to keep or discard changes, got: {}",
2247 error_msg
2248 );
2249 // Since save_file and restore_file_from_disk tools aren't added to the thread,
2250 // the error message should ask the user to manually save or revert
2251 assert!(
2252 error_msg.contains("save or revert the file manually"),
2253 "Error should ask user to manually save or revert when tools aren't available, got: {}",
2254 error_msg
2255 );
2256 }
2257}