1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4use std::path::Path;
5
6use super::Tool;
7
8pub struct ApplyPatchTool;
9
10impl Tool for ApplyPatchTool {
11 fn name(&self) -> &str {
12 "apply_patch"
13 }
14
15 fn description(&self) -> &str {
16 "Apply search-and-replace patches to one or more files. Each patch specifies a file path, the exact text to find (old), and the replacement text (new). Use for precise multi-file edits."
17 }
18
19 fn input_schema(&self) -> Value {
20 serde_json::json!({
21 "type": "object",
22 "properties": {
23 "patches": {
24 "type": "array",
25 "items": {
26 "type": "object",
27 "properties": {
28 "path": {
29 "type": "string",
30 "description": "File path to modify"
31 },
32 "old": {
33 "type": "string",
34 "description": "Exact text to find in the file"
35 },
36 "new": {
37 "type": "string",
38 "description": "Replacement text"
39 }
40 },
41 "required": ["path", "old", "new"]
42 },
43 "description": "Array of patches to apply"
44 }
45 },
46 "required": ["patches"]
47 })
48 }
49
50 fn execute(&self, input: Value) -> Result<String> {
51 let patches = input["patches"]
52 .as_array()
53 .context("Missing required parameter 'patches'")?;
54
55 if patches.is_empty() {
56 return Ok("No patches to apply.".to_string());
57 }
58
59 tracing::debug!("apply_patch: {} patches", patches.len());
60
61 let mut results: Vec<String> = Vec::new();
62 let mut errors: Vec<String> = Vec::new();
63
64 for patch in patches {
65 let path = match patch["path"].as_str() {
66 Some(p) => p,
67 None => {
68 errors.push("patch missing 'path' field".to_string());
69 continue;
70 }
71 };
72 let old = match patch["old"].as_str() {
73 Some(o) => o,
74 None => {
75 errors.push(format!("{}: missing 'old' field", path));
76 continue;
77 }
78 };
79 let new = match patch["new"].as_str() {
80 Some(n) => n,
81 None => {
82 errors.push(format!("{}: missing 'new' field", path));
83 continue;
84 }
85 };
86
87 if old.is_empty() && new.is_empty() {
88 continue;
89 }
90
91 if old.is_empty() {
92 if let Some(parent) = Path::new(path).parent()
93 && !parent.as_os_str().is_empty()
94 {
95 let _ = fs::create_dir_all(parent);
96 }
97 match fs::write(path, new) {
98 Ok(()) => results.push(format!("created {}", path)),
99 Err(e) => errors.push(format!("{}: {}", path, e)),
100 }
101 continue;
102 }
103
104 let content = match fs::read_to_string(path) {
105 Ok(c) => c,
106 Err(e) => {
107 errors.push(format!("{}: {}", path, e));
108 continue;
109 }
110 };
111
112 if !content.contains(old) {
113 errors.push(format!("{}: 'old' text not found in file", path));
114 continue;
115 }
116
117 let updated = content.replacen(old, new, 1);
118 match fs::write(path, &updated) {
119 Ok(()) => results.push(format!("patched {}", path)),
120 Err(e) => errors.push(format!("{}: write failed: {}", path, e)),
121 }
122 }
123
124 let mut output = String::new();
125 if !results.is_empty() {
126 output.push_str(&results.join("\n"));
127 }
128 if !errors.is_empty() {
129 if !output.is_empty() {
130 output.push('\n');
131 }
132 output.push_str("Errors:\n");
133 output.push_str(&errors.join("\n"));
134 }
135
136 Ok(output)
137 }
138}