Skip to main content

pi/core/tools/
edit.rs

1//! Edit tool: exact multi-replacement file edits under the mutation queue.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/tools/edit.ts`.
4//! Argument preparation folds legacy top-level `oldText`/`newText` and
5//! stringified `edits` JSON. Execution serializes per-file mutations, preserves
6//! BOM/CRLF, and applies original-coordinate non-overlapping replacements.
7
8use 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/// One replacement entry in the public edit schema.
27#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ReplaceEditInput {
30    /// Exact text for one targeted replacement.
31    pub old_text: String,
32    /// Replacement text for this targeted edit.
33    pub new_text: String,
34}
35
36/// TypeBox-compatible edit arguments (fixture `edit.json`).
37#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
38pub struct EditToolInput {
39    /// Path to the file to edit (relative or absolute).
40    pub path: String,
41    /// One or more targeted replacements.
42    pub edits: Vec<ReplaceEditInput>,
43}
44
45/// Structured details returned by a successful edit.
46#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct EditToolDetails {
49    /// Display-oriented numbered diff.
50    pub diff: String,
51    /// Standard unified patch.
52    pub patch: String,
53    /// First changed line in the new file (1-based), when present.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub first_changed_line: Option<usize>,
56}
57
58/// Options for [`EditTool`].
59#[derive(Clone, Debug)]
60pub struct EditToolOptions {
61    /// Working directory used to resolve relative paths.
62    pub cwd: PathBuf,
63}
64
65impl EditToolOptions {
66    /// Builds options for `cwd`.
67    #[must_use]
68    pub fn new(cwd: impl Into<PathBuf>) -> Self {
69        Self { cwd: cwd.into() }
70    }
71}
72
73/// Agent tool that applies unique non-overlapping text replacements.
74#[derive(Clone, Debug)]
75pub struct EditTool {
76    cwd: PathBuf,
77    parameters: Value,
78    description: String,
79}
80
81impl EditTool {
82    /// Creates an edit tool rooted at `cwd`.
83    #[must_use]
84    pub fn new(cwd: impl Into<PathBuf>) -> Self {
85        Self::with_options(EditToolOptions::new(cwd))
86    }
87
88    /// Creates an edit tool from explicit options.
89    #[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    /// Returns the JSON Schema for edit arguments (normalized `TypeBox` shape).
99    #[must_use]
100    pub fn parameters_schema() -> Value {
101        edit_parameters_schema()
102    }
103
104    /// Compatibility shim for legacy top-level oldText/newText and stringified edits.
105    #[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    /// Validates prepared arguments and requires a non-empty edits list.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`ToolError`] when required fields are missing, mistyped, or
140    /// `edits` is empty.
141    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    /// Formats the success text using the caller's path string.
153    #[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    // access R_OK | W_OK
271    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    // Also need readable: try open
280    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    // A successful write is the durable commit point. Cancellation observed
336    // after it must not turn the committed edit into a reported failure.
337    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            // Prefer errno-style names when available via Display of ErrorKind is not ENOENT.
392            // Use io::Error::to_string and map common kinds.
393            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                    // Fall back to full error string for unknown codes.
400                    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/// Builds an [`Arc<dyn AgentTool>`] edit tool for `cwd`.
411#[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        // untouched line keeps trailing spaces
692        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        // restore for tempdir cleanup
864        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                // slight delay so both race into queue
901                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}