edit.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import { afterAll, afterEach, describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { Effect, Layer, ManagedRuntime } from "effect"
  5. import { EditTool } from "../../src/tool/edit"
  6. import { Instance } from "../../src/project/instance"
  7. import { tmpdir } from "../fixture/fixture"
  8. import { FileTime } from "../../src/file/time"
  9. import { LSP } from "../../src/lsp"
  10. import { AppFileSystem } from "../../src/filesystem"
  11. import { Format } from "../../src/format"
  12. import { Bus } from "../../src/bus"
  13. import { BusEvent } from "../../src/bus/bus-event"
  14. import { SessionID, MessageID } from "../../src/session/schema"
  15. const ctx = {
  16. sessionID: SessionID.make("ses_test-edit-session"),
  17. messageID: MessageID.make(""),
  18. callID: "",
  19. agent: "build",
  20. abort: AbortSignal.any([]),
  21. messages: [],
  22. metadata: () => {},
  23. ask: () => Effect.void,
  24. }
  25. afterEach(async () => {
  26. await Instance.disposeAll()
  27. })
  28. async function touch(file: string, time: number) {
  29. const date = new Date(time)
  30. await fs.utimes(file, date, date)
  31. }
  32. const runtime = ManagedRuntime.make(
  33. Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
  34. )
  35. afterAll(async () => {
  36. await runtime.dispose()
  37. })
  38. const resolve = () =>
  39. runtime.runPromise(
  40. Effect.gen(function* () {
  41. const info = yield* EditTool
  42. return yield* Effect.promise(() => info.init())
  43. }),
  44. )
  45. const readFileTime = (sessionID: SessionID, filepath: string) =>
  46. runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
  47. const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
  48. runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
  49. describe("tool.edit", () => {
  50. describe("creating new files", () => {
  51. test("creates new file when oldString is empty", async () => {
  52. await using tmp = await tmpdir()
  53. const filepath = path.join(tmp.path, "newfile.txt")
  54. await Instance.provide({
  55. directory: tmp.path,
  56. fn: async () => {
  57. const edit = await resolve()
  58. const result = await Effect.runPromise(
  59. edit.execute(
  60. {
  61. filePath: filepath,
  62. oldString: "",
  63. newString: "new content",
  64. },
  65. ctx,
  66. ),
  67. )
  68. expect(result.metadata.diff).toContain("new content")
  69. const content = await fs.readFile(filepath, "utf-8")
  70. expect(content).toBe("new content")
  71. },
  72. })
  73. })
  74. test("creates new file with nested directories", async () => {
  75. await using tmp = await tmpdir()
  76. const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
  77. await Instance.provide({
  78. directory: tmp.path,
  79. fn: async () => {
  80. const edit = await resolve()
  81. await Effect.runPromise(
  82. edit.execute(
  83. {
  84. filePath: filepath,
  85. oldString: "",
  86. newString: "nested file",
  87. },
  88. ctx,
  89. ),
  90. )
  91. const content = await fs.readFile(filepath, "utf-8")
  92. expect(content).toBe("nested file")
  93. },
  94. })
  95. })
  96. test("emits add event for new files", async () => {
  97. await using tmp = await tmpdir()
  98. const filepath = path.join(tmp.path, "new.txt")
  99. await Instance.provide({
  100. directory: tmp.path,
  101. fn: async () => {
  102. const { FileWatcher } = await import("../../src/file/watcher")
  103. const events: string[] = []
  104. const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
  105. const edit = await resolve()
  106. await Effect.runPromise(
  107. edit.execute(
  108. {
  109. filePath: filepath,
  110. oldString: "",
  111. newString: "content",
  112. },
  113. ctx,
  114. ),
  115. )
  116. expect(events).toContain("updated")
  117. unsubUpdated()
  118. },
  119. })
  120. })
  121. })
  122. describe("editing existing files", () => {
  123. test("replaces text in existing file", async () => {
  124. await using tmp = await tmpdir()
  125. const filepath = path.join(tmp.path, "existing.txt")
  126. await fs.writeFile(filepath, "old content here", "utf-8")
  127. await Instance.provide({
  128. directory: tmp.path,
  129. fn: async () => {
  130. await readFileTime(ctx.sessionID, filepath)
  131. const edit = await resolve()
  132. const result = await Effect.runPromise(
  133. edit.execute(
  134. {
  135. filePath: filepath,
  136. oldString: "old content",
  137. newString: "new content",
  138. },
  139. ctx,
  140. ),
  141. )
  142. expect(result.output).toContain("Edit applied successfully")
  143. const content = await fs.readFile(filepath, "utf-8")
  144. expect(content).toBe("new content here")
  145. },
  146. })
  147. })
  148. test("throws error when file does not exist", async () => {
  149. await using tmp = await tmpdir()
  150. const filepath = path.join(tmp.path, "nonexistent.txt")
  151. await Instance.provide({
  152. directory: tmp.path,
  153. fn: async () => {
  154. await readFileTime(ctx.sessionID, filepath)
  155. const edit = await resolve()
  156. await expect(
  157. Effect.runPromise(
  158. edit.execute(
  159. {
  160. filePath: filepath,
  161. oldString: "old",
  162. newString: "new",
  163. },
  164. ctx,
  165. ),
  166. ),
  167. ).rejects.toThrow("not found")
  168. },
  169. })
  170. })
  171. test("throws error when oldString equals newString", async () => {
  172. await using tmp = await tmpdir()
  173. const filepath = path.join(tmp.path, "file.txt")
  174. await fs.writeFile(filepath, "content", "utf-8")
  175. await Instance.provide({
  176. directory: tmp.path,
  177. fn: async () => {
  178. const edit = await resolve()
  179. await expect(
  180. Effect.runPromise(
  181. edit.execute(
  182. {
  183. filePath: filepath,
  184. oldString: "same",
  185. newString: "same",
  186. },
  187. ctx,
  188. ),
  189. ),
  190. ).rejects.toThrow("identical")
  191. },
  192. })
  193. })
  194. test("throws error when oldString not found in file", async () => {
  195. await using tmp = await tmpdir()
  196. const filepath = path.join(tmp.path, "file.txt")
  197. await fs.writeFile(filepath, "actual content", "utf-8")
  198. await Instance.provide({
  199. directory: tmp.path,
  200. fn: async () => {
  201. await readFileTime(ctx.sessionID, filepath)
  202. const edit = await resolve()
  203. await expect(
  204. Effect.runPromise(
  205. edit.execute(
  206. {
  207. filePath: filepath,
  208. oldString: "not in file",
  209. newString: "replacement",
  210. },
  211. ctx,
  212. ),
  213. ),
  214. ).rejects.toThrow()
  215. },
  216. })
  217. })
  218. test("throws error when file was not read first (FileTime)", async () => {
  219. await using tmp = await tmpdir()
  220. const filepath = path.join(tmp.path, "file.txt")
  221. await fs.writeFile(filepath, "content", "utf-8")
  222. await Instance.provide({
  223. directory: tmp.path,
  224. fn: async () => {
  225. const edit = await resolve()
  226. await expect(
  227. Effect.runPromise(
  228. edit.execute(
  229. {
  230. filePath: filepath,
  231. oldString: "content",
  232. newString: "modified",
  233. },
  234. ctx,
  235. ),
  236. ),
  237. ).rejects.toThrow("You must read file")
  238. },
  239. })
  240. })
  241. test("throws error when file has been modified since read", async () => {
  242. await using tmp = await tmpdir()
  243. const filepath = path.join(tmp.path, "file.txt")
  244. await fs.writeFile(filepath, "original content", "utf-8")
  245. await touch(filepath, 1_000)
  246. await Instance.provide({
  247. directory: tmp.path,
  248. fn: async () => {
  249. // Read first
  250. await readFileTime(ctx.sessionID, filepath)
  251. // Simulate external modification
  252. await fs.writeFile(filepath, "modified externally", "utf-8")
  253. await touch(filepath, 2_000)
  254. // Try to edit with the new content
  255. const edit = await resolve()
  256. await expect(
  257. Effect.runPromise(
  258. edit.execute(
  259. {
  260. filePath: filepath,
  261. oldString: "modified externally",
  262. newString: "edited",
  263. },
  264. ctx,
  265. ),
  266. ),
  267. ).rejects.toThrow("modified since it was last read")
  268. },
  269. })
  270. })
  271. test("replaces all occurrences with replaceAll option", async () => {
  272. await using tmp = await tmpdir()
  273. const filepath = path.join(tmp.path, "file.txt")
  274. await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
  275. await Instance.provide({
  276. directory: tmp.path,
  277. fn: async () => {
  278. await readFileTime(ctx.sessionID, filepath)
  279. const edit = await resolve()
  280. await Effect.runPromise(
  281. edit.execute(
  282. {
  283. filePath: filepath,
  284. oldString: "foo",
  285. newString: "qux",
  286. replaceAll: true,
  287. },
  288. ctx,
  289. ),
  290. )
  291. const content = await fs.readFile(filepath, "utf-8")
  292. expect(content).toBe("qux bar qux baz qux")
  293. },
  294. })
  295. })
  296. test("emits change event for existing files", async () => {
  297. await using tmp = await tmpdir()
  298. const filepath = path.join(tmp.path, "file.txt")
  299. await fs.writeFile(filepath, "original", "utf-8")
  300. await Instance.provide({
  301. directory: tmp.path,
  302. fn: async () => {
  303. await readFileTime(ctx.sessionID, filepath)
  304. const { FileWatcher } = await import("../../src/file/watcher")
  305. const events: string[] = []
  306. const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
  307. const edit = await resolve()
  308. await Effect.runPromise(
  309. edit.execute(
  310. {
  311. filePath: filepath,
  312. oldString: "original",
  313. newString: "modified",
  314. },
  315. ctx,
  316. ),
  317. )
  318. expect(events).toContain("updated")
  319. unsubUpdated()
  320. },
  321. })
  322. })
  323. })
  324. describe("edge cases", () => {
  325. test("handles multiline replacements", async () => {
  326. await using tmp = await tmpdir()
  327. const filepath = path.join(tmp.path, "file.txt")
  328. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  329. await Instance.provide({
  330. directory: tmp.path,
  331. fn: async () => {
  332. await readFileTime(ctx.sessionID, filepath)
  333. const edit = await resolve()
  334. await Effect.runPromise(
  335. edit.execute(
  336. {
  337. filePath: filepath,
  338. oldString: "line2",
  339. newString: "new line 2\nextra line",
  340. },
  341. ctx,
  342. ),
  343. )
  344. const content = await fs.readFile(filepath, "utf-8")
  345. expect(content).toBe("line1\nnew line 2\nextra line\nline3")
  346. },
  347. })
  348. })
  349. test("handles CRLF line endings", async () => {
  350. await using tmp = await tmpdir()
  351. const filepath = path.join(tmp.path, "file.txt")
  352. await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
  353. await Instance.provide({
  354. directory: tmp.path,
  355. fn: async () => {
  356. await readFileTime(ctx.sessionID, filepath)
  357. const edit = await resolve()
  358. await Effect.runPromise(
  359. edit.execute(
  360. {
  361. filePath: filepath,
  362. oldString: "old",
  363. newString: "new",
  364. },
  365. ctx,
  366. ),
  367. )
  368. const content = await fs.readFile(filepath, "utf-8")
  369. expect(content).toBe("line1\r\nnew\r\nline3")
  370. },
  371. })
  372. })
  373. test("throws error when oldString equals newString", async () => {
  374. await using tmp = await tmpdir()
  375. const filepath = path.join(tmp.path, "file.txt")
  376. await fs.writeFile(filepath, "content", "utf-8")
  377. await Instance.provide({
  378. directory: tmp.path,
  379. fn: async () => {
  380. const edit = await resolve()
  381. await expect(
  382. Effect.runPromise(
  383. edit.execute(
  384. {
  385. filePath: filepath,
  386. oldString: "",
  387. newString: "",
  388. },
  389. ctx,
  390. ),
  391. ),
  392. ).rejects.toThrow("identical")
  393. },
  394. })
  395. })
  396. test("throws error when path is directory", async () => {
  397. await using tmp = await tmpdir()
  398. const dirpath = path.join(tmp.path, "adir")
  399. await fs.mkdir(dirpath)
  400. await Instance.provide({
  401. directory: tmp.path,
  402. fn: async () => {
  403. await readFileTime(ctx.sessionID, dirpath)
  404. const edit = await resolve()
  405. await expect(
  406. Effect.runPromise(
  407. edit.execute(
  408. {
  409. filePath: dirpath,
  410. oldString: "old",
  411. newString: "new",
  412. },
  413. ctx,
  414. ),
  415. ),
  416. ).rejects.toThrow("directory")
  417. },
  418. })
  419. })
  420. test("tracks file diff statistics", async () => {
  421. await using tmp = await tmpdir()
  422. const filepath = path.join(tmp.path, "file.txt")
  423. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  424. await Instance.provide({
  425. directory: tmp.path,
  426. fn: async () => {
  427. await readFileTime(ctx.sessionID, filepath)
  428. const edit = await resolve()
  429. const result = await Effect.runPromise(
  430. edit.execute(
  431. {
  432. filePath: filepath,
  433. oldString: "line2",
  434. newString: "new line a\nnew line b",
  435. },
  436. ctx,
  437. ),
  438. )
  439. expect(result.metadata.filediff).toBeDefined()
  440. expect(result.metadata.filediff.file).toBe(filepath)
  441. expect(result.metadata.filediff.additions).toBeGreaterThan(0)
  442. },
  443. })
  444. })
  445. })
  446. describe("line endings", () => {
  447. const old = "alpha\nbeta\ngamma"
  448. const next = "alpha\nbeta-updated\ngamma"
  449. const alt = "alpha\nbeta\nomega"
  450. const normalize = (text: string, ending: "\n" | "\r\n") => {
  451. const normalized = text.replaceAll("\r\n", "\n")
  452. if (ending === "\n") return normalized
  453. return normalized.replaceAll("\n", "\r\n")
  454. }
  455. const count = (content: string) => {
  456. const crlf = content.match(/\r\n/g)?.length ?? 0
  457. const lf = content.match(/\n/g)?.length ?? 0
  458. return {
  459. crlf,
  460. lf: lf - crlf,
  461. }
  462. }
  463. const expectLf = (content: string) => {
  464. const counts = count(content)
  465. expect(counts.crlf).toBe(0)
  466. expect(counts.lf).toBeGreaterThan(0)
  467. }
  468. const expectCrlf = (content: string) => {
  469. const counts = count(content)
  470. expect(counts.lf).toBe(0)
  471. expect(counts.crlf).toBeGreaterThan(0)
  472. }
  473. type Input = {
  474. content: string
  475. oldString: string
  476. newString: string
  477. replaceAll?: boolean
  478. }
  479. const apply = async (input: Input) => {
  480. await using tmp = await tmpdir({
  481. init: async (dir) => {
  482. await Bun.write(path.join(dir, "test.txt"), input.content)
  483. },
  484. })
  485. return await Instance.provide({
  486. directory: tmp.path,
  487. fn: async () => {
  488. const edit = await resolve()
  489. const filePath = path.join(tmp.path, "test.txt")
  490. await readFileTime(ctx.sessionID, filePath)
  491. await Effect.runPromise(
  492. edit.execute(
  493. {
  494. filePath,
  495. oldString: input.oldString,
  496. newString: input.newString,
  497. replaceAll: input.replaceAll,
  498. },
  499. ctx,
  500. ),
  501. )
  502. return await Bun.file(filePath).text()
  503. },
  504. })
  505. }
  506. test("preserves LF with LF multi-line strings", async () => {
  507. const content = normalize(old + "\n", "\n")
  508. const output = await apply({
  509. content,
  510. oldString: normalize(old, "\n"),
  511. newString: normalize(next, "\n"),
  512. })
  513. expect(output).toBe(normalize(next + "\n", "\n"))
  514. expectLf(output)
  515. })
  516. test("preserves CRLF with CRLF multi-line strings", async () => {
  517. const content = normalize(old + "\n", "\r\n")
  518. const output = await apply({
  519. content,
  520. oldString: normalize(old, "\r\n"),
  521. newString: normalize(next, "\r\n"),
  522. })
  523. expect(output).toBe(normalize(next + "\n", "\r\n"))
  524. expectCrlf(output)
  525. })
  526. test("preserves LF when old/new use CRLF", async () => {
  527. const content = normalize(old + "\n", "\n")
  528. const output = await apply({
  529. content,
  530. oldString: normalize(old, "\r\n"),
  531. newString: normalize(next, "\r\n"),
  532. })
  533. expect(output).toBe(normalize(next + "\n", "\n"))
  534. expectLf(output)
  535. })
  536. test("preserves CRLF when old/new use LF", async () => {
  537. const content = normalize(old + "\n", "\r\n")
  538. const output = await apply({
  539. content,
  540. oldString: normalize(old, "\n"),
  541. newString: normalize(next, "\n"),
  542. })
  543. expect(output).toBe(normalize(next + "\n", "\r\n"))
  544. expectCrlf(output)
  545. })
  546. test("preserves LF when newString uses CRLF", async () => {
  547. const content = normalize(old + "\n", "\n")
  548. const output = await apply({
  549. content,
  550. oldString: normalize(old, "\n"),
  551. newString: normalize(next, "\r\n"),
  552. })
  553. expect(output).toBe(normalize(next + "\n", "\n"))
  554. expectLf(output)
  555. })
  556. test("preserves CRLF when newString uses LF", async () => {
  557. const content = normalize(old + "\n", "\r\n")
  558. const output = await apply({
  559. content,
  560. oldString: normalize(old, "\r\n"),
  561. newString: normalize(next, "\n"),
  562. })
  563. expect(output).toBe(normalize(next + "\n", "\r\n"))
  564. expectCrlf(output)
  565. })
  566. test("preserves LF with mixed old/new line endings", async () => {
  567. const content = normalize(old + "\n", "\n")
  568. const output = await apply({
  569. content,
  570. oldString: "alpha\nbeta\r\ngamma",
  571. newString: "alpha\r\nbeta\nomega",
  572. })
  573. expect(output).toBe(normalize(alt + "\n", "\n"))
  574. expectLf(output)
  575. })
  576. test("preserves CRLF with mixed old/new line endings", async () => {
  577. const content = normalize(old + "\n", "\r\n")
  578. const output = await apply({
  579. content,
  580. oldString: "alpha\r\nbeta\ngamma",
  581. newString: "alpha\nbeta\r\nomega",
  582. })
  583. expect(output).toBe(normalize(alt + "\n", "\r\n"))
  584. expectCrlf(output)
  585. })
  586. test("replaceAll preserves LF for multi-line blocks", async () => {
  587. const blockOld = "alpha\nbeta"
  588. const blockNew = "alpha\nbeta-updated"
  589. const content = normalize(blockOld + "\n" + blockOld + "\n", "\n")
  590. const output = await apply({
  591. content,
  592. oldString: normalize(blockOld, "\n"),
  593. newString: normalize(blockNew, "\n"),
  594. replaceAll: true,
  595. })
  596. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\n"))
  597. expectLf(output)
  598. })
  599. test("replaceAll preserves CRLF for multi-line blocks", async () => {
  600. const blockOld = "alpha\nbeta"
  601. const blockNew = "alpha\nbeta-updated"
  602. const content = normalize(blockOld + "\n" + blockOld + "\n", "\r\n")
  603. const output = await apply({
  604. content,
  605. oldString: normalize(blockOld, "\r\n"),
  606. newString: normalize(blockNew, "\r\n"),
  607. replaceAll: true,
  608. })
  609. expect(output).toBe(normalize(blockNew + "\n" + blockNew + "\n", "\r\n"))
  610. expectCrlf(output)
  611. })
  612. })
  613. describe("concurrent editing", () => {
  614. test("serializes concurrent edits to same file", async () => {
  615. await using tmp = await tmpdir()
  616. const filepath = path.join(tmp.path, "file.txt")
  617. await fs.writeFile(filepath, "0", "utf-8")
  618. await Instance.provide({
  619. directory: tmp.path,
  620. fn: async () => {
  621. await readFileTime(ctx.sessionID, filepath)
  622. const edit = await resolve()
  623. // Two concurrent edits
  624. const promise1 = Effect.runPromise(
  625. edit.execute(
  626. {
  627. filePath: filepath,
  628. oldString: "0",
  629. newString: "1",
  630. },
  631. ctx,
  632. ),
  633. )
  634. // Need to read again since FileTime tracks per-session
  635. await readFileTime(ctx.sessionID, filepath)
  636. const promise2 = Effect.runPromise(
  637. edit.execute(
  638. {
  639. filePath: filepath,
  640. oldString: "0",
  641. newString: "2",
  642. },
  643. ctx,
  644. ),
  645. )
  646. // Both should complete without error (though one might fail due to content mismatch)
  647. const results = await Promise.allSettled([promise1, promise2])
  648. expect(results.some((r) => r.status === "fulfilled")).toBe(true)
  649. },
  650. })
  651. })
  652. })
  653. })