Skip to content

Commit a98ed86

Browse files
committed
implemented a updated version of a document without replacing the original content
1 parent 0033628 commit a98ed86

13 files changed

Lines changed: 1542 additions & 117 deletions

internal/agentic/content_merger.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package agentic
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// ContentMerger combines existing file content with AI-generated content
9+
// according to the classified EditIntent.
10+
type ContentMerger struct{}
11+
12+
// NewContentMerger creates a new ContentMerger instance.
13+
func NewContentMerger() *ContentMerger {
14+
return &ContentMerger{}
15+
}
16+
17+
// Merge combines existing content with new content according to the EditIntent.
18+
// For "append": concatenates with a single newline separator.
19+
// For "insert": places new content after the anchor line, falls back to append.
20+
// For "replace": returns newContent as-is.
21+
// For "patch": delegates to applySearchReplace (existing function).
22+
func (cm *ContentMerger) Merge(existingContent, newContent string, intent EditIntent) (string, error) {
23+
if newContent == "" {
24+
return "", fmt.Errorf("newContent must not be empty")
25+
}
26+
27+
switch intent.OperationType {
28+
case "append":
29+
return cm.mergeAppend(existingContent, newContent), nil
30+
case "insert":
31+
return cm.mergeInsert(existingContent, newContent), nil
32+
case "replace":
33+
return newContent, nil
34+
case "patch":
35+
return cm.mergePatch(existingContent, newContent)
36+
default:
37+
return "", fmt.Errorf("unknown operation type: %s", intent.OperationType)
38+
}
39+
}
40+
41+
// mergeAppend concatenates existing content with new content, ensuring exactly
42+
// one newline separator between them.
43+
func (cm *ContentMerger) mergeAppend(existingContent, newContent string) string {
44+
if existingContent == "" {
45+
return newContent
46+
}
47+
if strings.HasSuffix(existingContent, "\n") {
48+
return existingContent + newContent
49+
}
50+
return existingContent + "\n" + newContent
51+
}
52+
53+
// mergeInsert places new content after the anchor line in existing content.
54+
// The first line of newContent is treated as the anchor line; the rest is the
55+
// content to insert. If the anchor is not found, falls back to append.
56+
func (cm *ContentMerger) mergeInsert(existingContent, newContent string) string {
57+
lines := strings.SplitN(newContent, "\n", 2)
58+
anchor := lines[0]
59+
insertContent := ""
60+
if len(lines) > 1 {
61+
insertContent = lines[1]
62+
}
63+
64+
anchorIdx := cm.findAnchorLine(existingContent, anchor)
65+
if anchorIdx == -1 {
66+
// Anchor not found — fall back to append behavior.
67+
return cm.mergeAppend(existingContent, newContent)
68+
}
69+
70+
existingLines := strings.Split(existingContent, "\n")
71+
var result []string
72+
result = append(result, existingLines[:anchorIdx+1]...)
73+
if insertContent != "" {
74+
result = append(result, insertContent)
75+
}
76+
result = append(result, existingLines[anchorIdx+1:]...)
77+
return strings.Join(result, "\n")
78+
}
79+
80+
// mergePatch parses newContent as SEARCH/REPLACE blocks and applies them
81+
// sequentially to existingContent using the existing applySearchReplace function.
82+
func (cm *ContentMerger) mergePatch(existingContent, newContent string) (string, error) {
83+
patches := parseSearchReplace(newContent)
84+
if len(patches) == 0 {
85+
// If no SEARCH/REPLACE blocks found, try a single direct apply.
86+
return applySearchReplace(existingContent, "", newContent)
87+
}
88+
89+
result := existingContent
90+
for _, p := range patches {
91+
var err error
92+
result, err = applySearchReplace(result, p.search, p.replace)
93+
if err != nil {
94+
return "", fmt.Errorf("patch apply failed: %w", err)
95+
}
96+
}
97+
return result, nil
98+
}
99+
100+
// findAnchorLine locates the line number of the anchor in existingContent.
101+
// Returns -1 if not found. The match is based on trimmed line comparison.
102+
func (cm *ContentMerger) findAnchorLine(existingContent, anchor string) int {
103+
lines := strings.Split(existingContent, "\n")
104+
trimmedAnchor := strings.TrimSpace(anchor)
105+
for i, line := range lines {
106+
if strings.TrimSpace(line) == trimmedAnchor {
107+
return i
108+
}
109+
}
110+
return -1
111+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package agentic
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"pgregory.net/rapid"
8+
)
9+
10+
// appendIntent is the standard append EditIntent used across append property tests.
11+
var appendIntent = EditIntent{OperationType: "append", Confidence: 0.8}
12+
13+
// replaceIntent is the standard replace EditIntent used across replace property tests.
14+
var replaceIntent = EditIntent{OperationType: "replace", Confidence: 0.8}
15+
16+
// insertIntent is the standard insert EditIntent used across insert property tests.
17+
var insertIntent = EditIntent{OperationType: "insert", Confidence: 0.8}
18+
19+
// nonEmptyLineGen generates a non-empty single line (no newlines).
20+
func nonEmptyLineGen() *rapid.Generator[string] {
21+
return rapid.StringMatching(`[a-zA-Z0-9 _\-]{1,40}`)
22+
}
23+
24+
// multiLineContentGen generates non-empty content with 1-5 lines.
25+
func multiLineContentGen() *rapid.Generator[string] {
26+
return rapid.Custom[string](func(t *rapid.T) string {
27+
n := rapid.IntRange(1, 5).Draw(t, "lineCount")
28+
lines := make([]string, n)
29+
for i := 0; i < n; i++ {
30+
lines[i] = nonEmptyLineGen().Draw(t, "line")
31+
}
32+
return strings.Join(lines, "\n")
33+
})
34+
}
35+
36+
// Feature: content-preserving-file-updates, Property 7: Append merge preserves existing content and appends new content
37+
// For any non-empty existing file content and any non-empty new content, calling
38+
// ContentMerger.Merge(existing, new, appendIntent) should produce a result that starts with
39+
// the existing content, ends with the new content, and has exactly one newline character
40+
// separating the existing content from the new content.
41+
// **Validates: Requirements 3.1, 3.6, 8.1**
42+
func TestProperty7_AppendMergePreservesContent(t *testing.T) {
43+
cm := NewContentMerger()
44+
45+
rapid.Check(t, func(t *rapid.T) {
46+
existing := multiLineContentGen().Draw(t, "existing")
47+
newContent := multiLineContentGen().Draw(t, "newContent")
48+
49+
result, err := cm.Merge(existing, newContent, appendIntent)
50+
if err != nil {
51+
t.Fatalf("unexpected error: %v", err)
52+
}
53+
54+
// Normalize: strip trailing newline from existing for prefix check,
55+
// since mergeAppend consumes a trailing newline as the separator.
56+
trimmedExisting := strings.TrimRight(existing, "\n")
57+
58+
// Result must start with the existing content (minus any trailing newline).
59+
if !strings.HasPrefix(result, trimmedExisting) {
60+
t.Fatalf("result does not start with existing content.\nexisting: %q\nresult: %q",
61+
existing, result)
62+
}
63+
64+
// Result must end with the new content.
65+
if !strings.HasSuffix(result, newContent) {
66+
t.Fatalf("result does not end with new content.\nnewContent: %q\nresult: %q",
67+
newContent, result)
68+
}
69+
70+
// There must be exactly one newline separating existing from new content.
71+
// Find where existing ends and new content begins.
72+
// The separator is the character(s) between trimmedExisting and newContent.
73+
sepStart := len(trimmedExisting)
74+
sepEnd := len(result) - len(newContent)
75+
separator := result[sepStart:sepEnd]
76+
77+
if separator != "\n" {
78+
t.Fatalf("expected exactly one newline separator, got %q.\nexisting: %q\nnewContent: %q\nresult: %q",
79+
separator, existing, newContent, result)
80+
}
81+
})
82+
}
83+
84+
// Feature: content-preserving-file-updates, Property 8: Append merge line count
85+
// For any non-empty existing file content and any non-empty new content, the line count of the
86+
// result from ContentMerger.Merge(existing, new, appendIntent) should equal the line count of
87+
// the existing content plus the line count of the new content (accounting for the separator).
88+
// **Validates: Requirements 8.2**
89+
func TestProperty8_AppendMergeLineCount(t *testing.T) {
90+
cm := NewContentMerger()
91+
92+
rapid.Check(t, func(t *rapid.T) {
93+
existing := multiLineContentGen().Draw(t, "existing")
94+
newContent := multiLineContentGen().Draw(t, "newContent")
95+
96+
result, err := cm.Merge(existing, newContent, appendIntent)
97+
if err != nil {
98+
t.Fatalf("unexpected error: %v", err)
99+
}
100+
101+
existingLines := countLines(existing)
102+
newLines := countLines(newContent)
103+
resultLines := countLines(result)
104+
105+
// When existing ends with \n, mergeAppend does existingContent + newContent
106+
// (the trailing \n acts as the separator, so no extra line is added).
107+
// When existing does NOT end with \n, mergeAppend does existingContent + "\n" + newContent
108+
// (the \n is the separator, which doesn't add an extra line — it joins the last
109+
// existing line with the first new line on separate lines).
110+
//
111+
// In both cases the result line count = existingLines + newLines,
112+
// UNLESS existing ends with \n, in which case strings.Split counts an extra
113+
// empty trailing element. The trailing \n is consumed as separator, so:
114+
// - existing NOT ending with \n: result = existingLines + newLines
115+
// - existing ending with \n: existing has an extra empty line from Split,
116+
// but that empty line becomes the separator, so result = existingLines - 1 + newLines
117+
//
118+
// Simplified: result lines = countLines(trimmedExisting) + newLines
119+
// where trimmedExisting = strings.TrimRight(existing, "\n")
120+
trimmedExisting := strings.TrimRight(existing, "\n")
121+
expectedLines := countLines(trimmedExisting) + newLines
122+
123+
if resultLines != expectedLines {
124+
t.Fatalf("line count mismatch: existing=%d (trimmed=%d), new=%d, result=%d, expected=%d\nexisting: %q\nnewContent: %q\nresult: %q",
125+
existingLines, countLines(trimmedExisting), newLines, resultLines, expectedLines,
126+
existing, newContent, result)
127+
}
128+
})
129+
}
130+
131+
// Feature: content-preserving-file-updates, Property 9: Replace merge returns new content
132+
// For any existing file content and any non-empty new content, calling
133+
// ContentMerger.Merge(existing, new, replaceIntent) should return a result equal to the new content.
134+
// **Validates: Requirements 3.3**
135+
func TestProperty9_ReplaceMergeReturnsNewContent(t *testing.T) {
136+
cm := NewContentMerger()
137+
138+
rapid.Check(t, func(t *rapid.T) {
139+
// Existing can be anything, including empty.
140+
existing := rapid.StringMatching(`[a-zA-Z0-9 \n_\-]{0,80}`).Draw(t, "existing")
141+
newContent := multiLineContentGen().Draw(t, "newContent")
142+
143+
result, err := cm.Merge(existing, newContent, replaceIntent)
144+
if err != nil {
145+
t.Fatalf("unexpected error: %v", err)
146+
}
147+
148+
if result != newContent {
149+
t.Fatalf("replace merge did not return newContent.\nexpected: %q\ngot: %q",
150+
newContent, result)
151+
}
152+
})
153+
}
154+
155+
// Feature: content-preserving-file-updates, Property 10: Replace with same content is idempotent
156+
// For any valid non-empty file content string, calling
157+
// ContentMerger.Merge(content, content, replaceIntent) should return a result identical to
158+
// the original content.
159+
// **Validates: Requirements 8.3**
160+
func TestProperty10_ReplaceIdempotent(t *testing.T) {
161+
cm := NewContentMerger()
162+
163+
rapid.Check(t, func(t *rapid.T) {
164+
content := multiLineContentGen().Draw(t, "content")
165+
166+
result, err := cm.Merge(content, content, replaceIntent)
167+
if err != nil {
168+
t.Fatalf("unexpected error: %v", err)
169+
}
170+
171+
if result != content {
172+
t.Fatalf("replace with same content is not idempotent.\noriginal: %q\nresult: %q",
173+
content, result)
174+
}
175+
})
176+
}
177+
178+
// Feature: content-preserving-file-updates, Property 11: Insert merge places content after anchor
179+
// For any existing file content containing at least two lines, if the anchor line exists in the
180+
// existing content, then calling ContentMerger.Merge with insert intent should produce a result
181+
// where the new content appears immediately after the anchor line, and all original lines are
182+
// preserved.
183+
// For insert, the first line of newContent is the anchor, the rest is the content to insert.
184+
// **Validates: Requirements 3.2**
185+
func TestProperty11_InsertMergePlacesContentAfterAnchor(t *testing.T) {
186+
cm := NewContentMerger()
187+
188+
rapid.Check(t, func(t *rapid.T) {
189+
// Generate existing content with at least 2 lines.
190+
lineCount := rapid.IntRange(2, 6).Draw(t, "lineCount")
191+
existingLines := make([]string, lineCount)
192+
for i := 0; i < lineCount; i++ {
193+
existingLines[i] = nonEmptyLineGen().Draw(t, "existingLine")
194+
}
195+
existing := strings.Join(existingLines, "\n")
196+
197+
// Pick a random line from existing as the anchor.
198+
anchorIdx := rapid.IntRange(0, lineCount-1).Draw(t, "anchorIdx")
199+
anchor := existingLines[anchorIdx]
200+
201+
// Generate content to insert (at least one line).
202+
insertLineCount := rapid.IntRange(1, 3).Draw(t, "insertLineCount")
203+
insertLines := make([]string, insertLineCount)
204+
for i := 0; i < insertLineCount; i++ {
205+
insertLines[i] = nonEmptyLineGen().Draw(t, "insertLine")
206+
}
207+
insertContent := strings.Join(insertLines, "\n")
208+
209+
// For insert intent, newContent = anchor + "\n" + content to insert.
210+
newContent := anchor + "\n" + insertContent
211+
212+
result, err := cm.Merge(existing, newContent, insertIntent)
213+
if err != nil {
214+
t.Fatalf("unexpected error: %v", err)
215+
}
216+
217+
resultLines := strings.Split(result, "\n")
218+
219+
// Find the anchor in the result.
220+
anchorFound := false
221+
for i, line := range resultLines {
222+
if strings.TrimSpace(line) == strings.TrimSpace(anchor) {
223+
anchorFound = true
224+
225+
// The inserted content should appear immediately after the anchor.
226+
// Check that the insert content lines follow the anchor.
227+
for j, insertLine := range insertLines {
228+
resultIdx := i + 1 + j
229+
if resultIdx >= len(resultLines) {
230+
t.Fatalf("result too short: expected insert line at index %d but result has %d lines",
231+
resultIdx, len(resultLines))
232+
}
233+
if strings.TrimSpace(resultLines[resultIdx]) != strings.TrimSpace(insertLine) {
234+
t.Fatalf("insert line mismatch at result index %d: expected %q, got %q",
235+
resultIdx, insertLine, resultLines[resultIdx])
236+
}
237+
}
238+
break
239+
}
240+
}
241+
242+
if !anchorFound {
243+
t.Fatalf("anchor line %q not found in result:\n%s", anchor, result)
244+
}
245+
246+
// All original lines must be preserved in the result.
247+
for _, origLine := range existingLines {
248+
if !strings.Contains(result, origLine) {
249+
t.Fatalf("original line %q not found in result:\n%s", origLine, result)
250+
}
251+
}
252+
})
253+
}

0 commit comments

Comments
 (0)