1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10use similar::TextDiff;
11
12use crate::tools::write::is_hard_blocked;
13use crate::tools::ToolCtx;
14
15pub struct EditTool {
16 ctx: Arc<ToolCtx>,
17}
18
19impl EditTool {
20 pub fn new(ctx: Arc<ToolCtx>) -> Self {
21 Self { ctx }
22 }
23}
24
25impl Tool for EditTool {
26 fn def(&self) -> ToolDef {
27 ToolDef {
28 name: "edit".into(),
29 description: "Exact-string replacement in a file. Use `replace_all=true` to replace every occurrence.".into(),
30 input_schema: json!({
31 "type": "object",
32 "properties": {
33 "path": { "type": "string" },
34 "old_string": { "type": "string" },
35 "new_string": { "type": "string" },
36 "replace_all": { "type": "boolean", "default": false }
37 },
38 "required": ["path", "old_string", "new_string"]
39 }),
40 }
41 }
42
43 fn call(
44 &self,
45 args: Value,
46 _ctx: &ToolContext,
47 ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
48 let ctx = Arc::clone(&self.ctx);
49 Box::pin(async move {
50 let path = match args.get("path").and_then(|v| v.as_str()) {
51 Some(path) => PathBuf::from(path),
52 None => return ToolResult::error("missing 'path'"),
53 };
54 let old = match args.get("old_string").and_then(|value| value.as_str()) {
55 Some(old) => old,
56 None => return ToolResult::error("missing 'old_string'"),
57 };
58 let new = match args.get("new_string").and_then(|value| value.as_str()) {
59 Some(new) => new,
60 None => return ToolResult::error("missing 'new_string'"),
61 };
62 let replace_all = args
63 .get("replace_all")
64 .and_then(|value| value.as_bool())
65 .unwrap_or(false);
66
67 if old == new {
68 return ToolResult::error("old_string and new_string are identical");
69 }
70 let abs = if path.is_absolute() {
71 path
72 } else {
73 ctx.cwd.join(&path)
74 };
75 if is_hard_blocked(&abs) {
76 return ToolResult::error(format!("edit blocked: {} is protected", abs.display()));
77 }
78
79 let canonical = tokio::fs::canonicalize(&abs)
80 .await
81 .unwrap_or_else(|_| abs.clone());
82 if !ctx.has_been_read(&canonical).await && !ctx.has_been_read(&abs).await {
83 return ToolResult::error(format!(
84 "refusing to edit {} without reading it first",
85 abs.display()
86 ));
87 }
88
89 let original = match tokio::fs::read_to_string(&abs).await {
90 Ok(text) => text,
91 Err(err) => return ToolResult::error(format!("read failed: {err}")),
92 };
93
94 if !original.contains(old) {
95 return ToolResult::error("old_string not found in file");
96 }
97
98 let count = original.matches(old).count();
99 if !replace_all && count > 1 {
100 return ToolResult::error(format!(
101 "old_string matches {count} occurrences; pass replace_all=true or add more context"
102 ));
103 }
104
105 let replaced = if replace_all {
106 original.replace(old, new)
107 } else {
108 original.replacen(old, new, 1)
109 };
110
111 if let Err(err) = tokio::fs::write(&abs, &replaced).await {
112 return ToolResult::error(format!("write failed: {err}"));
113 }
114 ctx.mark_read(&canonical).await;
115
116 let diff = TextDiff::from_lines(&original, &replaced);
117 let udiff = diff
118 .unified_diff()
119 .context_radius(3)
120 .header(&abs.display().to_string(), &abs.display().to_string())
121 .to_string();
122
123 ToolResult::text(format!(
124 "{{\"path\":\"{}\",\"replacements\":{},\"diff\":{}}}",
125 abs.display(),
126 if replace_all { count } else { 1 },
127 serde_json::to_string(&udiff).unwrap_or_else(|_| "\"<diff render error>\"".into())
128 ))
129 })
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::permissions::NoOpPermissionGate;
137 use std::path::Path;
138 use tempfile::tempdir;
139 use tokio::sync::mpsc;
140
141 fn test_ctx(cwd: &Path) -> Arc<ToolCtx> {
142 let (tx, _rx) = mpsc::channel(8);
143 Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
144 }
145
146 #[tokio::test]
147 async fn replaces_single_occurrence_when_unique() {
148 let dir = tempdir().unwrap();
149 let file = dir.path().join("code.rs");
150 tokio::fs::write(&file, "fn main() { println!(\"old\"); }")
151 .await
152 .unwrap();
153 let ctx = test_ctx(dir.path());
154 let canonical = tokio::fs::canonicalize(&file).await.unwrap();
155 ctx.read_files.lock().await.insert(canonical);
156 let tool = EditTool::new(ctx);
157 let result = tool
158 .call(
159 json!({ "path": "code.rs", "old_string": "old", "new_string": "new" }),
160 &ToolContext::default(),
161 )
162 .await;
163
164 assert!(!result.is_error, "{result:?}");
165 let debug = format!("{result:?}");
166 assert!(debug.contains("@@"), "{debug}");
167 let body = tokio::fs::read_to_string(&file).await.unwrap();
168 assert!(body.contains("new"), "{body}");
169 }
170
171 #[tokio::test]
172 async fn rejects_ambiguous_old_string_without_replace_all() {
173 let dir = tempdir().unwrap();
174 let file = dir.path().join("code.rs");
175 tokio::fs::write(&file, "x\nx\n").await.unwrap();
176 let ctx = test_ctx(dir.path());
177 ctx.read_files
178 .lock()
179 .await
180 .insert(tokio::fs::canonicalize(&file).await.unwrap());
181 let tool = EditTool::new(ctx);
182 let result = tool
183 .call(
184 json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
185 &ToolContext::default(),
186 )
187 .await;
188 assert!(
189 format!("{result:?}").contains("2 occurrences"),
190 "{result:?}"
191 );
192 }
193
194 #[tokio::test]
195 async fn replace_all_true_replaces_every_match() {
196 let dir = tempdir().unwrap();
197 let file = dir.path().join("code.rs");
198 tokio::fs::write(&file, "x\nx\n").await.unwrap();
199 let ctx = test_ctx(dir.path());
200 ctx.read_files
201 .lock()
202 .await
203 .insert(tokio::fs::canonicalize(&file).await.unwrap());
204 let tool = EditTool::new(ctx);
205 let result = tool
206 .call(
207 json!({ "path": "code.rs", "old_string": "x", "new_string": "y", "replace_all": true }),
208 &ToolContext::default(),
209 )
210 .await;
211
212 assert!(!result.is_error, "{result:?}");
213 let body = tokio::fs::read_to_string(&file).await.unwrap();
214 assert_eq!(body, "y\ny\n");
215 }
216
217 #[tokio::test]
218 async fn rejects_identical_strings() {
219 let dir = tempdir().unwrap();
220 let file = dir.path().join("code.rs");
221 tokio::fs::write(&file, "x").await.unwrap();
222 let ctx = test_ctx(dir.path());
223 ctx.read_files
224 .lock()
225 .await
226 .insert(tokio::fs::canonicalize(&file).await.unwrap());
227 let tool = EditTool::new(ctx);
228 let result = tool
229 .call(
230 json!({ "path": "code.rs", "old_string": "x", "new_string": "x" }),
231 &ToolContext::default(),
232 )
233 .await;
234 assert!(format!("{result:?}").contains("identical"), "{result:?}");
235 }
236
237 #[tokio::test]
238 async fn rejects_missing_old_string() {
239 let dir = tempdir().unwrap();
240 let tool = EditTool::new(test_ctx(dir.path()));
241 let result = tool
242 .call(
243 json!({ "path": "code.rs", "new_string": "y" }),
244 &ToolContext::default(),
245 )
246 .await;
247 assert!(
248 format!("{result:?}").contains("missing 'old_string'"),
249 "{result:?}"
250 );
251 }
252
253 #[tokio::test]
254 async fn rejects_edit_without_prior_read() {
255 let dir = tempdir().unwrap();
256 let file = dir.path().join("code.rs");
257 tokio::fs::write(&file, "x").await.unwrap();
258 let tool = EditTool::new(test_ctx(dir.path()));
259 let result = tool
260 .call(
261 json!({ "path": "code.rs", "old_string": "x", "new_string": "y" }),
262 &ToolContext::default(),
263 )
264 .await;
265 let debug = format!("{result:?}");
266 assert!(debug.contains("without reading"), "{debug}");
267 }
268}