1use futures_util::future::BoxFuture;
5use serde_json::{Map, Value, json};
6use tokio_util::sync::CancellationToken;
7use yapi_agent::{Tool, UpdateSink};
8use yapi_types::event::ToolResult;
9use yapi_types::message::ToolDeclaration;
10
11use super::edit_diff::{
12 Replacement, apply_edits, detect_line_ending, display_diff, normalize_to_lf,
13 restore_line_endings, unified_patch,
14};
15use super::mutation::with_file_lock;
16use super::{ToolEnv, check_abort, declaration, error_code, string_arg, text_result};
17
18pub struct Edit {
20 env: ToolEnv,
21 declaration: ToolDeclaration,
22}
23
24impl Edit {
25 pub fn new(env: ToolEnv) -> Edit {
27 Edit {
28 env,
29 declaration: declaration(
30 "edit",
31 "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.".into(),
32 json!({"type":"object","required":["path","edits"],"properties":{
33 "path":{"type":"string","description":"Path to the file to edit (relative or absolute)"},
34 "edits":{"type":"array","items":{"type":"object","required":["oldText","newText"],"properties":{
35 "oldText":{"type":"string","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."},
36 "newText":{"type":"string","description":"Replacement text for this targeted edit."}}},
37 "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."}}}),
38 ),
39 }
40 }
41}
42
43fn is_single_edit(value: &Value) -> bool {
44 value["oldText"].is_string() && value["newText"].is_string()
45}
46
47impl Tool for Edit {
48 fn declaration(&self) -> &ToolDeclaration {
49 &self.declaration
50 }
51
52 fn prepare_arguments(&self, mut args: Map<String, Value>) -> Map<String, Value> {
55 match args.get("edits") {
56 Some(Value::String(text)) => match serde_json::from_str::<Value>(text) {
57 Ok(parsed @ Value::Array(_)) => {
58 args.insert("edits".into(), parsed);
59 }
60 Ok(parsed) if is_single_edit(&parsed) => {
61 args.insert("edits".into(), Value::Array(vec![parsed]));
62 }
63 _ => {}
64 },
65 Some(single) if is_single_edit(single) => {
66 let single = single.clone();
67 args.insert("edits".into(), Value::Array(vec![single]));
68 }
69 _ => {}
70 }
71 if let (Some(Value::String(old)), Some(Value::String(new))) =
72 (args.get("oldText").cloned(), args.get("newText").cloned())
73 {
74 let mut edits = match args.get("edits") {
75 Some(Value::Array(edits)) => edits.clone(),
76 _ => Vec::new(),
77 };
78 edits.push(json!({"oldText": old, "newText": new}));
79 args.shift_remove("oldText");
80 args.shift_remove("newText");
81 args.insert("edits".into(), Value::Array(edits));
82 }
83 args
84 }
85
86 fn execute(
87 &self,
88 _call_id: String,
89 args: Value,
90 cancel: CancellationToken,
91 _updates: UpdateSink,
92 ) -> BoxFuture<'_, Result<ToolResult, String>> {
93 Box::pin(async move {
94 let edits = args["edits"]
95 .as_array()
96 .into_iter()
97 .flatten()
98 .map(|edit| {
99 Ok(Replacement {
100 old_text: string_arg(edit, "oldText")?.to_owned(),
101 new_text: string_arg(edit, "newText")?.to_owned(),
102 })
103 })
104 .collect::<Result<Vec<_>, String>>()?;
105 if edits.is_empty() {
106 return Err(
107 "Edit tool input is invalid. edits must contain at least one replacement."
108 .into(),
109 );
110 }
111 let path = string_arg(&args, "path")?.to_owned();
112 let absolute = super::path::resolve_to_cwd(&path, &self.env.cwd);
113 with_file_lock(&absolute, async {
114 check_abort(&cancel)?;
115 let metadata = tokio::fs::metadata(&absolute).await;
116 let writable = metadata
117 .as_ref()
118 .map(|metadata| !metadata.permissions().readonly());
119 match writable {
120 Ok(true) => {}
121 Ok(false) => {
122 return Err(format!("Could not edit file: {path}. Error code: EACCES."));
123 }
124 Err(err) => {
125 check_abort(&cancel)?;
126 return Err(format!("Could not edit file: {path}. {}.", error_code(err)));
127 }
128 }
129 let raw = tokio::fs::read(&absolute)
130 .await
131 .map_err(|err| format!("Could not edit file: {path}. {}.", error_code(&err)))?;
132 check_abort(&cancel)?;
133 let Ok(raw) = String::from_utf8(raw) else {
135 return Err(format!(
136 "Could not edit file: {path}. File is not valid UTF-8."
137 ));
138 };
139 let (bom, content) = match raw.strip_prefix('\u{FEFF}') {
140 Some(rest) => ("\u{FEFF}", rest),
141 None => ("", raw.as_str()),
142 };
143 let ending = detect_line_ending(content);
144 let normalized = normalize_to_lf(content);
145 let new_content = apply_edits(&normalized, &edits, &path)?;
146 check_abort(&cancel)?;
147 let restored = restore_line_endings(content, &normalized, &new_content, ending);
148 let output = format!("{bom}{restored}");
149 tokio::fs::write(&absolute, output)
150 .await
151 .map_err(|err| format!("Could not edit file: {path}. {}.", error_code(&err)))?;
152 check_abort(&cancel)?;
153 let (diff, first_changed) = display_diff(&normalized, &new_content);
154 let patch = unified_patch(&path, &normalized, &new_content);
155 let mut details = json!({"diff": diff, "patch": patch});
156 if let Some(line) = first_changed {
157 details["firstChangedLine"] = json!(line);
158 }
159 Ok(text_result(
160 format!("Successfully replaced {} block(s) in {path}.", edits.len()),
161 Some(details),
162 ))
163 })
164 .await
165 })
166 }
167}