1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use futures::FutureExt as _;
12use futures::future::BoxFuture;
13use pi_agent::{AgentTool, AgentToolResult, ToolError, ToolUpdates};
14use pi_ai::ToolResultContent;
15use pi_ai::types::TextContent;
16use serde::{Deserialize, Serialize};
17use serde_json::{Map, Value};
18use tokio_util::sync::CancellationToken;
19
20use super::edit_diff::{
21 Edit, apply_edits_to_normalized_content, detect_line_ending, generate_diff_string,
22 generate_unified_patch, normalize_to_lf, restore_line_endings, strip_bom,
23};
24use super::{MutationQueueError, PathResolveError, resolve_to_cwd, with_file_mutation_queue};
25
26#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ReplaceEditInput {
30 pub old_text: String,
32 pub new_text: String,
34}
35
36#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38pub struct EditToolInput {
39 pub path: String,
41 pub edits: Vec<ReplaceEditInput>,
43}
44
45#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct EditToolDetails {
49 pub diff: String,
51 pub patch: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub first_changed_line: Option<usize>,
56}
57
58#[derive(Clone, Debug)]
60pub struct EditToolOptions {
61 pub cwd: PathBuf,
63}
64
65impl EditToolOptions {
66 #[must_use]
68 pub fn new(cwd: impl Into<PathBuf>) -> Self {
69 Self { cwd: cwd.into() }
70 }
71}
72
73#[derive(Clone, Debug)]
75pub struct EditTool {
76 cwd: PathBuf,
77 parameters: Value,
78 description: String,
79}
80
81impl EditTool {
82 #[must_use]
84 pub fn new(cwd: impl Into<PathBuf>) -> Self {
85 Self::with_options(EditToolOptions::new(cwd))
86 }
87
88 #[must_use]
90 pub fn with_options(options: EditToolOptions) -> Self {
91 Self {
92 cwd: options.cwd,
93 parameters: edit_parameters_schema(),
94 description: "Edit a single file using exact text replacement. Every edits[].oldText must match a unique, non-overlapping region of the original file. If two changes affect the same block or nearby lines, merge them into one edit instead of emitting overlapping edits. Do not include large unchanged regions just to connect distant changes.".to_owned(),
95 }
96 }
97
98 #[must_use]
100 pub fn parameters_schema() -> Value {
101 edit_parameters_schema()
102 }
103
104 #[must_use]
106 pub fn prepare_edit_arguments(raw: &Map<String, Value>) -> Map<String, Value> {
107 let mut args = raw.clone();
108
109 if let Some(Value::String(edits_str)) = args.get("edits").cloned()
110 && let Ok(parsed) = serde_json::from_str::<Value>(&edits_str)
111 && parsed.is_array()
112 {
113 args.insert("edits".to_owned(), parsed);
114 }
115
116 let old_text = args.get("oldText").cloned();
117 let new_text = args.get("newText").cloned();
118 if let (Some(Value::String(old)), Some(Value::String(new))) = (old_text, new_text) {
119 let mut edits = match args.get("edits") {
120 Some(Value::Array(items)) => items.clone(),
121 _ => Vec::new(),
122 };
123 edits.push(serde_json::json!({
124 "oldText": old,
125 "newText": new,
126 }));
127 args.insert("edits".to_owned(), Value::Array(edits));
128 args.remove("oldText");
129 args.remove("newText");
130 }
131
132 args
133 }
134
135 pub fn parse_input(args: &Map<String, Value>) -> Result<EditToolInput, ToolError> {
142 let input: EditToolInput = serde_json::from_value(Value::Object(args.clone()))
143 .map_err(|error| ToolError::new(format!("Edit tool input is invalid. {error}")))?;
144 if input.edits.is_empty() {
145 return Err(ToolError::new(
146 "Edit tool input is invalid. edits must contain at least one replacement.",
147 ));
148 }
149 Ok(input)
150 }
151
152 #[must_use]
154 pub fn success_text(path: &str, edit_count: usize) -> String {
155 format!("Successfully replaced {edit_count} block(s) in {path}.")
156 }
157}
158
159impl AgentTool for EditTool {
160 fn name(&self) -> &'static str {
161 "edit"
162 }
163
164 fn label(&self) -> &'static str {
165 "edit"
166 }
167
168 fn description(&self) -> &str {
169 &self.description
170 }
171
172 fn parameters(&self) -> &Value {
173 &self.parameters
174 }
175
176 fn prepare_arguments(&self, raw: &Map<String, Value>) -> Result<Map<String, Value>, ToolError> {
177 Ok(Self::prepare_edit_arguments(raw))
178 }
179
180 fn validate_arguments(
181 &self,
182 args: &Map<String, Value>,
183 ) -> Result<Map<String, Value>, ToolError> {
184 let _ = Self::parse_input(args)?;
185 Ok(args.clone())
186 }
187
188 fn execute(
189 &self,
190 _tool_call_id: &str,
191 args: Map<String, Value>,
192 cancel: CancellationToken,
193 _updates: ToolUpdates,
194 ) -> BoxFuture<'static, Result<AgentToolResult, ToolError>> {
195 let cwd = self.cwd.clone();
196 async move {
197 throw_if_cancelled(&cancel)?;
198 let edit = parse_edit_request(&cwd, &args)?;
199 apply_edit(edit, &cancel).await
200 }
201 .boxed()
202 }
203}
204
205struct PreparedEdit {
206 absolute: PathBuf,
207 path_for_message: String,
208 edits: Vec<Edit>,
209}
210
211fn parse_edit_request(cwd: &Path, args: &Map<String, Value>) -> Result<PreparedEdit, ToolError> {
212 let input = EditTool::parse_input(args)?;
213 let absolute_path = resolve_to_cwd(&input.path, cwd.to_string_lossy().as_ref())
214 .map_err(|error| path_error(&error))?;
215 let edits = input
216 .edits
217 .into_iter()
218 .map(|edit| Edit {
219 old_text: edit.old_text,
220 new_text: edit.new_text,
221 })
222 .collect();
223 Ok(PreparedEdit {
224 absolute: PathBuf::from(absolute_path),
225 path_for_message: input.path,
226 edits,
227 })
228}
229
230async fn apply_edit(
231 edit: PreparedEdit,
232 cancel: &CancellationToken,
233) -> Result<AgentToolResult, ToolError> {
234 let cancel_for_queue = cancel.clone();
235 with_file_mutation_queue(&edit.absolute, || {
236 let absolute = edit.absolute.clone();
237 let path_for_message = edit.path_for_message.clone();
238 let edits = edit.edits.clone();
239 let cancel = cancel_for_queue.clone();
240 async move { apply_edit_mutation(&absolute, &path_for_message, &edits, &cancel).await }
241 })
242 .await
243 .map_err(|error| mutation_error(&error))?
244}
245
246async fn apply_edit_mutation(
247 absolute: &Path,
248 path_for_message: &str,
249 edits: &[Edit],
250 cancel: &CancellationToken,
251) -> Result<AgentToolResult, ToolError> {
252 apply_edit_mutation_with_commit_hooks(absolute, path_for_message, edits, cancel, || {}, || {})
253 .await
254}
255
256async fn apply_edit_mutation_with_commit_hooks<BeforeCommit, AfterCommit>(
257 absolute: &Path,
258 path_for_message: &str,
259 edits: &[Edit],
260 cancel: &CancellationToken,
261 before_commit: BeforeCommit,
262 after_commit: AfterCommit,
263) -> Result<AgentToolResult, ToolError>
264where
265 BeforeCommit: FnOnce() + Send,
266 AfterCommit: FnOnce() + Send,
267{
268 throw_if_cancelled(cancel)?;
269
270 let metadata = tokio::fs::metadata(absolute)
272 .await
273 .map_err(|error| access_error(path_for_message, &error))?;
274 if metadata.permissions().readonly() {
275 return Err(ToolError::new(format!(
276 "Could not edit file: {path_for_message}. Error code: EACCES."
277 )));
278 }
279 let _ = tokio::fs::File::open(absolute)
281 .await
282 .map_err(|error| access_error(path_for_message, &error))?;
283 throw_if_cancelled(cancel)?;
284
285 let bytes = tokio::fs::read(absolute)
286 .await
287 .map_err(|error| access_error(path_for_message, &error))?;
288 let raw_content = String::from_utf8(bytes).map_err(|_| {
289 ToolError::new(format!(
290 "Could not edit file: {path_for_message}. File is not valid UTF-8."
291 ))
292 })?;
293 throw_if_cancelled(cancel)?;
294
295 let (bom, content) = strip_bom(&raw_content);
296 let original_ending = detect_line_ending(&content);
297 let normalized_content = normalize_to_lf(&content);
298 let applied = apply_edits_to_normalized_content(&normalized_content, edits, path_for_message)
299 .map_err(ToolError::new)?;
300 throw_if_cancelled(cancel)?;
301
302 let final_content = bom + &restore_line_endings(&applied.new_content, original_ending);
303 let diff_result = generate_diff_string(&applied.base_content, &applied.new_content, 4);
304 let patch = generate_unified_patch(
305 path_for_message,
306 &applied.base_content,
307 &applied.new_content,
308 4,
309 );
310 let details = EditToolDetails {
311 diff: diff_result.diff,
312 patch,
313 first_changed_line: diff_result.first_changed_line,
314 };
315 let details_value = serde_json::to_value(details)
316 .map_err(|error| ToolError::new(format!("Could not serialize edit details: {error}")))?;
317 let result = AgentToolResult {
318 content: vec![ToolResultContent::Text(TextContent::new(
319 EditTool::success_text(path_for_message, edits.len()),
320 ))],
321 details: details_value,
322 added_tool_names: None,
323 terminate: None,
324 };
325
326 before_commit();
327 throw_if_cancelled(cancel)?;
328 tokio::fs::write(absolute, final_content.as_bytes())
329 .await
330 .map_err(|error| {
331 ToolError::new(format!("Could not edit file: {path_for_message}. {error}."))
332 })?;
333 after_commit();
334
335 Ok(result)
338}
339
340fn edit_parameters_schema() -> Value {
341 serde_json::json!({
342 "type": "object",
343 "required": ["path", "edits"],
344 "properties": {
345 "path": {
346 "type": "string",
347 "description": "Path to the file to edit (relative or absolute)"
348 },
349 "edits": {
350 "type": "array",
351 "description": "One or more targeted replacements. Each edit is matched against the original file, not incrementally. Do not include overlapping or nested edits. If two changes touch the same block or nearby lines, merge them into one edit instead.",
352 "items": {
353 "type": "object",
354 "required": ["oldText", "newText"],
355 "properties": {
356 "oldText": {
357 "type": "string",
358 "description": "Exact text for one targeted replacement. It must be unique in the original file and must not overlap with any other edits[].oldText in the same call."
359 },
360 "newText": {
361 "type": "string",
362 "description": "Replacement text for this targeted edit."
363 }
364 }
365 }
366 }
367 }
368 })
369}
370
371fn throw_if_cancelled(cancel: &CancellationToken) -> Result<(), ToolError> {
372 if cancel.is_cancelled() {
373 Err(ToolError::new("Operation aborted"))
374 } else {
375 Ok(())
376 }
377}
378
379fn path_error(error: &PathResolveError) -> ToolError {
380 ToolError::new(error.to_string())
381}
382
383fn mutation_error(error: &MutationQueueError) -> ToolError {
384 ToolError::new(error.to_string())
385}
386
387fn access_error(path: &str, error: &std::io::Error) -> ToolError {
388 let message = match error.raw_os_error() {
389 Some(_) => {
390 let code = error.kind();
391 let code_str = match code {
394 std::io::ErrorKind::NotFound => "ENOENT",
395 std::io::ErrorKind::PermissionDenied => "EACCES",
396 std::io::ErrorKind::IsADirectory => "EISDIR",
397 std::io::ErrorKind::NotADirectory => "ENOTDIR",
398 _ => {
399 return ToolError::new(format!("Could not edit file: {path}. Error: {error}."));
401 }
402 };
403 format!("Error code: {code_str}")
404 }
405 None => format!("Error: {error}"),
406 };
407 ToolError::new(format!("Could not edit file: {path}. {message}."))
408}
409
410#[must_use]
412pub fn create_edit_tool(cwd: impl Into<PathBuf>) -> Arc<dyn AgentTool> {
413 Arc::new(EditTool::new(cwd))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use std::os::unix::fs::PermissionsExt;
420 use std::sync::Arc;
421 use std::time::Duration;
422
423 use serde_json::json;
424 use tempfile::tempdir;
425 use tokio::sync::Barrier;
426
427 fn fixture_schema() -> Result<Value, serde_json::Error> {
428 let text = include_str!("../../../tests/fixtures/tool-schemas/edit.json");
429 serde_json::from_str(text)
430 }
431
432 fn json_map(value: &Value) -> Map<String, Value> {
433 assert!(value.is_object(), "test input must be a JSON object");
434 value.as_object().cloned().unwrap_or_default()
435 }
436
437 fn text_of(result: &AgentToolResult) -> String {
438 match result.content.first() {
439 Some(ToolResultContent::Text(text)) => text.text.to_string(),
440 _ => String::new(),
441 }
442 }
443
444 #[test]
445 fn schema_matches_typebox_fixture() -> Result<(), Box<dyn std::error::Error>> {
446 let schema = EditTool::parameters_schema();
447 assert_eq!(schema, fixture_schema()?);
448 Ok(())
449 }
450
451 #[test]
452 fn prepare_folds_legacy_old_new_text() {
453 let prepared = EditTool::prepare_edit_arguments(&json_map(&json!({
454 "path": "file.txt",
455 "oldText": "before",
456 "newText": "after"
457 })));
458 assert_eq!(
459 prepared,
460 json_map(&json!({
461 "path": "file.txt",
462 "edits": [{"oldText": "before", "newText": "after"}]
463 }))
464 );
465 assert!(!prepared.contains_key("oldText"));
466 assert!(!prepared.contains_key("newText"));
467 }
468
469 #[test]
470 fn prepare_appends_legacy_to_existing_edits() {
471 let prepared = EditTool::prepare_edit_arguments(&json_map(&json!({
472 "path": "file.txt",
473 "edits": [{"oldText": "a", "newText": "b"}],
474 "oldText": "c",
475 "newText": "d"
476 })));
477 assert_eq!(
478 prepared.get("edits"),
479 Some(&json!([
480 {"oldText": "a", "newText": "b"},
481 {"oldText": "c", "newText": "d"}
482 ]))
483 );
484 }
485
486 #[test]
487 fn prepare_parses_stringified_edits() {
488 let prepared = EditTool::prepare_edit_arguments(&json_map(&json!({
489 "path": "file.txt",
490 "edits": "[{\"oldText\":\"a\",\"newText\":\"b\"}]"
491 })));
492 assert_eq!(
493 prepared.get("edits"),
494 Some(&json!([{"oldText":"a","newText":"b"}]))
495 );
496 }
497
498 #[test]
499 fn empty_edits_rejected() -> Result<(), Box<dyn std::error::Error>> {
500 let err = EditTool::parse_input(&json_map(&json!({
501 "path": "f.txt",
502 "edits": []
503 })))
504 .err()
505 .ok_or("empty edits were accepted")?;
506 assert!(
507 err.message()
508 .contains("edits must contain at least one replacement")
509 );
510 Ok(())
511 }
512
513 #[tokio::test]
514 async fn exact_single_and_multi_edits() -> Result<(), Box<dyn std::error::Error>> {
515 let dir = tempdir()?;
516 let path = dir.path().join("edit-test.txt");
517 tokio::fs::write(&path, "Hello, world!").await?;
518 let tool = EditTool::new(dir.path());
519
520 let result = tool
521 .execute(
522 "1",
523 json_map(&json!({
524 "path": "edit-test.txt",
525 "edits": [{"oldText": "world", "newText": "testing"}]
526 })),
527 CancellationToken::new(),
528 ToolUpdates::noop(),
529 )
530 .await?;
531 assert_eq!(
532 text_of(&result),
533 "Successfully replaced 1 block(s) in edit-test.txt."
534 );
535 assert_eq!(tokio::fs::read_to_string(&path).await?, "Hello, testing!");
536 assert!(result.details.get("diff").is_some());
537 assert!(result.details.get("patch").is_some());
538
539 tokio::fs::write(&path, "alpha\nbeta\ngamma\ndelta\n").await?;
540 let result = tool
541 .execute(
542 "2",
543 json_map(&json!({
544 "path": "edit-test.txt",
545 "edits": [
546 {"oldText": "alpha\n", "newText": "ALPHA\n"},
547 {"oldText": "gamma\n", "newText": "GAMMA\n"}
548 ]
549 })),
550 CancellationToken::new(),
551 ToolUpdates::noop(),
552 )
553 .await?;
554 assert_eq!(
555 text_of(&result),
556 "Successfully replaced 2 block(s) in edit-test.txt."
557 );
558 assert_eq!(
559 tokio::fs::read_to_string(&path).await?,
560 "ALPHA\nbeta\nGAMMA\ndelta\n"
561 );
562 Ok(())
563 }
564
565 #[tokio::test]
566 async fn occurrence_and_overlap_errors() -> Result<(), Box<dyn std::error::Error>> {
567 let dir = tempdir()?;
568 let path = dir.path().join("dups.txt");
569 tokio::fs::write(&path, "foo foo foo").await?;
570 let tool = EditTool::new(dir.path());
571 let err = tool
572 .execute(
573 "1",
574 json_map(&json!({
575 "path": "dups.txt",
576 "edits": [{"oldText": "foo", "newText": "bar"}]
577 })),
578 CancellationToken::new(),
579 ToolUpdates::noop(),
580 )
581 .await
582 .err()
583 .ok_or("duplicate edit was accepted")?;
584 assert!(err.message().contains("Found 3 occurrences"));
585
586 tokio::fs::write(&path, "one\ntwo\nthree\n").await?;
587 let err = tool
588 .execute(
589 "2",
590 json_map(&json!({
591 "path": "dups.txt",
592 "edits": [
593 {"oldText": "one\ntwo\n", "newText": "ONE\nTWO\n"},
594 {"oldText": "two\nthree\n", "newText": "TWO\nTHREE\n"}
595 ]
596 })),
597 CancellationToken::new(),
598 ToolUpdates::noop(),
599 )
600 .await
601 .err()
602 .ok_or("overlapping edits were accepted")?;
603 assert!(err.message().contains("overlap"));
604 Ok(())
605 }
606
607 #[tokio::test]
608 async fn reverse_apply_original_coordinates() -> Result<(), Box<dyn std::error::Error>> {
609 let dir = tempdir()?;
610 let path = dir.path().join("orig.txt");
611 tokio::fs::write(&path, "foo\nbar\nbaz\n").await?;
612 let tool = EditTool::new(dir.path());
613 tool.execute(
614 "1",
615 json_map(&json!({
616 "path": "orig.txt",
617 "edits": [
618 {"oldText": "foo\n", "newText": "foo bar\n"},
619 {"oldText": "bar\n", "newText": "BAR\n"}
620 ]
621 })),
622 CancellationToken::new(),
623 ToolUpdates::noop(),
624 )
625 .await?;
626 assert_eq!(
627 tokio::fs::read_to_string(&path).await?,
628 "foo bar\nBAR\nbaz\n"
629 );
630 Ok(())
631 }
632
633 #[tokio::test]
634 async fn bom_and_crlf_preserved() -> Result<(), Box<dyn std::error::Error>> {
635 let dir = tempdir()?;
636 let path = dir.path().join("bom.txt");
637 tokio::fs::write(&path, "\u{FEFF}first\r\nsecond\r\nthird\r\n").await?;
638 let tool = EditTool::new(dir.path());
639 tool.execute(
640 "1",
641 json_map(&json!({
642 "path": "bom.txt",
643 "edits": [{"oldText": "second\n", "newText": "REPLACED\n"}]
644 })),
645 CancellationToken::new(),
646 ToolUpdates::noop(),
647 )
648 .await?;
649 assert_eq!(
650 tokio::fs::read_to_string(&path).await?,
651 "\u{FEFF}first\r\nREPLACED\r\nthird\r\n"
652 );
653 Ok(())
654 }
655
656 #[tokio::test]
657 async fn fuzzy_punctuation_and_space() -> Result<(), Box<dyn std::error::Error>> {
658 let dir = tempdir()?;
659 let path = dir.path().join("fuzzy.txt");
660 tokio::fs::write(
661 &path,
662 "console.log(\u{2018}hello\u{2019});\nhello\u{00A0}world\n",
663 )
664 .await?;
665 let tool = EditTool::new(dir.path());
666 tool.execute(
667 "1",
668 json_map(&json!({
669 "path": "fuzzy.txt",
670 "edits": [
671 {"oldText": "console.log('hello');\n", "newText": "console.log('world');\n"},
672 {"oldText": "hello world\n", "newText": "hello universe\n"}
673 ]
674 })),
675 CancellationToken::new(),
676 ToolUpdates::noop(),
677 )
678 .await?;
679 assert_eq!(
680 tokio::fs::read_to_string(&path).await?,
681 "console.log('world');\nhello universe\n"
682 );
683 Ok(())
684 }
685
686 #[tokio::test]
687 async fn fuzzy_preserves_untouched_trailing_whitespace()
688 -> Result<(), Box<dyn std::error::Error>> {
689 let dir = tempdir()?;
690 let path = dir.path().join("ws.txt");
691 let original = ["keep before ", "target line ", "keep after ", ""].join("\n");
693 tokio::fs::write(&path, &original).await?;
694 let tool = EditTool::new(dir.path());
695 tool.execute(
696 "1",
697 json_map(&json!({
698 "path": "ws.txt",
699 "edits": [{"oldText": "target line\n", "newText": "changed\n"}]
700 })),
701 CancellationToken::new(),
702 ToolUpdates::noop(),
703 )
704 .await?;
705 let expected = ["keep before ", "changed", "keep after ", ""].join("\n");
706 assert_eq!(tokio::fs::read_to_string(&path).await?, expected);
707 Ok(())
708 }
709
710 #[tokio::test]
711 async fn unchanged_no_op_rejected() -> Result<(), Box<dyn std::error::Error>> {
712 let dir = tempdir()?;
713 let path = dir.path().join("same.txt");
714 tokio::fs::write(&path, "same").await?;
715 let tool = EditTool::new(dir.path());
716 let err = tool
717 .execute(
718 "1",
719 json_map(&json!({
720 "path": "same.txt",
721 "edits": [{"oldText": "same", "newText": "same"}]
722 })),
723 CancellationToken::new(),
724 ToolUpdates::noop(),
725 )
726 .await
727 .err()
728 .ok_or("unchanged edit was accepted")?;
729 assert!(err.message().contains("No changes made"));
730 Ok(())
731 }
732
733 #[tokio::test]
734 async fn cancellation_before_work_aborts() -> Result<(), Box<dyn std::error::Error>> {
735 let dir = tempdir()?;
736 let tool = EditTool::new(dir.path());
737 let cancel = CancellationToken::new();
738 cancel.cancel();
739 let err = tool
740 .execute(
741 "1",
742 json_map(&json!({
743 "path": "a.txt",
744 "edits": [{"oldText": "a", "newText": "b"}]
745 })),
746 cancel,
747 ToolUpdates::noop(),
748 )
749 .await
750 .err()
751 .ok_or("cancelled edit succeeded")?;
752 assert_eq!(err.message(), "Operation aborted");
753 Ok(())
754 }
755
756 #[tokio::test]
757 async fn cancellation_before_edit_commit_aborts_without_mutating()
758 -> Result<(), Box<dyn std::error::Error>> {
759 let dir = tempdir()?;
760 let path = dir.path().join("pre-commit.txt");
761 tokio::fs::write(&path, "before").await?;
762 let cancel = CancellationToken::new();
763 let cancel_at_boundary = cancel.clone();
764 let edits = [Edit {
765 old_text: "before".to_owned(),
766 new_text: "after".to_owned(),
767 }];
768
769 let result = apply_edit_mutation_with_commit_hooks(
770 &path,
771 "pre-commit.txt",
772 &edits,
773 &cancel,
774 move || cancel_at_boundary.cancel(),
775 || {},
776 )
777 .await;
778 let Err(error) = result else {
779 return Err("pre-commit cancellation unexpectedly succeeded".into());
780 };
781
782 assert_eq!(error.message(), "Operation aborted");
783 assert_eq!(tokio::fs::read_to_string(&path).await?, "before");
784 Ok(())
785 }
786
787 #[tokio::test]
788 async fn cancellation_after_edit_commit_reports_success()
789 -> Result<(), Box<dyn std::error::Error>> {
790 let dir = tempdir()?;
791 let path = dir.path().join("post-commit.txt");
792 tokio::fs::write(&path, "before").await?;
793 let cancel = CancellationToken::new();
794 let cancel_at_boundary = cancel.clone();
795 let edits = [Edit {
796 old_text: "before".to_owned(),
797 new_text: "after".to_owned(),
798 }];
799
800 let result = apply_edit_mutation_with_commit_hooks(
801 &path,
802 "post-commit.txt",
803 &edits,
804 &cancel,
805 || {},
806 move || cancel_at_boundary.cancel(),
807 )
808 .await?;
809
810 assert!(cancel.is_cancelled());
811 assert_eq!(
812 text_of(&result),
813 "Successfully replaced 1 block(s) in post-commit.txt."
814 );
815 assert_eq!(tokio::fs::read_to_string(&path).await?, "after");
816 Ok(())
817 }
818
819 #[tokio::test]
820 async fn missing_file_enoent() -> Result<(), Box<dyn std::error::Error>> {
821 let dir = tempdir()?;
822 let tool = EditTool::new(dir.path());
823 let err = tool
824 .execute(
825 "1",
826 json_map(&json!({
827 "path": "missing.txt",
828 "edits": [{"oldText": "a", "newText": "b"}]
829 })),
830 CancellationToken::new(),
831 ToolUpdates::noop(),
832 )
833 .await
834 .err()
835 .ok_or("missing file edit succeeded")?;
836 assert!(err.message().contains("Error code: ENOENT"));
837 Ok(())
838 }
839
840 #[tokio::test]
841 async fn readonly_eacces() -> Result<(), Box<dyn std::error::Error>> {
842 let dir = tempdir()?;
843 let path = dir.path().join("ro.txt");
844 tokio::fs::write(&path, "hello\n").await?;
845 let mut perms = tokio::fs::metadata(&path).await?.permissions();
846 perms.set_mode(0o444);
847 tokio::fs::set_permissions(&path, perms).await?;
848 let tool = EditTool::new(dir.path());
849 let err = tool
850 .execute(
851 "1",
852 json_map(&json!({
853 "path": "ro.txt",
854 "edits": [{"oldText": "hello", "newText": "world"}]
855 })),
856 CancellationToken::new(),
857 ToolUpdates::noop(),
858 )
859 .await
860 .err()
861 .ok_or("readonly file edit succeeded")?;
862 assert!(err.message().contains("Error code: EACCES"));
863 let mut perms = tokio::fs::metadata(&path).await?.permissions();
865 perms.set_mode(0o644);
866 tokio::fs::set_permissions(&path, perms).await?;
867 Ok(())
868 }
869
870 #[tokio::test]
871 async fn mutation_queue_serializes_same_path() -> Result<(), Box<dyn std::error::Error>> {
872 let dir = tempdir()?;
873 let path = dir.path().join("serial.txt");
874 tokio::fs::write(&path, "alpha\nbeta\n").await?;
875 let tool = Arc::new(EditTool::new(dir.path()));
876 let barrier = Arc::new(Barrier::new(2));
877
878 let t1 = {
879 let tool = tool.clone();
880 let barrier = barrier.clone();
881 tokio::spawn(async move {
882 barrier.wait().await;
883 tool.execute(
884 "1",
885 json_map(&json!({
886 "path": "serial.txt",
887 "edits": [{"oldText": "alpha", "newText": "ALPHA"}]
888 })),
889 CancellationToken::new(),
890 ToolUpdates::noop(),
891 )
892 .await
893 })
894 };
895 let t2 = {
896 let tool = tool.clone();
897 let barrier = barrier.clone();
898 tokio::spawn(async move {
899 barrier.wait().await;
900 tokio::time::sleep(Duration::from_millis(5)).await;
902 tool.execute(
903 "2",
904 json_map(&json!({
905 "path": "serial.txt",
906 "edits": [{"oldText": "beta", "newText": "BETA"}]
907 })),
908 CancellationToken::new(),
909 ToolUpdates::noop(),
910 )
911 .await
912 })
913 };
914 t1.await??;
915 t2.await??;
916 assert_eq!(tokio::fs::read_to_string(&path).await?, "ALPHA\nBETA\n");
917 Ok(())
918 }
919}