1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "time"
10
11 "github.com/kujtimiihoxha/opencode/internal/config"
12 "github.com/kujtimiihoxha/opencode/internal/diff"
13 "github.com/kujtimiihoxha/opencode/internal/history"
14 "github.com/kujtimiihoxha/opencode/internal/lsp"
15 "github.com/kujtimiihoxha/opencode/internal/permission"
16)
17
18type PatchParams struct {
19 PatchText string `json:"patch_text"`
20}
21
22type PatchResponseMetadata struct {
23 FilesChanged []string `json:"files_changed"`
24 Additions int `json:"additions"`
25 Removals int `json:"removals"`
26}
27
28type patchTool struct {
29 lspClients map[string]*lsp.Client
30 permissions permission.Service
31 files history.Service
32}
33
34const (
35 PatchToolName = "patch"
36 patchDescription = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
37
38The patch text must follow this format:
39*** Begin Patch
40*** Update File: /path/to/file
41@@ Context line (unique within the file)
42 Line to keep
43-Line to remove
44+Line to add
45 Line to keep
46*** Add File: /path/to/new/file
47+Content of the new file
48+More content
49*** Delete File: /path/to/file/to/delete
50*** End Patch
51
52Before using this tool:
531. Use the FileRead tool to understand the files' contents and context
542. Verify all file paths are correct (use the LS tool)
55
56CRITICAL REQUIREMENTS FOR USING THIS TOOL:
57
581. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
592. PRECISION: All whitespace, indentation, and surrounding code must match exactly
603. VALIDATION: Ensure edits result in idiomatic, correct code
614. PATHS: Always use absolute file paths (starting with /)
62
63The tool will apply all changes in a single atomic operation.`
64)
65
66func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
67 return &patchTool{
68 lspClients: lspClients,
69 permissions: permissions,
70 files: files,
71 }
72}
73
74func (p *patchTool) Info() ToolInfo {
75 return ToolInfo{
76 Name: PatchToolName,
77 Description: patchDescription,
78 Parameters: map[string]any{
79 "patch_text": map[string]any{
80 "type": "string",
81 "description": "The full patch text that describes all changes to be made",
82 },
83 },
84 Required: []string{"patch_text"},
85 }
86}
87
88func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
89 var params PatchParams
90 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
91 return NewTextErrorResponse("invalid parameters"), nil
92 }
93
94 if params.PatchText == "" {
95 return NewTextErrorResponse("patch_text is required"), nil
96 }
97
98 // Identify all files needed for the patch and verify they've been read
99 filesToRead := diff.IdentifyFilesNeeded(params.PatchText)
100 for _, filePath := range filesToRead {
101 absPath := filePath
102 if !filepath.IsAbs(absPath) {
103 wd := config.WorkingDirectory()
104 absPath = filepath.Join(wd, absPath)
105 }
106
107 if getLastReadTime(absPath).IsZero() {
108 return NewTextErrorResponse(fmt.Sprintf("you must read the file %s before patching it. Use the FileRead tool first", filePath)), nil
109 }
110
111 fileInfo, err := os.Stat(absPath)
112 if err != nil {
113 if os.IsNotExist(err) {
114 return NewTextErrorResponse(fmt.Sprintf("file not found: %s", absPath)), nil
115 }
116 return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
117 }
118
119 if fileInfo.IsDir() {
120 return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", absPath)), nil
121 }
122
123 modTime := fileInfo.ModTime()
124 lastRead := getLastReadTime(absPath)
125 if modTime.After(lastRead) {
126 return NewTextErrorResponse(
127 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
128 absPath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
129 )), nil
130 }
131 }
132
133 // Check for new files to ensure they don't already exist
134 filesToAdd := diff.IdentifyFilesAdded(params.PatchText)
135 for _, filePath := range filesToAdd {
136 absPath := filePath
137 if !filepath.IsAbs(absPath) {
138 wd := config.WorkingDirectory()
139 absPath = filepath.Join(wd, absPath)
140 }
141
142 _, err := os.Stat(absPath)
143 if err == nil {
144 return NewTextErrorResponse(fmt.Sprintf("file already exists and cannot be added: %s", absPath)), nil
145 } else if !os.IsNotExist(err) {
146 return ToolResponse{}, fmt.Errorf("failed to check file: %w", err)
147 }
148 }
149
150 // Load all required files
151 currentFiles := make(map[string]string)
152 for _, filePath := range filesToRead {
153 absPath := filePath
154 if !filepath.IsAbs(absPath) {
155 wd := config.WorkingDirectory()
156 absPath = filepath.Join(wd, absPath)
157 }
158
159 content, err := os.ReadFile(absPath)
160 if err != nil {
161 return ToolResponse{}, fmt.Errorf("failed to read file %s: %w", absPath, err)
162 }
163 currentFiles[filePath] = string(content)
164 }
165
166 // Process the patch
167 patch, fuzz, err := diff.TextToPatch(params.PatchText, currentFiles)
168 if err != nil {
169 return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %s", err)), nil
170 }
171
172 if fuzz > 0 {
173 return NewTextErrorResponse(fmt.Sprintf("patch contains fuzzy matches (fuzz level: %d). Please make your context lines more precise", fuzz)), nil
174 }
175
176 // Convert patch to commit
177 commit, err := diff.PatchToCommit(patch, currentFiles)
178 if err != nil {
179 return NewTextErrorResponse(fmt.Sprintf("failed to create commit from patch: %s", err)), nil
180 }
181
182 // Get session ID and message ID
183 sessionID, messageID := GetContextValues(ctx)
184 if sessionID == "" || messageID == "" {
185 return ToolResponse{}, fmt.Errorf("session ID and message ID are required for creating a patch")
186 }
187
188 // Request permission for all changes
189 for path, change := range commit.Changes {
190 switch change.Type {
191 case diff.ActionAdd:
192 dir := filepath.Dir(path)
193 patchDiff, _, _ := diff.GenerateDiff("", *change.NewContent, path)
194 p := p.permissions.Request(
195 permission.CreatePermissionRequest{
196 Path: dir,
197 ToolName: PatchToolName,
198 Action: "create",
199 Description: fmt.Sprintf("Create file %s", path),
200 Params: EditPermissionsParams{
201 FilePath: path,
202 Diff: patchDiff,
203 },
204 },
205 )
206 if !p {
207 return ToolResponse{}, permission.ErrorPermissionDenied
208 }
209 case diff.ActionUpdate:
210 currentContent := ""
211 if change.OldContent != nil {
212 currentContent = *change.OldContent
213 }
214 newContent := ""
215 if change.NewContent != nil {
216 newContent = *change.NewContent
217 }
218 patchDiff, _, _ := diff.GenerateDiff(currentContent, newContent, path)
219 dir := filepath.Dir(path)
220 p := p.permissions.Request(
221 permission.CreatePermissionRequest{
222 Path: dir,
223 ToolName: PatchToolName,
224 Action: "update",
225 Description: fmt.Sprintf("Update file %s", path),
226 Params: EditPermissionsParams{
227 FilePath: path,
228 Diff: patchDiff,
229 },
230 },
231 )
232 if !p {
233 return ToolResponse{}, permission.ErrorPermissionDenied
234 }
235 case diff.ActionDelete:
236 dir := filepath.Dir(path)
237 patchDiff, _, _ := diff.GenerateDiff(*change.OldContent, "", path)
238 p := p.permissions.Request(
239 permission.CreatePermissionRequest{
240 Path: dir,
241 ToolName: PatchToolName,
242 Action: "delete",
243 Description: fmt.Sprintf("Delete file %s", path),
244 Params: EditPermissionsParams{
245 FilePath: path,
246 Diff: patchDiff,
247 },
248 },
249 )
250 if !p {
251 return ToolResponse{}, permission.ErrorPermissionDenied
252 }
253 }
254 }
255
256 // Apply the changes to the filesystem
257 err = diff.ApplyCommit(commit, func(path string, content string) error {
258 absPath := path
259 if !filepath.IsAbs(absPath) {
260 wd := config.WorkingDirectory()
261 absPath = filepath.Join(wd, absPath)
262 }
263
264 // Create parent directories if needed
265 dir := filepath.Dir(absPath)
266 if err := os.MkdirAll(dir, 0o755); err != nil {
267 return fmt.Errorf("failed to create parent directories for %s: %w", absPath, err)
268 }
269
270 return os.WriteFile(absPath, []byte(content), 0o644)
271 }, func(path string) error {
272 absPath := path
273 if !filepath.IsAbs(absPath) {
274 wd := config.WorkingDirectory()
275 absPath = filepath.Join(wd, absPath)
276 }
277 return os.Remove(absPath)
278 })
279 if err != nil {
280 return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %s", err)), nil
281 }
282
283 // Update file history for all modified files
284 changedFiles := []string{}
285 totalAdditions := 0
286 totalRemovals := 0
287
288 for path, change := range commit.Changes {
289 absPath := path
290 if !filepath.IsAbs(absPath) {
291 wd := config.WorkingDirectory()
292 absPath = filepath.Join(wd, absPath)
293 }
294 changedFiles = append(changedFiles, absPath)
295
296 oldContent := ""
297 if change.OldContent != nil {
298 oldContent = *change.OldContent
299 }
300
301 newContent := ""
302 if change.NewContent != nil {
303 newContent = *change.NewContent
304 }
305
306 // Calculate diff statistics
307 _, additions, removals := diff.GenerateDiff(oldContent, newContent, path)
308 totalAdditions += additions
309 totalRemovals += removals
310
311 // Update history
312 file, err := p.files.GetByPathAndSession(ctx, absPath, sessionID)
313 if err != nil && change.Type != diff.ActionAdd {
314 // If not adding a file, create history entry for existing file
315 _, err = p.files.Create(ctx, sessionID, absPath, oldContent)
316 if err != nil {
317 fmt.Printf("Error creating file history: %v\n", err)
318 }
319 }
320
321 if err == nil && change.Type != diff.ActionAdd && file.Content != oldContent {
322 // User manually changed content, store intermediate version
323 _, err = p.files.CreateVersion(ctx, sessionID, absPath, oldContent)
324 if err != nil {
325 fmt.Printf("Error creating file history version: %v\n", err)
326 }
327 }
328
329 // Store new version
330 if change.Type == diff.ActionDelete {
331 _, err = p.files.CreateVersion(ctx, sessionID, absPath, "")
332 } else {
333 _, err = p.files.CreateVersion(ctx, sessionID, absPath, newContent)
334 }
335 if err != nil {
336 fmt.Printf("Error creating file history version: %v\n", err)
337 }
338
339 // Record file operations
340 recordFileWrite(absPath)
341 recordFileRead(absPath)
342 }
343
344 // Run LSP diagnostics on all changed files
345 for _, filePath := range changedFiles {
346 waitForLspDiagnostics(ctx, filePath, p.lspClients)
347 }
348
349 result := fmt.Sprintf("Patch applied successfully. %d files changed, %d additions, %d removals",
350 len(changedFiles), totalAdditions, totalRemovals)
351
352 diagnosticsText := ""
353 for _, filePath := range changedFiles {
354 diagnosticsText += getDiagnostics(filePath, p.lspClients)
355 }
356
357 if diagnosticsText != "" {
358 result += "\n\nDiagnostics:\n" + diagnosticsText
359 }
360
361 return WithResponseMetadata(
362 NewTextResponse(result),
363 PatchResponseMetadata{
364 FilesChanged: changedFiles,
365 Additions: totalAdditions,
366 Removals: totalRemovals,
367 }), nil
368}