Skip to content

Commit 3897052

Browse files
authored
feat: Add ReplaceFileContent function with line count verification (#79)
* feat: Add ReplaceFileContent function with line count verification - Adds new ReplaceFileContent function for AI-safe file replacement - Requires oldContentLineCount parameter as verification (CAPTCHA for agents) - Prevents accidental overwrites by validating line count matches - Returns helpful error if verification fails - Saves old content to edit history for undo support * feat: Add ReplaceMultipleInFile function and fix string array deserialization bug - Add ReplaceMultipleInFile function for atomic multi-pattern replacement in single file - Fix bug in FunctionFactory.cs where string arrays included quotes (GetRawText vs GetString) - Apply fix to Array, List, and Tuple deserialization - Clean, concise output: 'File X updated: replaced N occurrences.' - Validates all patterns before making any changes (atomic operation) - Supports undo via EditHistory * Add comprehensive tests for ReplaceFileContent and ReplaceMultipleInFile functions * Fix shell glob expansion in test commands by quoting asterisk * Fix test format: use 'bash:' instead of 'run:' for multi-line shell scripts * Fix echo commands to create proper 3-line files using -e flag * Fix line count: use printf instead of echo to avoid trailing newline
1 parent 0b730a9 commit 3897052

File tree

3 files changed

+262
-3
lines changed

3 files changed

+262
-3
lines changed

src/cycod/FunctionCalling/FunctionFactory.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ private static object CreateGenericCollectionFromJsonArray(string parameterValue
318318
var collection = Array.CreateInstance(elementType, array.Length);
319319
for (int i = 0; i < array.Length; i++)
320320
{
321-
var parsed = ParseParameterValue(array[i].GetRawText(), elementType);
321+
var elementValue = array[i].ValueKind == JsonValueKind.String ? array[i].GetString()! : array[i].GetRawText();
322+
var parsed = ParseParameterValue(elementValue, elementType);
322323
if (parsed != null) collection.SetValue(parsed, i);
323324
}
324325
return collection;
@@ -329,7 +330,8 @@ private static object CreateGenericCollectionFromJsonArray(string parameterValue
329330
var list = collection as IList;
330331
foreach (var item in array)
331332
{
332-
var parsed = ParseParameterValue(item.GetRawText(), elementType);
333+
var elementValue = item.ValueKind == JsonValueKind.String ? item.GetString()! : item.GetRawText();
334+
var parsed = ParseParameterValue(elementValue, elementType);
333335
if (parsed != null) list!.Add(parsed);
334336
}
335337
return collection!;
@@ -349,7 +351,8 @@ private static object CreateTuppleTypeFromJsonArray(string parameterValue, Type
349351

350352
foreach (var item in array)
351353
{
352-
var parsed = ParseParameterValue(item.GetRawText(), elementType);
354+
var elementValue = item.ValueKind == JsonValueKind.String ? item.GetString()! : item.GetRawText();
355+
var parsed = ParseParameterValue(elementValue, elementType);
353356
if (parsed != null) list!.Add(parsed);
354357
}
355358

src/cycod/FunctionCallingTools/StrReplaceEditorHelperFunctions.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,43 @@ public string CreateFile(
305305
return $"Created file {path} with {fileText.Length} characters.";
306306
}
307307

308+
[ReadOnly(false)]
309+
[Description("Replaces the entire content of an existing file. Requires knowing the current line count as verification that you've read the file.")]
310+
public string ReplaceFileContent(
311+
[Description("Absolute or relative path to file.")] string path,
312+
[Description("New content to replace the entire file.")] string newContent,
313+
[Description("Current line count of the file (for verification).")] int oldContentLineCount)
314+
{
315+
if (!File.Exists(path))
316+
{
317+
return $"File {path} does not exist. Use CreateFile to create a new file.";
318+
}
319+
320+
// Read current content and count lines
321+
var currentContent = File.ReadAllText(path);
322+
var currentLines = currentContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
323+
var actualLineCount = currentLines.Length;
324+
325+
// Verify line count matches
326+
if (actualLineCount != oldContentLineCount)
327+
{
328+
return $"Line count mismatch: you specified {oldContentLineCount} but file has {actualLineCount} lines. Please read the file with ViewFile and verify the line count.";
329+
}
330+
331+
// Save current content for undo
332+
if (!EditHistory.ContainsKey(path))
333+
{
334+
EditHistory[path] = currentContent;
335+
}
336+
337+
// Replace entire content
338+
File.WriteAllText(path, newContent);
339+
var newLines = newContent.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
340+
341+
return $"Replaced content in {path}: {actualLineCount} lines → {newLines.Length} lines ({newContent.Length} characters).";
342+
}
343+
344+
308345
// [ReadOnly(false)]
309346
// [Description("Replaces the lines in the file at `path` from `startLine` to `endLine` with the new string `newStr`. If `endLine` is -1, all remaining lines are replaced.")]
310347
// public string LinesReplace(
@@ -378,6 +415,75 @@ public string ReplaceOneInFile(
378415
return $"File {path} updated: replaced 1 occurrence of specified text.";
379416
}
380417

418+
[ReadOnly(false)]
419+
[Description("Replaces multiple text patterns in a single file atomically. All replacements must be unique occurrences.")]
420+
public string ReplaceMultipleInFile(
421+
[Description("Absolute or relative path to file.")] string path,
422+
[Description("Array of old strings to be replaced. Each must match exactly one occurrence.")] string[] oldStrings,
423+
[Description("Array of new strings to replace with. Must be same length as oldStrings.")] string[] newStrings)
424+
{
425+
if (!File.Exists(path))
426+
{
427+
return $"File {path} does not exist.";
428+
}
429+
430+
if (oldStrings.Length != newStrings.Length)
431+
{
432+
return $"Error: oldStrings array length ({oldStrings.Length}) must match newStrings array length ({newStrings.Length}).";
433+
}
434+
435+
if (oldStrings.Length == 0)
436+
{
437+
return "Error: No replacements specified.";
438+
}
439+
440+
var originalText = FileHelpers.ReadAllText(path);
441+
var currentText = originalText;
442+
443+
// Validate all patterns exist and are unique before making any changes
444+
for (int i = 0; i < oldStrings.Length; i++)
445+
{
446+
var oldStr = oldStrings[i];
447+
var newStr = newStrings[i];
448+
449+
var testReplacement = StringHelpers.ReplaceOnce(currentText, oldStr, newStr, out var countFound);
450+
if (countFound != 1)
451+
{
452+
var message = countFound == 0
453+
? $"Replacement {i + 1}: No occurrences of specified text found."
454+
: $"Replacement {i + 1}: Multiple matches found for specified text; must be unique.";
455+
return $"{message}\nNo changes made to {path}.";
456+
}
457+
}
458+
459+
// All validations passed, now perform the replacements
460+
for (int i = 0; i < oldStrings.Length; i++)
461+
{
462+
var oldStr = oldStrings[i];
463+
var newStr = newStrings[i];
464+
465+
var replacedText = StringHelpers.ReplaceOnce(currentText, oldStr, newStr, out var countFound);
466+
if (replacedText != null && countFound == 1)
467+
{
468+
currentText = replacedText;
469+
}
470+
else
471+
{
472+
// This shouldn't happen since we validated, but handle gracefully
473+
return $"Unexpected error during replacement {i + 1}. File may be in inconsistent state.";
474+
}
475+
}
476+
477+
// Save original content for undo before writing
478+
if (!EditHistory.ContainsKey(path))
479+
{
480+
EditHistory[path] = originalText;
481+
}
482+
483+
File.WriteAllText(path, currentText);
484+
485+
return $"File {path} updated: replaced {oldStrings.Length} occurrences.";
486+
}
381487
[ReadOnly(false)]
382488
[Description("Inserts the specified string `newStr` into the file at `path` after the specified line number (`insertLine`). Use 0 to insert at the beginning of the file.")]
383489
public string Insert(
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
tests:
2+
# ReplaceFileContent tests
3+
- name: ReplaceFileContent with correct line count succeeds
4+
steps:
5+
- name: Run test
6+
bash: |
7+
printf 'line 1\nline 2\nline 3' > test-replace-content.txt
8+
cycod --input "use ReplaceFileContent to replace test-replace-content.txt with 'new content', it has 3 lines" --auto-approve '*'
9+
expect-regex: |
10+
assistant-function: ReplaceFileContent
11+
path.*test-replace-content\.txt
12+
newContent.*new content
13+
oldContentLineCount.*3
14+
Replaced content.*3 lines.*1 lines
15+
timeout: 30000
16+
- name: Cleanup
17+
bash: rm -f test-replace-content.txt
18+
optional: needsAI
19+
20+
- name: ReplaceFileContent with wrong line count fails
21+
steps:
22+
- name: Run test
23+
bash: |
24+
printf 'line 1\nline 2\nline 3' > test-replace-fail.txt
25+
cycod --input "use ReplaceFileContent to replace test-replace-fail.txt with 'new', say it has 5 lines (which is wrong)" --auto-approve '*'
26+
expect-regex: |
27+
assistant-function: ReplaceFileContent
28+
oldContentLineCount.*5
29+
Line count mismatch.*you specified 5 but file has 3 lines
30+
timeout: 30000
31+
- name: Cleanup
32+
bash: rm -f test-replace-fail.txt
33+
optional: needsAI
34+
35+
- name: ReplaceFileContent on non-existent file fails
36+
bash: |
37+
cycod --input "use ReplaceFileContent to replace nonexistent-file.txt with 'content', say it has 1 line" --auto-approve '*'
38+
expect-regex: |
39+
assistant-function: ReplaceFileContent
40+
File.*does not exist.*Use CreateFile
41+
timeout: 30000
42+
optional: needsAI
43+
44+
- name: ReplaceFileContent can be undone
45+
steps:
46+
- name: Run test
47+
bash: |
48+
echo "original content" > test-undo-replace.txt
49+
cycod --input "First use ReplaceFileContent to replace test-undo-replace.txt with 'new content', it has 1 line. Then use UndoEdit to revert it." --auto-approve '*'
50+
expect-regex: |
51+
assistant-function: ReplaceFileContent
52+
Replaced content
53+
assistant-function: UndoEdit
54+
Reverted last edit
55+
timeout: 45000
56+
- name: Cleanup
57+
bash: rm -f test-undo-replace.txt
58+
optional: needsAI
59+
60+
# ReplaceMultipleInFile tests
61+
- name: ReplaceMultipleInFile replaces multiple patterns atomically
62+
steps:
63+
- name: Run test
64+
bash: |
65+
echo "foo bar baz" > test-multi-replace.txt
66+
cycod --input "use ReplaceMultipleInFile on test-multi-replace.txt to replace 'foo' with 'FOO' and 'bar' with 'BAR'" --auto-approve '*'
67+
expect-regex: |
68+
assistant-function: ReplaceMultipleInFile
69+
path.*test-multi-replace\.txt
70+
oldStrings.*foo.*bar
71+
newStrings.*FOO.*BAR
72+
replaced 2 occurrences
73+
timeout: 30000
74+
- name: Cleanup
75+
bash: rm -f test-multi-replace.txt
76+
optional: needsAI
77+
78+
- name: ReplaceMultipleInFile fails if pattern not found
79+
steps:
80+
- name: Run test
81+
bash: |
82+
echo "foo bar" > test-multi-fail.txt
83+
cycod --input "use ReplaceMultipleInFile on test-multi-fail.txt to replace 'foo' with 'FOO' and 'baz' with 'BAZ' (baz doesn't exist)" --auto-approve '*'
84+
expect-regex: |
85+
assistant-function: ReplaceMultipleInFile
86+
No occurrences.*found|not found
87+
No changes made
88+
timeout: 30000
89+
- name: Cleanup
90+
bash: rm -f test-multi-fail.txt
91+
optional: needsAI
92+
93+
- name: ReplaceMultipleInFile fails if array lengths don't match
94+
steps:
95+
- name: Run test
96+
bash: |
97+
echo "foo bar baz" > test-array-length.txt
98+
cycod --input "use ReplaceMultipleInFile on test-array-length.txt with oldStrings=['foo','bar'] but newStrings=['FOO'] (mismatched lengths)" --auto-approve '*'
99+
expect-regex: |
100+
assistant-function: ReplaceMultipleInFile
101+
array length.*must match
102+
timeout: 30000
103+
- name: Cleanup
104+
bash: rm -f test-array-length.txt
105+
optional: needsAI
106+
107+
- name: ReplaceMultipleInFile handles string arrays correctly
108+
steps:
109+
- name: Run test
110+
bash: |
111+
echo "alpha beta gamma delta" > test-string-array.txt
112+
cycod --input "use ReplaceMultipleInFile on test-string-array.txt to replace ['alpha', 'beta', 'gamma'] with ['ALPHA', 'BETA', 'GAMMA']" --auto-approve '*'
113+
expect-regex: |
114+
assistant-function: ReplaceMultipleInFile
115+
replaced 3 occurrences
116+
timeout: 30000
117+
- name: Cleanup
118+
bash: rm -f test-string-array.txt
119+
optional: needsAI
120+
121+
- name: ReplaceMultipleInFile can be undone
122+
steps:
123+
- name: Run test
124+
bash: |
125+
echo "foo bar baz" > test-multi-undo.txt
126+
cycod --input "First use ReplaceMultipleInFile on test-multi-undo.txt to replace 'foo' with 'FOO' and 'bar' with 'BAR'. Then use UndoEdit to revert." --auto-approve '*'
127+
expect-regex: |
128+
assistant-function: ReplaceMultipleInFile
129+
replaced 2 occurrences
130+
assistant-function: UndoEdit
131+
Reverted last edit
132+
timeout: 45000
133+
- name: Cleanup
134+
bash: rm -f test-multi-undo.txt
135+
optional: needsAI
136+
137+
- name: ReplaceMultipleInFile validates uniqueness of patterns
138+
steps:
139+
- name: Run test
140+
bash: |
141+
echo "test test test" > test-unique.txt
142+
cycod --input "use ReplaceMultipleInFile on test-unique.txt to replace 'test' with 'TEST' (but 'test' appears multiple times, so should fail)" --auto-approve '*'
143+
expect-regex: |
144+
assistant-function: ReplaceMultipleInFile
145+
Multiple matches found.*must be unique
146+
No changes made
147+
timeout: 30000
148+
- name: Cleanup
149+
bash: rm -f test-unique.txt
150+
optional: needsAI

0 commit comments

Comments
 (0)