@aaroncql/pim-agent 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +94 -66
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/apply-patch/coordinator.ts +49 -0
  6. package/src/extensions/apply-patch/executor.ts +566 -0
  7. package/src/extensions/apply-patch/index.ts +74 -0
  8. package/src/extensions/apply-patch/matcher.ts +66 -0
  9. package/src/extensions/apply-patch/model.ts +34 -0
  10. package/src/extensions/apply-patch/parser.ts +381 -0
  11. package/src/extensions/apply-patch/render.ts +261 -0
  12. package/src/extensions/apply-patch/schema.ts +43 -0
  13. package/src/extensions/apply-patch/types.ts +30 -0
  14. package/src/extensions/bash/index.ts +3 -3
  15. package/src/extensions/edit/index.ts +2 -1
  16. package/src/extensions/glob/index.ts +3 -1
  17. package/src/extensions/glob/schema.ts +2 -1
  18. package/src/extensions/grep/index.ts +3 -1
  19. package/src/extensions/grep/render.ts +18 -4
  20. package/src/extensions/grep/schema.ts +1 -1
  21. package/src/extensions/read/index.ts +36 -9
  22. package/src/extensions/read/render.ts +31 -3
  23. package/src/extensions/subagent/index.ts +4 -1
  24. package/src/extensions/todo/index.ts +4 -3
  25. package/src/extensions/web-search/index.ts +2 -1
  26. package/src/extensions/write/index.ts +2 -1
  27. package/src/shared/PatchSummary.ts +82 -0
  28. package/src/telegram/Renderer.ts +190 -4
  29. package/src/extensions/bash/capture.test.ts +0 -126
  30. package/src/extensions/bash/format.test.ts +0 -240
  31. package/src/extensions/bash/run.test.ts +0 -262
  32. package/src/extensions/command-picker/ranker.test.ts +0 -46
  33. package/src/extensions/edit/edit.test.ts +0 -285
  34. package/src/extensions/file-picker/catalog.test.ts +0 -263
  35. package/src/extensions/file-picker/index.test.ts +0 -168
  36. package/src/extensions/file-picker/ranker.test.ts +0 -94
  37. package/src/extensions/footer/git.test.ts +0 -76
  38. package/src/extensions/footer/index.test.ts +0 -161
  39. package/src/extensions/footer/segments.test.ts +0 -164
  40. package/src/extensions/glob/glob.test.ts +0 -171
  41. package/src/extensions/glob/index.test.ts +0 -68
  42. package/src/extensions/glob/render.test.ts +0 -126
  43. package/src/extensions/grep/grep.test.ts +0 -387
  44. package/src/extensions/grep/index.test.ts +0 -68
  45. package/src/extensions/grep/render.test.ts +0 -269
  46. package/src/extensions/read/read.test.ts +0 -177
  47. package/src/extensions/read/render.test.ts +0 -61
  48. package/src/extensions/subagent/index.test.ts +0 -44
  49. package/src/extensions/subagent/render.test.ts +0 -292
  50. package/src/extensions/subagent/subagent.test.ts +0 -315
  51. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  52. package/src/extensions/todo/index.test.ts +0 -244
  53. package/src/extensions/todo/render.test.ts +0 -180
  54. package/src/extensions/todo/todo.test.ts +0 -222
  55. package/src/extensions/tps/index.test.ts +0 -254
  56. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  57. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  58. package/src/extensions/web-fetch/render.test.ts +0 -56
  59. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  60. package/src/extensions/web-search/render.test.ts +0 -21
  61. package/src/extensions/web-search/search.test.ts +0 -53
  62. package/src/extensions/working-indicator/index.test.ts +0 -21
  63. package/src/extensions/write/render.test.ts +0 -64
  64. package/src/extensions/write/write.test.ts +0 -108
  65. package/src/shared/DiffLines.test.ts +0 -193
  66. package/src/shared/DiffRenderer.test.ts +0 -206
  67. package/src/shared/EditMatcher.test.ts +0 -123
  68. package/src/shared/FileScanner.test.ts +0 -158
  69. package/src/shared/FuzzyMatcher.test.ts +0 -114
  70. package/src/shared/GitignoreFilter.test.ts +0 -64
  71. package/src/shared/Lines.test.ts +0 -25
  72. package/src/shared/McpClient.test.ts +0 -235
  73. package/src/shared/OutputBudget.test.ts +0 -99
  74. package/src/shared/Paths.test.ts +0 -51
  75. package/src/shared/PimSettings.test.ts +0 -90
  76. package/src/shared/Renderer.test.ts +0 -190
  77. package/src/shared/SpillCache.test.ts +0 -94
  78. package/src/shared/Tools.test.ts +0 -392
  79. package/src/telegram/Config.test.ts +0 -275
  80. package/src/telegram/Markdown.test.ts +0 -143
  81. package/src/telegram/Renderer.test.ts +0 -216
  82. package/src/telegram/SessionRegistry.test.ts +0 -89
  83. package/src/telegram/TaskScheduler.test.ts +0 -278
  84. package/src/telegram/TaskTool.test.ts +0 -179
@@ -1,262 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, stat } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { SpillCache } from "../../shared/SpillCache";
6
- import { killAllActiveBashGroups, runBashCommand } from "./run";
7
- import {
8
- DRAIN_GRACE_MS,
9
- KILL_GRACE_MS,
10
- STREAM_HEAD_BYTES,
11
- STREAM_TAIL_BYTES,
12
- } from "./schema";
13
-
14
- let previousPimHomeDir: string | undefined;
15
- let testPimHomeDir: string | undefined;
16
-
17
- beforeAll(async () => {
18
- previousPimHomeDir = process.env.PIM_HOME_DIR;
19
- testPimHomeDir = await mkdtemp(join(tmpdir(), "pim-bash-home-"));
20
- process.env.PIM_HOME_DIR = testPimHomeDir;
21
- });
22
-
23
- afterAll(async () => {
24
- if (previousPimHomeDir === undefined) {
25
- delete process.env.PIM_HOME_DIR;
26
- } else {
27
- process.env.PIM_HOME_DIR = previousPimHomeDir;
28
- }
29
- if (testPimHomeDir) {
30
- await rm(testPimHomeDir, { recursive: true, force: true });
31
- }
32
- });
33
-
34
- function shellQuote(value: string): string {
35
- return `'${value.replaceAll("'", `'"'"'`)}'`;
36
- }
37
-
38
- async function waitForFile(path: string, timeoutMs: number): Promise<void> {
39
- const deadline = Date.now() + timeoutMs;
40
- while (Date.now() < deadline) {
41
- if (await Bun.file(path).exists()) {
42
- return;
43
- }
44
- await Bun.sleep(10);
45
- }
46
- throw new Error(`Timed out waiting for ${path}`);
47
- }
48
-
49
- async function waitForNoProcess(
50
- marker: string,
51
- timeoutMs: number
52
- ): Promise<void> {
53
- const deadline = Date.now() + timeoutMs;
54
- while (true) {
55
- const probe = Bun.spawnSync({ cmd: ["pgrep", "-f", marker] });
56
- if (probe.exitCode !== 0) {
57
- return;
58
- }
59
- if (Date.now() >= deadline) {
60
- throw new Error(`Process still running for marker ${marker}`);
61
- }
62
- await Bun.sleep(25);
63
- }
64
- }
65
-
66
- describe("runBashCommand (integration)", () => {
67
- test("captures stdout from a successful command", async () => {
68
- const r = await runBashCommand(
69
- "echo hello",
70
- 5000,
71
- undefined,
72
- process.cwd()
73
- );
74
- expect(r.exitCode).toBe(0);
75
- expect(r.aborted).toBe(false);
76
- expect(r.timedOut).toBe(false);
77
- expect(r.stdout.text.trim()).toBe("hello");
78
- expect(r.stderr.totalBytes).toBe(0);
79
- });
80
-
81
- test("captures stderr and non-zero exit", async () => {
82
- const r = await runBashCommand(
83
- "echo oops 1>&2; exit 3",
84
- 5000,
85
- undefined,
86
- process.cwd()
87
- );
88
- expect(r.exitCode).toBe(3);
89
- expect(r.stderr.text.trim()).toBe("oops");
90
- });
91
-
92
- test("respects cwd", async () => {
93
- const r = await runBashCommand("pwd", 5000, undefined, "/tmp");
94
- expect(r.exitCode).toBe(0);
95
- expect(r.stdout.text.trim()).toBe("/tmp");
96
- });
97
-
98
- test("times out and reports timedOut", async () => {
99
- const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
100
- expect(r.timedOut).toBe(true);
101
- expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
102
- });
103
-
104
- test("aborts when signal fires", async () => {
105
- const ctrl = new AbortController();
106
- const promise = runBashCommand("sleep 5", 5000, ctrl.signal, process.cwd());
107
- ctrl.abort();
108
- const r = await promise;
109
- expect(r.aborted).toBe(true);
110
- });
111
-
112
- test("returns promptly when a backgrounded child inherits the pipe", async () => {
113
- const startedAt = Date.now();
114
- const r = await runBashCommand(
115
- "nohup sleep 47 > /dev/null 2>&1 & disown; echo done",
116
- 5000,
117
- undefined,
118
- process.cwd()
119
- );
120
- const elapsed = Date.now() - startedAt;
121
- expect(r.exitCode).toBe(0);
122
- expect(r.timedOut).toBe(false);
123
- expect(r.stdout.text.trim()).toBe("done");
124
- expect(elapsed).toBeLessThan(2000);
125
- // clean up the orphaned sleep so it doesn't linger
126
- try {
127
- Bun.spawnSync({ cmd: ["pkill", "-f", "sleep 47"] });
128
- } catch {}
129
- });
130
-
131
- test("timeout kills the whole process group", async () => {
132
- const marker = `pim-test-timeout-${Date.now()}`;
133
- const startedAt = Date.now();
134
- const r = await runBashCommand(
135
- `bash -c ${shellQuote(`exec -a ${marker} sleep 60`)}`,
136
- 50,
137
- undefined,
138
- process.cwd()
139
- );
140
- const elapsed = Date.now() - startedAt;
141
- expect(r.timedOut).toBe(true);
142
- expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
143
- expect(elapsed).toBeLessThan(KILL_GRACE_MS + 2000);
144
- await waitForNoProcess(marker, KILL_GRACE_MS + 500);
145
- });
146
-
147
- test("does not crash on timeout while drains still hold readers", async () => {
148
- // Regression: stream.cancel() on a locked stream rejects (Bun throws
149
- // synchronously). Drains run fire-and-forget, so when a quiet command
150
- // times out (no output → drain blocked on read), the finally hits
151
- // cancel before drain has released. Unhandled rejection would crash Bun.
152
- const rejections: unknown[] = [];
153
- const onRejection = (err: unknown) => rejections.push(err);
154
- process.on("unhandledRejection", onRejection);
155
- try {
156
- const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
157
- expect(r.timedOut).toBe(true);
158
- await Bun.sleep(25);
159
- expect(rejections).toEqual([]);
160
- } finally {
161
- process.off("unhandledRejection", onRejection);
162
- }
163
- });
164
-
165
- test("bounded drain returns even when a daemon escapes our process group", async () => {
166
- // A child that calls setsid itself leaves our pgid and survives killGroup.
167
- // If it keeps the pipe open, drain would block forever; the DRAIN_GRACE_MS
168
- // bound forces us to return anyway. The marker lets us clean up after.
169
- const marker = `pim-test-detached-${Date.now()}`;
170
- const startedAt = Date.now();
171
- const r = await runBashCommand(
172
- `setsid bash -c 'sleep 60; echo ${marker}' > /tmp/${marker}.out 2>&1 < /dev/null & disown; echo done`,
173
- 5000,
174
- undefined,
175
- process.cwd()
176
- );
177
- const elapsed = Date.now() - startedAt;
178
- expect(r.exitCode).toBe(0);
179
- expect(r.stdout.text.trim()).toBe("done");
180
- expect(elapsed).toBeLessThan(DRAIN_GRACE_MS + 2000);
181
- try {
182
- Bun.spawnSync({ cmd: ["pkill", "-f", marker] });
183
- Bun.spawnSync({ cmd: ["rm", "-f", `/tmp/${marker}.out`] });
184
- } catch {}
185
- });
186
-
187
- test("killAllActiveBashGroups sweeps in-flight subtrees", async () => {
188
- const id = Date.now();
189
- const marker = `/tmp/pim-test-active-${id}.marker`;
190
- const ready = `/tmp/pim-test-active-${id}.ready`;
191
- const processMarker = `pim-test-active-${id}`;
192
- const pending = runBashCommand(
193
- `touch ${shellQuote(ready)}; bash -c ${shellQuote(`exec -a ${processMarker} sleep 30`)} && touch ${shellQuote(marker)}`,
194
- 30_000,
195
- undefined,
196
- process.cwd()
197
- );
198
- try {
199
- await waitForFile(ready, 1000);
200
- killAllActiveBashGroups("SIGTERM");
201
- const result = await pending;
202
- expect(result.exitCode === null || result.exitCode !== 0).toBe(true);
203
- await waitForNoProcess(processMarker, KILL_GRACE_MS + 500);
204
- expect(await Bun.file(marker).exists()).toBe(false);
205
- } finally {
206
- Bun.spawnSync({ cmd: ["rm", "-f", marker, ready] });
207
- }
208
- });
209
-
210
- test("truncates very large stdout", async () => {
211
- const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 1000;
212
- const r = await runBashCommand(
213
- `head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
214
- 5000,
215
- undefined,
216
- process.cwd()
217
- );
218
- expect(r.exitCode).toBe(0);
219
- expect(r.stdout.totalBytes).toBe(totalBytes);
220
- expect(r.stdout.truncated).toBe(true);
221
- expect(r.stdout.text).toContain("bytes truncated");
222
- });
223
-
224
- test("spills full stdout to ~/.pim/cache when truncated", async () => {
225
- const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 4096;
226
- const r = await runBashCommand(
227
- `head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
228
- 5000,
229
- undefined,
230
- process.cwd()
231
- );
232
- try {
233
- expect(r.exitCode).toBe(0);
234
- expect(r.stdout.truncated).toBe(true);
235
- expect(r.stdout.path).toBeTruthy();
236
- expect(r.stdout.path!.startsWith(join(SpillCache.dir(), "bash-"))).toBe(
237
- true
238
- );
239
- expect(r.stdout.path!.endsWith(".out")).toBe(true);
240
- const cacheMode = (await stat(SpillCache.dir())).mode & 0o777;
241
- const spillMode = (await stat(r.stdout.path!)).mode & 0o777;
242
- expect(cacheMode).toBe(0o700);
243
- expect(spillMode).toBe(0o600);
244
- const spilled = await Bun.file(r.stdout.path!).text();
245
- expect(spilled.length).toBe(totalBytes);
246
- expect(spilled).toBe("A".repeat(totalBytes));
247
- } finally {
248
- if (r.stdout.path) {
249
- try {
250
- Bun.spawnSync({ cmd: ["rm", "-f", r.stdout.path] });
251
- } catch {}
252
- }
253
- }
254
- });
255
-
256
- test("omits spill path when stream is empty", async () => {
257
- const r = await runBashCommand("true", 5000, undefined, process.cwd());
258
- expect(r.exitCode).toBe(0);
259
- expect(r.stdout.path).toBeNull();
260
- expect(r.stderr.path).toBeNull();
261
- });
262
- });
@@ -1,46 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
- import { rank } from "./ranker";
4
-
5
- const item = (name: string, description?: string): AutocompleteItem => ({
6
- value: name,
7
- label: name,
8
- ...(description !== undefined && { description }),
9
- });
10
-
11
- test("empty query returns items alphabetically by label", () => {
12
- const items = rank("", [
13
- item("rename", "Rename the session."),
14
- item("clear", "Clear the session."),
15
- item("help", "Show help."),
16
- ]);
17
-
18
- expect(items.map((i) => i.value)).toEqual(["clear", "help", "rename"]);
19
- });
20
-
21
- test("query ranks fuzzy matches by score", () => {
22
- const items = rank("cl", [
23
- item("rename", "Rename the session."),
24
- item("clear", "Clear the session."),
25
- item("help", "Show help."),
26
- ]);
27
-
28
- expect(items[0]?.value).toBe("clear");
29
- });
30
-
31
- test("matches against description when label doesn't contain query", () => {
32
- const items = rank("rename", [
33
- item("noop", "fully unrelated"),
34
- item("x", "rename the session"),
35
- ]);
36
-
37
- expect(items[0]?.value).toBe("x");
38
- });
39
-
40
- test("limit caps the returned items", () => {
41
- const items = rank("", [item("c"), item("a"), item("b"), item("d")], {
42
- limit: 2,
43
- });
44
-
45
- expect(items.map((i) => i.value)).toEqual(["a", "b"]);
46
- });
@@ -1,285 +0,0 @@
1
- import {
2
- chmod,
3
- link,
4
- mkdtemp,
5
- readFile,
6
- rm,
7
- stat,
8
- symlink,
9
- writeFile,
10
- } from "node:fs/promises";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
- import { afterAll, describe, expect, test } from "bun:test";
14
- import { editFile, formatEditSummary } from "./edit";
15
- import type { RawEdit } from "./schema";
16
-
17
- const tempRoots: string[] = [];
18
-
19
- const tempRoot = async (): Promise<string> => {
20
- const root = await mkdtemp(join(tmpdir(), "pim-edit-tool-"));
21
- tempRoots.push(root);
22
- return root;
23
- };
24
-
25
- afterAll(async () => {
26
- await Promise.all(
27
- tempRoots.map((root) => rm(root, { force: true, recursive: true }))
28
- );
29
- });
30
-
31
- describe("editFile", () => {
32
- test("applies exact single edit", async () => {
33
- const root = await tempRoot();
34
- const path = join(root, "notes.txt");
35
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
36
-
37
- const outcome = await editFile(path, [
38
- { oldString: "beta", newString: "delta" },
39
- ]);
40
-
41
- expect(await readFile(path, "utf8")).toBe("alpha\ndelta\ngamma");
42
- expect(outcome.editCount).toBe(1);
43
- expect(outcome.noops).toEqual([]);
44
- expect(outcome.resolvedEdits[0]?.strategy).toBe("simple");
45
- expect(formatEditSummary(path, outcome)).toBe(
46
- `1 edit made to ${path}: lines 2.`
47
- );
48
- });
49
-
50
- test("applies multi-edit batch against pre-batch content", async () => {
51
- const root = await tempRoot();
52
- const path = join(root, "notes.txt");
53
- await writeFile(path, "alpha\nbeta\ngamma\ndelta", "utf8");
54
-
55
- await editFile(path, [
56
- { oldString: "beta", newString: "BETA" },
57
- { oldString: "delta", newString: "DELTA" },
58
- ]);
59
-
60
- expect(await readFile(path, "utf8")).toBe("alpha\nBETA\ngamma\nDELTA");
61
- });
62
-
63
- test("rejects sequential transformations in one batch", async () => {
64
- const root = await tempRoot();
65
- const path = join(root, "notes.txt");
66
- await writeFile(path, "alpha\nbeta", "utf8");
67
-
68
- await expect(
69
- editFile(path, [
70
- { oldString: "beta", newString: "delta" },
71
- { oldString: "delta", newString: "omega" },
72
- ])
73
- ).rejects.toThrow(/oldString was not found/);
74
- expect(await readFile(path, "utf8")).toBe("alpha\nbeta");
75
- });
76
-
77
- test("rejects overlapping resolved ranges", async () => {
78
- const root = await tempRoot();
79
- const path = join(root, "notes.txt");
80
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
81
-
82
- await expect(
83
- editFile(path, [
84
- { oldString: "beta\ngamma", newString: "merged" },
85
- { oldString: "gamma", newString: "changed" },
86
- ])
87
- ).rejects.toThrow(/target overlapping byte ranges/);
88
- expect(await readFile(path, "utf8")).toBe("alpha\nbeta\ngamma");
89
- });
90
-
91
- test("replaceAll replaces all occurrences and lists every range", async () => {
92
- const root = await tempRoot();
93
- const path = join(root, "notes.txt");
94
- await writeFile(path, "foo\nbar\nfoo", "utf8");
95
-
96
- const outcome = await editFile(path, [
97
- { oldString: "foo", newString: "baz", replaceAll: true },
98
- ]);
99
-
100
- expect(await readFile(path, "utf8")).toBe("baz\nbar\nbaz");
101
- expect(outcome.ranges).toEqual(["1", "3"]);
102
- expect(formatEditSummary(path, outcome)).toBe(
103
- `1 edit made to ${path}: lines 1 and 3.`
104
- );
105
- });
106
-
107
- test("replaceAll overlap with another edit is rejected", async () => {
108
- const root = await tempRoot();
109
- const path = join(root, "notes.txt");
110
- await writeFile(path, "foo\nbar\nfoo", "utf8");
111
-
112
- await expect(
113
- editFile(path, [
114
- { oldString: "foo", newString: "baz", replaceAll: true },
115
- { oldString: "foo\nbar", newString: "merged" },
116
- ])
117
- ).rejects.toThrow(/target overlapping byte ranges/);
118
- });
119
-
120
- test("rejects duplicate edits", async () => {
121
- const root = await tempRoot();
122
- const path = join(root, "notes.txt");
123
- await writeFile(path, "alpha\nbeta", "utf8");
124
-
125
- const edit: RawEdit = { oldString: "beta", newString: "delta" };
126
-
127
- await expect(editFile(path, [edit, edit])).rejects.toThrow(
128
- /Edits 0 and 1 are identical/
129
- );
130
- });
131
-
132
- test("rejects all-noop fuzzy batches and tracks partial noops", async () => {
133
- const root = await tempRoot();
134
- const noopPath = join(root, "noop.txt");
135
- const partialPath = join(root, "partial.txt");
136
- await writeFile(noopPath, " beta", "utf8");
137
- await writeFile(partialPath, "alpha\n beta", "utf8");
138
-
139
- await expect(
140
- editFile(noopPath, [{ oldString: "beta ", newString: " beta" }])
141
- ).rejects.toThrow(/All edits were no-ops/);
142
-
143
- const outcome = await editFile(partialPath, [
144
- { oldString: "alpha", newString: "ALPHA" },
145
- { oldString: "beta ", newString: " beta" },
146
- ]);
147
-
148
- expect(await readFile(partialPath, "utf8")).toBe("ALPHA\n beta");
149
- expect(outcome.noops).toEqual([{ index: 1, range: "2" }]);
150
- });
151
-
152
- test("reports closest regions when oldString is not found", async () => {
153
- const root = await tempRoot();
154
- const path = join(root, "notes.txt");
155
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
156
-
157
- await expect(
158
- editFile(path, [{ oldString: "betx", newString: "delta" }])
159
- ).rejects.toThrow(/oldString was not found[\s\S]*lines 2/);
160
- });
161
-
162
- test("reports bare not found when nothing is close", async () => {
163
- const root = await tempRoot();
164
- const path = join(root, "notes.txt");
165
- await writeFile(path, "aaaa\nbbbb", "utf8");
166
-
167
- await expect(
168
- editFile(path, [{ oldString: "zzzz", newString: "delta" }])
169
- ).rejects.toThrow("oldString was not found in the file.");
170
- });
171
-
172
- test("rejects multiple matches without replaceAll", async () => {
173
- const root = await tempRoot();
174
- const path = join(root, "notes.txt");
175
- await writeFile(path, "foo\nbar\nfoo", "utf8");
176
-
177
- await expect(
178
- editFile(path, [{ oldString: "foo", newString: "baz" }])
179
- ).rejects.toThrow(/matched multiple regions/);
180
- });
181
-
182
- test("rejects escape drift after fuzzy match", async () => {
183
- const root = await tempRoot();
184
- const path = join(root, "notes.txt");
185
- await writeFile(path, " beta", "utf8");
186
-
187
- await expect(
188
- editFile(path, [{ oldString: "beta ", newString: "new\\nvalue" }])
189
- ).rejects.toThrow(/newString contains literal escape text/);
190
- });
191
-
192
- test("preserves UTF-8 BOM when editing", async () => {
193
- const root = await tempRoot();
194
- const path = join(root, "bom.txt");
195
- await writeFile(path, "\uFEFFalpha\nbeta", "utf8");
196
-
197
- await editFile(path, [{ oldString: "beta", newString: "delta" }]);
198
-
199
- const bytes = await Bun.file(path).bytes();
200
- expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
201
- expect(await readFile(path, "utf8")).toBe("\uFEFFalpha\ndelta");
202
- });
203
-
204
- test("preserves CRLF line endings when editing", async () => {
205
- const root = await tempRoot();
206
- const path = join(root, "crlf.txt");
207
- await writeFile(path, "alpha\r\nbeta\r\n", "utf8");
208
-
209
- await editFile(path, [{ oldString: "beta", newString: "delta" }]);
210
-
211
- expect(await readFile(path, "utf8")).toBe("alpha\r\ndelta\r\n");
212
- });
213
-
214
- test("updates symlink targets and preserves hard-link inodes plus mode", async () => {
215
- const root = await tempRoot();
216
- const target = join(root, "target.txt");
217
- const linked = join(root, "linked.txt");
218
- const alias = join(root, "alias.txt");
219
-
220
- await writeFile(target, "alpha\nbeta", "utf8");
221
- await chmod(target, 0o640);
222
- await link(target, linked);
223
- await symlink(target, alias);
224
-
225
- const before = await stat(target);
226
-
227
- await editFile(alias, [{ oldString: "beta", newString: "delta" }]);
228
-
229
- const after = await stat(target);
230
- expect(await readFile(target, "utf8")).toBe("alpha\ndelta");
231
- expect(await readFile(linked, "utf8")).toBe("alpha\ndelta");
232
- expect(after.ino).toBe(before.ino);
233
- expect(Number(after.mode) & 0o777).toBe(0o640);
234
- });
235
-
236
- test("serializes concurrent edits on the same path", async () => {
237
- const root = await tempRoot();
238
- const path = join(root, "notes.txt");
239
- await writeFile(path, "0\n", "utf8");
240
-
241
- const concurrent = await Promise.all([
242
- editFile(path, [{ oldString: "0", newString: "0\n1" }]),
243
- editFile(path, [{ oldString: "0", newString: "0\n2" }]),
244
- editFile(path, [{ oldString: "0", newString: "0\n3" }]),
245
- ]);
246
-
247
- const final = await readFile(path, "utf8");
248
- expect(final).toContain("0");
249
- expect(concurrent).toHaveLength(3);
250
- });
251
-
252
- test("buildDiff splits distant edits into separate hunks", async () => {
253
- const root = await tempRoot();
254
- const path = join(root, "notes.txt");
255
- const lines = Array.from(
256
- { length: 200 },
257
- (_, index) => `line ${index + 1}`
258
- );
259
- await writeFile(path, lines.join("\n"), "utf8");
260
-
261
- const outcome = await editFile(path, [
262
- { oldString: "line 1\nline 2", newString: "changed 1\nline 2" },
263
- { oldString: "line 199\nline 200", newString: "line 199\nchanged 200" },
264
- ]);
265
-
266
- expect(outcome.diff?.hunks).toHaveLength(2);
267
- expect(formatEditSummary(path, outcome)).toBe(
268
- `2 edits made to ${path}: lines 1-2 and 199-200.`
269
- );
270
- });
271
-
272
- test("rejects directories and binary files", async () => {
273
- const root = await tempRoot();
274
- const binary = join(root, "bin.dat");
275
- await Bun.write(binary, new Uint8Array([0, 1, 2, 0, 4]));
276
-
277
- await expect(
278
- editFile(root, [{ oldString: "x", newString: "y" }])
279
- ).rejects.toThrow(/Path is a directory/);
280
-
281
- await expect(
282
- editFile(binary, [{ oldString: "x", newString: "y" }])
283
- ).rejects.toThrow(/binary file/);
284
- });
285
- });