Skip to main content

synaps_cli/tools/
edit.rs

1use serde_json::{json, Value};
2use crate::{Result, RuntimeError};
3use super::{Tool, ToolContext, expand_path};
4
5pub struct EditTool;
6
7#[async_trait::async_trait]
8impl Tool for EditTool {
9    fn name(&self) -> &str { "edit" }
10
11    fn description(&self) -> &str {
12        "Make a surgical edit to a file by replacing an exact string match. The old_string must appear exactly once in the file. Provide enough surrounding context to make the match unique."
13    }
14
15    fn parameters(&self) -> Value {
16        json!({
17            "type": "object",
18            "properties": {
19                "path": {
20                    "type": "string",
21                    "description": "Path to the file to edit"
22                },
23                "old_string": {
24                    "type": "string",
25                    "description": "The exact text to find and replace. Must match exactly once in the file."
26                },
27                "new_string": {
28                    "type": "string",
29                    "description": "The replacement text"
30                }
31            },
32            "required": ["path", "old_string", "new_string"]
33        })
34    }
35
36    async fn execute(&self, params: Value, _ctx: ToolContext) -> Result<String> {
37        let raw_path = params["path"].as_str()
38            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
39        let old_string = params["old_string"].as_str()
40            .ok_or_else(|| RuntimeError::Tool("Missing old_string parameter".to_string()))?;
41        let new_string = params["new_string"].as_str()
42            .ok_or_else(|| RuntimeError::Tool("Missing new_string parameter".to_string()))?;
43
44        let path = expand_path(raw_path);
45
46        let content = tokio::fs::read_to_string(&path).await
47            .map_err(|e| RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e)))?;
48
49        let count = content.matches(old_string).count();
50
51        if count == 0 {
52            return Err(RuntimeError::Tool(format!(
53                "old_string not found in '{}'. Make sure it matches exactly, including whitespace and indentation.",
54                path.display()
55            )));
56        }
57
58        if count > 1 {
59            return Err(RuntimeError::Tool(format!(
60                "old_string found {} times in '{}'. It must be unique — include more surrounding context.",
61                count, path.display()
62            )));
63        }
64
65        let new_content = content.replacen(old_string, new_string, 1);
66
67        // Preserve original file permissions (executable bits, etc.)
68        let original_perms = tokio::fs::metadata(&path).await
69            .map(|m| m.permissions())
70            .ok();
71
72        let tmp_path = path.with_extension("agent-tmp");
73        tokio::fs::write(&tmp_path, &new_content).await
74            .map_err(|e| RuntimeError::Tool(format!("Failed to write file: {}", e)))?;
75
76        // Restore original permissions on the temp file before rename
77        if let Some(perms) = original_perms {
78            let _ = tokio::fs::set_permissions(&tmp_path, perms).await;
79        }
80
81        tokio::fs::rename(&tmp_path, &path).await
82            .map_err(|e| {
83                let tmp = tmp_path.clone();
84                tokio::spawn(async move { let _ = tokio::fs::remove_file(tmp).await; });
85                RuntimeError::Tool(format!("Failed to finalize edit: {}", e))
86            })?;
87
88        let old_lines: Vec<&str> = old_string.lines().collect();
89        let new_lines: Vec<&str> = new_string.lines().collect();
90        Ok(format!(
91            "Edited {} — replaced {} line(s) with {} line(s)",
92            path.display(), old_lines.len(), new_lines.len()
93        ))
94    }
95}
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use super::super::test_helpers::create_tool_context;
100    use crate::tools::Tool;
101    use serde_json::json;
102
103    #[test]
104    fn test_edit_tool_schema() {
105        let tool = EditTool;
106        assert_eq!(tool.name(), "edit");
107        assert!(!tool.description().is_empty());
108
109        let params = tool.parameters();
110        assert_eq!(params["type"], "object");
111        assert!(params["properties"].is_object());
112        assert!(params["required"].is_array());
113    }
114
115    #[tokio::test]
116    async fn test_edit_tool_execution() {
117        let temp_dir = std::env::temp_dir();
118        let test_file = temp_dir.join("edit_tool_test.txt");
119
120        // Create file with known content
121        let initial_content = "Hello world\nThis is a test\nEnd of file";
122        std::fs::write(&test_file, initial_content).unwrap();
123
124        let tool = EditTool;
125
126        // Test successful replacement
127        let ctx = create_tool_context();
128        let params = json!({
129            "path": test_file.to_string_lossy(),
130            "old_string": "This is a test",
131            "new_string": "This is modified"
132        });
133
134        let result = tool.execute(params, ctx).await.unwrap();
135        assert!(result.contains("Edited"));
136        assert!(result.contains("replaced 1 line(s) with 1 line(s)"));
137
138        let modified_content = std::fs::read_to_string(&test_file).unwrap();
139        assert!(modified_content.contains("This is modified"));
140        assert!(!modified_content.contains("This is a test"));
141
142        // Test old_string not found
143        let ctx = create_tool_context();
144        let params = json!({
145            "path": test_file.to_string_lossy(),
146            "old_string": "nonexistent string",
147            "new_string": "replacement"
148        });
149
150        let result = tool.execute(params, ctx).await;
151        assert!(result.is_err());
152        assert!(result.unwrap_err().to_string().contains("old_string not found"));
153
154        // Test old_string found multiple times
155        std::fs::write(&test_file, "test\ntest\nother").unwrap();
156        let ctx = create_tool_context();
157        let params = json!({
158            "path": test_file.to_string_lossy(),
159            "old_string": "test",
160            "new_string": "replacement"
161        });
162
163        let result = tool.execute(params, ctx).await;
164        assert!(result.is_err());
165        let error_msg = result.unwrap_err().to_string();
166        assert!(error_msg.contains("found 2 times"));
167        assert!(error_msg.contains("must be unique"));
168
169        // Cleanup
170        let _ = std::fs::remove_file(&test_file);
171    }
172
173    #[tokio::test]
174    async fn test_edit_tool_no_match() {
175        let temp_dir = std::env::temp_dir();
176        let test_file = temp_dir.join("test_edit_tool_no_match.txt");
177
178        // Create file with known content
179        let content = "some content\nmore content";
180        std::fs::write(&test_file, content).unwrap();
181
182        let tool = EditTool;
183        let ctx = create_tool_context();
184
185        let params = json!({
186            "path": test_file.to_string_lossy(),
187            "old_string": "this string does not exist",
188            "new_string": "replacement"
189        });
190
191        let result = tool.execute(params, ctx).await;
192
193        // Should return error about string not found
194        assert!(result.is_err());
195        let error = result.unwrap_err().to_string();
196        assert!(error.contains("old_string not found"));
197
198        // Cleanup
199        let _ = std::fs::remove_file(&test_file);
200    }
201}