1package tools
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "git.secluded.site/crush/internal/csync"
10 "git.secluded.site/crush/internal/history"
11 "git.secluded.site/crush/internal/lsp"
12 "git.secluded.site/crush/internal/permission"
13 "git.secluded.site/crush/internal/pubsub"
14 "github.com/stretchr/testify/require"
15)
16
17type mockPermissionService struct {
18 *pubsub.Broker[permission.PermissionRequest]
19}
20
21func (m *mockPermissionService) Request(req permission.CreatePermissionRequest) bool {
22 return true
23}
24
25func (m *mockPermissionService) Grant(req permission.PermissionRequest) {}
26
27func (m *mockPermissionService) Deny(req permission.PermissionRequest) {}
28
29func (m *mockPermissionService) GrantPersistent(req permission.PermissionRequest) {}
30
31func (m *mockPermissionService) AutoApproveSession(sessionID string) {}
32
33func (m *mockPermissionService) SetSkipRequests(skip bool) {}
34
35func (m *mockPermissionService) SkipRequests() bool {
36 return false
37}
38
39func (m *mockPermissionService) SubscribeNotifications(ctx context.Context) <-chan pubsub.Event[permission.PermissionNotification] {
40 return make(<-chan pubsub.Event[permission.PermissionNotification])
41}
42
43func (m *mockPermissionService) NotifyInteraction(toolCallID string) {}
44
45type mockHistoryService struct {
46 *pubsub.Broker[history.File]
47}
48
49func (m *mockHistoryService) Create(ctx context.Context, sessionID, path, content string) (history.File, error) {
50 return history.File{Path: path, Content: content}, nil
51}
52
53func (m *mockHistoryService) CreateVersion(ctx context.Context, sessionID, path, content string) (history.File, error) {
54 return history.File{}, nil
55}
56
57func (m *mockHistoryService) GetByPathAndSession(ctx context.Context, path, sessionID string) (history.File, error) {
58 return history.File{Path: path, Content: ""}, nil
59}
60
61func (m *mockHistoryService) Get(ctx context.Context, id string) (history.File, error) {
62 return history.File{}, nil
63}
64
65func (m *mockHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
66 return nil, nil
67}
68
69func (m *mockHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
70 return nil, nil
71}
72
73func (m *mockHistoryService) Delete(ctx context.Context, id string) error {
74 return nil
75}
76
77func (m *mockHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
78 return nil
79}
80
81func TestApplyEditToContentPartialSuccess(t *testing.T) {
82 t.Parallel()
83
84 content := "line 1\nline 2\nline 3\n"
85
86 // Test successful edit.
87 newContent, err := applyEditToContent(content, MultiEditOperation{
88 OldString: "line 1",
89 NewString: "LINE 1",
90 })
91 require.NoError(t, err)
92 require.Contains(t, newContent, "LINE 1")
93 require.Contains(t, newContent, "line 2")
94
95 // Test failed edit (string not found).
96 _, err = applyEditToContent(content, MultiEditOperation{
97 OldString: "line 99",
98 NewString: "LINE 99",
99 })
100 require.Error(t, err)
101 require.Contains(t, err.Error(), "not found")
102}
103
104func TestMultiEditSequentialApplication(t *testing.T) {
105 t.Parallel()
106
107 tmpDir := t.TempDir()
108 testFile := filepath.Join(tmpDir, "test.txt")
109
110 // Create test file.
111 content := "line 1\nline 2\nline 3\nline 4\n"
112 err := os.WriteFile(testFile, []byte(content), 0o644)
113 require.NoError(t, err)
114
115 // Mock components.
116 lspClients := csync.NewMap[string, *lsp.Client]()
117 permissions := &mockPermissionService{Broker: pubsub.NewBroker[permission.PermissionRequest]()}
118 files := &mockHistoryService{Broker: pubsub.NewBroker[history.File]()}
119
120 // Create multiedit tool.
121 _ = NewMultiEditTool(lspClients, permissions, files, tmpDir)
122
123 // Simulate reading the file first.
124 recordFileRead(testFile)
125
126 // Manually test the sequential application logic.
127 currentContent := content
128
129 // Apply edits sequentially, tracking failures.
130 edits := []MultiEditOperation{
131 {OldString: "line 1", NewString: "LINE 1"}, // Should succeed
132 {OldString: "line 99", NewString: "LINE 99"}, // Should fail - doesn't exist
133 {OldString: "line 3", NewString: "LINE 3"}, // Should succeed
134 {OldString: "line 2", NewString: "LINE 2"}, // Should succeed - still exists
135 }
136
137 var failedEdits []FailedEdit
138 successCount := 0
139
140 for i, edit := range edits {
141 newContent, err := applyEditToContent(currentContent, edit)
142 if err != nil {
143 failedEdits = append(failedEdits, FailedEdit{
144 Index: i + 1,
145 Error: err.Error(),
146 Edit: edit,
147 })
148 continue
149 }
150 currentContent = newContent
151 successCount++
152 }
153
154 // Verify results.
155 require.Equal(t, 3, successCount, "Expected 3 successful edits")
156 require.Len(t, failedEdits, 1, "Expected 1 failed edit")
157
158 // Check failed edit details.
159 require.Equal(t, 2, failedEdits[0].Index)
160 require.Contains(t, failedEdits[0].Error, "not found")
161
162 // Verify content changes.
163 require.Contains(t, currentContent, "LINE 1")
164 require.Contains(t, currentContent, "LINE 2")
165 require.Contains(t, currentContent, "LINE 3")
166 require.Contains(t, currentContent, "line 4") // Original unchanged
167 require.NotContains(t, currentContent, "LINE 99")
168}
169
170func TestMultiEditAllEditsSucceed(t *testing.T) {
171 t.Parallel()
172
173 content := "line 1\nline 2\nline 3\n"
174
175 edits := []MultiEditOperation{
176 {OldString: "line 1", NewString: "LINE 1"},
177 {OldString: "line 2", NewString: "LINE 2"},
178 {OldString: "line 3", NewString: "LINE 3"},
179 }
180
181 currentContent := content
182 successCount := 0
183
184 for _, edit := range edits {
185 newContent, err := applyEditToContent(currentContent, edit)
186 if err != nil {
187 t.Fatalf("Unexpected error: %v", err)
188 }
189 currentContent = newContent
190 successCount++
191 }
192
193 require.Equal(t, 3, successCount)
194 require.Contains(t, currentContent, "LINE 1")
195 require.Contains(t, currentContent, "LINE 2")
196 require.Contains(t, currentContent, "LINE 3")
197}
198
199func TestMultiEditAllEditsFail(t *testing.T) {
200 t.Parallel()
201
202 content := "line 1\nline 2\n"
203
204 edits := []MultiEditOperation{
205 {OldString: "line 99", NewString: "LINE 99"},
206 {OldString: "line 100", NewString: "LINE 100"},
207 }
208
209 currentContent := content
210 var failedEdits []FailedEdit
211
212 for i, edit := range edits {
213 newContent, err := applyEditToContent(currentContent, edit)
214 if err != nil {
215 failedEdits = append(failedEdits, FailedEdit{
216 Index: i + 1,
217 Error: err.Error(),
218 Edit: edit,
219 })
220 continue
221 }
222 currentContent = newContent
223 }
224
225 require.Len(t, failedEdits, 2)
226 require.Equal(t, content, currentContent, "Content should be unchanged")
227}