1use async_trait::async_trait;
2use serde_json::json;
3use std::fs;
4
5use super::{Tool, ToolCtx, ToolResult, resolve_workspace_path};
6use crate::event::{Block, RiskLevel};
7
8pub struct Edit;
9
10#[async_trait]
11impl Tool for Edit {
12 fn name(&self) -> &str {
13 "edit"
14 }
15 fn description(&self) -> &str {
16 "Edit a file by exact string replacement"
17 }
18 fn schema(&self) -> serde_json::Value {
19 json!({
20 "type": "object",
21 "properties": {
22 "path": { "type": "string", "description": "Relative file path" },
23 "old": { "type": "string", "description": "Exact text to replace" },
24 "new": { "type": "string", "description": "Replacement text" },
25 "replace_all": { "type": "boolean", "description": "Replace all occurrences (default false)" }
26 },
27 "required": ["path", "old", "new"]
28 })
29 }
30 fn risk(&self) -> RiskLevel {
31 RiskLevel::Mutating
32 }
33 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
34 let path = args["path"].as_str().unwrap_or("");
35 let old = args["old"].as_str().unwrap_or("");
36 let new = args["new"].as_str().unwrap_or("");
37 let replace_all = args["replace_all"].as_bool().unwrap_or(false);
38 let full_path = resolve_workspace_path(&ctx.workspace_root, path)?;
39
40 let content = fs::read_to_string(&full_path)?;
41
42 if old.is_empty() {
43 return Ok(ToolResult::error("old string cannot be empty"));
44 }
45
46 let count = content.matches(old).count();
47 if count == 0 {
48 return Ok(ToolResult::error(format!(
49 "Not found in {}: '{}'",
50 path, old
51 )));
52 }
53 if count > 1 && !replace_all {
54 return Ok(ToolResult::error(format!(
55 "Found {} matches in {}. Use replace_all: true or add more context to 'old'.",
56 count, path
57 )));
58 }
59
60 let new_content = if replace_all {
61 content.replace(old, new)
62 } else {
63 content.replacen(old, new, 1)
64 };
65
66 let old_lines = old.lines().count() as u32;
67 let new_lines = new.lines().count() as u32;
68
69 fs::write(&full_path, &new_content)?;
70
71 Ok(ToolResult::ok(vec![
72 Block::Text(format!(
73 "Edited {}: replaced {} occurrence(s)",
74 path,
75 if replace_all { count } else { 1 }
76 )),
77 Block::Diff {
78 file: path.to_string(),
79 patch: format!(
80 "@@ -1,{} +1,{} @@\n-{}\n+{}",
81 old_lines, new_lines, old, new
82 ),
83 },
84 ]))
85 }
86}
87
88pub struct MultiEdit;
89
90#[async_trait]
91impl Tool for MultiEdit {
92 fn name(&self) -> &str {
93 "multi_edit"
94 }
95 fn description(&self) -> &str {
96 "Apply multiple edits to a file in one operation"
97 }
98 fn schema(&self) -> serde_json::Value {
99 json!({
100 "type": "object",
101 "properties": {
102 "path": { "type": "string" },
103 "edits": {
104 "type": "array",
105 "items": {
106 "type": "object",
107 "properties": {
108 "old": { "type": "string" },
109 "new": { "type": "string" }
110 },
111 "required": ["old", "new"]
112 }
113 }
114 },
115 "required": ["path", "edits"]
116 })
117 }
118 fn risk(&self) -> RiskLevel {
119 RiskLevel::Mutating
120 }
121 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
122 let path = args["path"].as_str().unwrap_or("");
123 let full_path = resolve_workspace_path(&ctx.workspace_root, path)?;
124 let mut content = fs::read_to_string(&full_path)?;
125
126 let edits = args["edits"]
127 .as_array()
128 .ok_or_else(|| anyhow::anyhow!("edits must be an array"))?;
129
130 let mut total_replacements = 0;
131 for edit in edits {
132 let old = edit["old"].as_str().unwrap_or("");
133 let new = edit["new"].as_str().unwrap_or("");
134 if old.is_empty() {
135 continue;
136 }
137 let count = content.matches(old).count();
138 if count == 1 {
139 content = content.replace(old, new);
140 total_replacements += 1;
141 } else if count > 1 {
142 return Ok(ToolResult::error(format!(
143 "Found {} matches for '{}'. Each edit must match exactly once.",
144 count,
145 if old.len() > 50 {
146 format!("{}...", &old[..50])
147 } else {
148 old.to_string()
149 }
150 )));
151 }
152 }
153
154 fs::write(&full_path, &content)?;
155 Ok(ToolResult::text(format!(
156 "Applied {} edits to {}",
157 total_replacements, path
158 )))
159 }
160}