Skip to main content

dot/tools/
patch.rs

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}