Skip to main content

capo_agent/tools/
edit.rs

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}