Skip to main content

sparrow/tools/
edit.rs

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}