multiedit_test.go

  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}