tool-edit.test.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Layer } from "effect"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Environment } from "@opencode-ai/core/environment"
  8. import { FileMutation } from "@opencode-ai/core/file-mutation"
  9. import { Formatter } from "@opencode-ai/core/formatter"
  10. import { Location } from "@opencode-ai/core/location"
  11. import { LocationMutation } from "@opencode-ai/core/location-mutation"
  12. import { Permission } from "@opencode-ai/core/permission"
  13. import { AbsolutePath } from "@opencode-ai/core/schema"
  14. import { Session } from "@opencode-ai/core/session"
  15. import { Tool } from "@opencode-ai/core/tool"
  16. import { EditTool } from "@opencode-ai/core/tool/plugin/edit"
  17. import { transformEnvironmentFiles } from "./fixture/environment"
  18. import { location } from "./fixture/location"
  19. import { tmpdir } from "./fixture/tmpdir"
  20. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  21. import { testEffect } from "./lib/effect"
  22. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  23. const editToolNode = makeLocationNode({
  24. name: "test/edit-tool-plugin",
  25. layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
  26. deps: [
  27. Tool.node,
  28. LocationMutation.node,
  29. FileMutation.node,
  30. Environment.node,
  31. Formatter.node,
  32. Location.node,
  33. Permission.node,
  34. ],
  35. })
  36. const sessionID = Session.ID.make("ses_edit_tool_test")
  37. const assertions: Permission.AssertInput[] = []
  38. const writes: string[] = []
  39. let reads = 0
  40. let denyAction: string | undefined
  41. let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
  42. let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
  43. const permission = Layer.succeed(
  44. Permission.Service,
  45. Permission.Service.of({
  46. assert: (input) =>
  47. Effect.sync(() => assertions.push(input)).pipe(
  48. Effect.andThen(
  49. input.action === denyAction
  50. ? Effect.fail(
  51. new Permission.BlockedError({
  52. rules: [],
  53. permission: input.action,
  54. resources: input.resources,
  55. }),
  56. )
  57. : Effect.void,
  58. ),
  59. ),
  60. ask: () => Effect.die("unused"),
  61. reply: () => Effect.die("unused"),
  62. get: () => Effect.die("unused"),
  63. forSession: () => Effect.die("unused"),
  64. list: () => Effect.die("unused"),
  65. }),
  66. )
  67. const formatter = Layer.mock(Formatter.Service, {
  68. file: (target) => formatFile(target),
  69. })
  70. const reset = () => {
  71. assertions.length = 0
  72. writes.length = 0
  73. reads = 0
  74. denyAction = undefined
  75. afterRead = () => Effect.void
  76. formatFile = () => Effect.succeed(false)
  77. }
  78. const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) => Effect.Effect<A, E, R>) => {
  79. const activeLocation = Layer.succeed(
  80. Location.Service,
  81. Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
  82. )
  83. return Effect.gen(function* () {
  84. return yield* body(yield* Tool.Service)
  85. }).pipe(
  86. Effect.provide(
  87. AppNodeBuilder.build(
  88. LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
  89. [
  90. [
  91. Environment.node,
  92. transformEnvironmentFiles(activeLocation, (files) => ({
  93. read: (target, range) =>
  94. files
  95. .read(target, range)
  96. .pipe(
  97. Effect.tap((result) =>
  98. Effect.sync(() => reads++).pipe(
  99. Effect.andThen(Effect.suspend(() => afterRead(target, result.bytes))),
  100. ),
  101. ),
  102. ),
  103. write: (target, content) =>
  104. Effect.sync(() => writes.push(target)).pipe(Effect.andThen(files.write(target, content))),
  105. })),
  106. ],
  107. [Location.node, activeLocation],
  108. [Formatter.node, formatter],
  109. [Permission.node, permission],
  110. ],
  111. ),
  112. ),
  113. )
  114. }
  115. const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
  116. sessionID,
  117. ...toolIdentity,
  118. call: { type: "tool-call" as const, id, name: "edit", input },
  119. })
  120. const it = testEffect(Layer.empty)
  121. describe("EditTool", () => {
  122. it.live("registers and replaces relative exact text through FileMutation once", () =>
  123. Effect.acquireUseRelease(
  124. Effect.promise(() => tmpdir()),
  125. (tmp) => {
  126. reset()
  127. const target = path.join(tmp.path, "hello.txt")
  128. return Effect.promise(() => fs.writeFile(target, "before\nrest\n")).pipe(
  129. Effect.andThen(
  130. withTool(tmp.path, (registry) =>
  131. Effect.gen(function* () {
  132. expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["edit", "execute"])
  133. expect(
  134. (yield* toolDefinitions(registry, [{ action: "edit", resource: "*", effect: "deny" }])).map(
  135. (tool) => tool.name,
  136. ),
  137. ).toEqual(["execute"])
  138. const settled = yield* executeTool(
  139. registry,
  140. call({ path: "hello.txt", oldString: "before", newString: "after" }),
  141. )
  142. expect(settled.status).toBe("completed")
  143. if (settled.status !== "completed") return
  144. expect(settled.content).toEqual([
  145. {
  146. type: "text",
  147. text: "Edited hello.txt (1 replacement)",
  148. },
  149. ])
  150. // Compact UI metadata carries the file diffs the TUI renders.
  151. expect(settled.metadata).toMatchObject({
  152. files: [{ file: "hello.txt", status: "modified", additions: 1, deletions: 1 }],
  153. })
  154. expect(settled.output).toEqual({
  155. replacements: 1,
  156. files: [
  157. {
  158. file: "hello.txt",
  159. status: "modified",
  160. additions: 1,
  161. deletions: 1,
  162. patch: expect.stringContaining("-before\n+after"),
  163. },
  164. ],
  165. })
  166. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
  167. expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
  168. expect(assertions[0]?.metadata).toMatchObject({
  169. files: [
  170. {
  171. file: "hello.txt",
  172. status: "modified",
  173. additions: 1,
  174. deletions: 1,
  175. patch: expect.stringContaining("-before\n+after"),
  176. },
  177. ],
  178. })
  179. expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
  180. }),
  181. ),
  182. ),
  183. )
  184. },
  185. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  186. ),
  187. )
  188. it.live("returns the diff for final formatted content", () =>
  189. Effect.acquireUseRelease(
  190. Effect.promise(() => tmpdir()),
  191. (tmp) => {
  192. reset()
  193. const target = path.join(tmp.path, "formatted.txt")
  194. formatFile = (file) =>
  195. Effect.promise(async () => {
  196. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
  197. return true
  198. })
  199. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  200. Effect.andThen(
  201. withTool(tmp.path, (registry) =>
  202. Effect.gen(function* () {
  203. const settled = yield* executeTool(
  204. registry,
  205. call({ path: "formatted.txt", oldString: "before", newString: "after" }),
  206. )
  207. expect(settled.status).toBe("completed")
  208. if (settled.status !== "completed") return
  209. expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
  210. expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
  211. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
  212. }),
  213. ),
  214. ),
  215. )
  216. },
  217. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  218. ),
  219. )
  220. it.live("accepts an absolute file path inside the active Location", () =>
  221. Effect.acquireUseRelease(
  222. Effect.promise(() => tmpdir()),
  223. (tmp) => {
  224. reset()
  225. const target = path.join(tmp.path, "absolute.txt")
  226. return Effect.promise(() => fs.writeFile(target, "before")).pipe(
  227. Effect.andThen(
  228. withTool(tmp.path, (registry) =>
  229. executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
  230. ),
  231. ),
  232. Effect.andThen((result) =>
  233. Effect.gen(function* () {
  234. expect(result.status).toBe("completed")
  235. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  236. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
  237. }),
  238. ),
  239. )
  240. },
  241. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  242. ),
  243. )
  244. it.live("edits an external symlink target with only its in-location permission", () =>
  245. Effect.acquireUseRelease(
  246. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  247. ([active, outside]) => {
  248. reset()
  249. if (process.platform === "win32") return Effect.void
  250. const target = path.join(outside.path, "external.txt")
  251. const link = path.join(active.path, "link.txt")
  252. return Effect.promise(async () => {
  253. await fs.writeFile(target, "before")
  254. await fs.symlink(target, link)
  255. }).pipe(
  256. Effect.andThen(
  257. withTool(active.path, (registry) =>
  258. executeTool(registry, call({ path: "link.txt", oldString: "before", newString: "after" })),
  259. ),
  260. ),
  261. Effect.andThen((result) =>
  262. Effect.sync(() => {
  263. expect(result.status).toBe("completed")
  264. expect(assertions.map((input) => input.action)).toEqual(["edit"])
  265. expect(assertions[0]?.resources).toEqual(["link.txt"])
  266. }),
  267. ),
  268. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  269. Effect.tap((content) => Effect.sync(() => expect(content).toBe("after"))),
  270. )
  271. },
  272. ([active, outside]) =>
  273. Effect.promise(() =>
  274. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  275. ),
  276. ),
  277. )
  278. it.live("approves an explicit external absolute path before edit", () =>
  279. Effect.acquireUseRelease(
  280. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  281. ([active, outside]) => {
  282. reset()
  283. const target = path.join(outside.path, "external.txt")
  284. return Effect.promise(() => fs.writeFile(target, "before")).pipe(
  285. Effect.andThen(
  286. withTool(active.path, (registry) =>
  287. executeTool(registry, call({ path: target, oldString: "before", newString: "after" })),
  288. ),
  289. ),
  290. Effect.andThen((result) =>
  291. Effect.gen(function* () {
  292. expect(result.status).toBe("completed")
  293. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  294. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after")
  295. expect(writes).toHaveLength(1)
  296. }),
  297. ),
  298. )
  299. },
  300. ([active, outside]) =>
  301. Effect.promise(() =>
  302. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  303. ),
  304. ),
  305. )
  306. it.live("does not write when external_directory or edit approval is denied", () =>
  307. Effect.acquireUseRelease(
  308. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  309. ([active, outside]) =>
  310. Effect.gen(function* () {
  311. const external = path.join(outside.path, "denied.txt")
  312. yield* Effect.promise(() => fs.writeFile(external, "before"))
  313. reset()
  314. denyAction = "external_directory"
  315. expect(
  316. yield* withTool(active.path, (registry) =>
  317. executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
  318. ),
  319. ).toEqual({
  320. status: "error",
  321. error: { type: "permission.rejected", message: "Permission denied: external_directory" },
  322. })
  323. expect(assertions.map((input) => input.action)).toEqual(["external_directory"])
  324. expect(reads).toBe(0)
  325. expect(writes).toEqual([])
  326. reset()
  327. denyAction = "edit"
  328. expect(
  329. yield* withTool(active.path, (registry) =>
  330. executeTool(registry, call({ path: external, oldString: "before", newString: "after" })),
  331. ),
  332. ).toEqual({
  333. status: "error",
  334. error: { type: "permission.rejected", message: "Permission denied: edit" },
  335. })
  336. expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
  337. expect(reads).toBe(1)
  338. expect(writes).toEqual([])
  339. expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
  340. }),
  341. ([active, outside]) =>
  342. Effect.promise(() =>
  343. Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
  344. ),
  345. ),
  346. )
  347. it.live("denied edit does not disclose whether oldString matches", () =>
  348. Effect.acquireUseRelease(
  349. Effect.promise(() => tmpdir()),
  350. (tmp) => {
  351. reset()
  352. denyAction = "edit"
  353. const target = path.join(tmp.path, "secret.txt")
  354. return Effect.promise(() => fs.writeFile(target, "secret content")).pipe(
  355. Effect.andThen(
  356. withTool(tmp.path, (registry) =>
  357. Effect.gen(function* () {
  358. const matching = yield* executeTool(
  359. registry,
  360. call({ path: "secret.txt", oldString: "secret content", newString: "replacement" }),
  361. )
  362. const missing = yield* executeTool(
  363. registry,
  364. call({ path: "secret.txt", oldString: "not present", newString: "replacement" }),
  365. )
  366. expect(matching).toEqual({
  367. status: "error",
  368. error: { type: "permission.rejected", message: "Permission denied: edit" },
  369. })
  370. expect(missing).toEqual(matching)
  371. expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
  372. expect(reads).toBe(2)
  373. expect(writes).toEqual([])
  374. }),
  375. ),
  376. ),
  377. )
  378. },
  379. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  380. ),
  381. )
  382. it.live("rejects no-op, empty, missing, and ambiguous exact replacements", () =>
  383. Effect.acquireUseRelease(
  384. Effect.promise(() => tmpdir()),
  385. (tmp) => {
  386. reset()
  387. const target = path.join(tmp.path, "matches.txt")
  388. return Effect.promise(() => fs.writeFile(target, "same same")).pipe(
  389. Effect.andThen(
  390. withTool(tmp.path, (registry) =>
  391. Effect.gen(function* () {
  392. expect(
  393. yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "same" })),
  394. ).toEqual({
  395. status: "error",
  396. error: {
  397. type: "tool.execution",
  398. message: "No changes to apply: oldString and newString are identical.",
  399. },
  400. })
  401. expect(
  402. yield* executeTool(registry, call({ path: "matches.txt", oldString: "", newString: "after" })),
  403. ).toEqual({
  404. status: "error",
  405. error: {
  406. type: "tool.execution",
  407. message: "oldString must not be empty. Use write to create or overwrite a file.",
  408. },
  409. })
  410. expect(
  411. yield* executeTool(registry, call({ path: "matches.txt", oldString: "missing", newString: "after" })),
  412. ).toEqual({
  413. status: "error",
  414. error: {
  415. type: "tool.execution",
  416. message:
  417. "Could not find oldString in matches.txt. It must match exactly, including whitespace and indentation.",
  418. },
  419. })
  420. expect(
  421. yield* executeTool(registry, call({ path: "matches.txt", oldString: "same", newString: "after" })),
  422. ).toEqual({
  423. status: "error",
  424. error: {
  425. type: "tool.execution",
  426. message:
  427. "Found 2 matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.",
  428. },
  429. })
  430. expect(writes).toEqual([])
  431. }),
  432. ),
  433. ),
  434. )
  435. },
  436. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  437. ),
  438. )
  439. it.live("returns specific missing file and directory errors", () =>
  440. Effect.acquireUseRelease(
  441. Effect.promise(() => tmpdir()),
  442. (tmp) => {
  443. reset()
  444. const directory = path.join(tmp.path, "src")
  445. return Effect.promise(() => fs.mkdir(directory)).pipe(
  446. Effect.andThen(
  447. withTool(tmp.path, (registry) =>
  448. Effect.gen(function* () {
  449. expect(
  450. yield* executeTool(registry, call({ path: "missing.ts", oldString: "before", newString: "after" })),
  451. ).toEqual({
  452. status: "error",
  453. error: { type: "tool.execution", message: "File not found: missing.ts" },
  454. })
  455. expect(
  456. yield* executeTool(registry, call({ path: "src", oldString: "before", newString: "after" })),
  457. ).toEqual({
  458. status: "error",
  459. error: { type: "tool.execution", message: "Path is a directory, not a file: src" },
  460. })
  461. expect(writes).toEqual([])
  462. }),
  463. ),
  464. ),
  465. )
  466. },
  467. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  468. ),
  469. )
  470. it.live("replaces every exact occurrence when replaceAll is true", () =>
  471. Effect.acquireUseRelease(
  472. Effect.promise(() => tmpdir()),
  473. (tmp) => {
  474. reset()
  475. const target = path.join(tmp.path, "all.txt")
  476. return Effect.promise(() => fs.writeFile(target, "same same same")).pipe(
  477. Effect.andThen(
  478. withTool(tmp.path, (registry) =>
  479. executeTool(registry, call({ path: "all.txt", oldString: "same", newString: "after", replaceAll: true })),
  480. ),
  481. ),
  482. Effect.andThen((settled) =>
  483. Effect.gen(function* () {
  484. expect(settled.status).toBe("completed")
  485. if (settled.status !== "completed") return
  486. expect(settled.output).toMatchObject({ replacements: 3 })
  487. expect(settled.content).toEqual([{ type: "text", text: "Edited all.txt (3 replacements)" }])
  488. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after after after")
  489. expect(writes).toHaveLength(1)
  490. }),
  491. ),
  492. )
  493. },
  494. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  495. ),
  496. )
  497. it.live("normalizes Unicode typography only after exact matching fails", () =>
  498. Effect.acquireUseRelease(
  499. Effect.promise(() => tmpdir()),
  500. (tmp) => {
  501. reset()
  502. const target = path.join(tmp.path, "unicode.txt")
  503. return Effect.promise(() =>
  504. fs.writeFile(target, "exact - match\ncurly “quotes”\nminus − one\nspace\u00A0here\nexact − match\n"),
  505. ).pipe(
  506. Effect.andThen(
  507. withTool(tmp.path, (registry) =>
  508. Effect.gen(function* () {
  509. const normalized = yield* executeTool(
  510. registry,
  511. call({
  512. path: "unicode.txt",
  513. oldString: 'curly "quotes"\nminus - one\nspace here',
  514. newString: "normalized",
  515. }),
  516. )
  517. expect(normalized.status).toBe("completed")
  518. const exact = yield* executeTool(
  519. registry,
  520. call({ path: "unicode.txt", oldString: "exact - match", newString: "selected" }),
  521. )
  522. expect(exact.status).toBe("completed")
  523. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(
  524. "selected\nnormalized\nexact − match\n",
  525. )
  526. }),
  527. ),
  528. ),
  529. )
  530. },
  531. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  532. ),
  533. )
  534. it.live("ignores trailing whitespace while preserving untouched lines", () =>
  535. Effect.acquireUseRelease(
  536. Effect.promise(() => tmpdir()),
  537. (tmp) => {
  538. reset()
  539. const target = path.join(tmp.path, "whitespace.txt")
  540. return Effect.promise(() => fs.writeFile(target, "before \nmatch \nnext\t\nafter \n")).pipe(
  541. Effect.andThen(
  542. withTool(tmp.path, (registry) =>
  543. executeTool(registry, call({ path: "whitespace.txt", oldString: "match\nnext", newString: "changed" })),
  544. ),
  545. ),
  546. Effect.tap((result) => Effect.sync(() => expect(result.status).toBe("completed"))),
  547. Effect.andThen(Effect.promise(() => fs.readFile(target, "utf8"))),
  548. Effect.tap((content) => Effect.sync(() => expect(content).toBe("before \nchanged\nafter \n"))),
  549. )
  550. },
  551. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  552. ),
  553. )
  554. it.live("uses non-overlapping trailing-whitespace matches and preserves CRLF", () =>
  555. Effect.acquireUseRelease(
  556. Effect.promise(() => tmpdir()),
  557. (tmp) => {
  558. reset()
  559. const overlap = path.join(tmp.path, "overlap.txt")
  560. const windows = path.join(tmp.path, "windows.txt")
  561. return Effect.promise(() =>
  562. Promise.all([fs.writeFile(overlap, "a \na \na \n"), fs.writeFile(windows, "a \r\nb\t\r\n")]),
  563. ).pipe(
  564. Effect.andThen(
  565. withTool(tmp.path, (registry) =>
  566. Effect.gen(function* () {
  567. const replaced = yield* executeTool(
  568. registry,
  569. call({ path: "overlap.txt", oldString: "a\na", newString: "x", replaceAll: true }),
  570. )
  571. expect(replaced).toMatchObject({ status: "completed", output: { replacements: 1 } })
  572. yield* executeTool(registry, call({ path: "windows.txt", oldString: "a\nb", newString: "x" }))
  573. }),
  574. ),
  575. ),
  576. Effect.andThen(
  577. Effect.promise(() => Promise.all([fs.readFile(overlap, "utf8"), fs.readFile(windows, "utf8")])),
  578. ),
  579. Effect.tap(([overlapContent, windowsContent]) =>
  580. Effect.sync(() => {
  581. expect(overlapContent).toBe("x\na \n")
  582. expect(windowsContent).toBe("x\r\n")
  583. }),
  584. ),
  585. )
  586. },
  587. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  588. ),
  589. )
  590. it.live("preserves BOM and CRLF line endings", () =>
  591. Effect.acquireUseRelease(
  592. Effect.promise(() => tmpdir()),
  593. (tmp) => {
  594. reset()
  595. const target = path.join(tmp.path, "windows.txt")
  596. formatFile = (file) =>
  597. Effect.promise(async () => {
  598. await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
  599. return true
  600. })
  601. return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
  602. Effect.andThen(
  603. withTool(tmp.path, (registry) =>
  604. executeTool(registry, call({ path: "windows.txt", oldString: "before\nrest", newString: "after\nrest" })),
  605. ),
  606. ),
  607. Effect.andThen(() => Effect.promise(() => fs.readFile(target, "utf8"))),
  608. Effect.tap((content) => Effect.sync(() => expect(content).toBe("\uFEFFafter\r\nrest\r\n"))),
  609. )
  610. },
  611. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  612. ),
  613. )
  614. it.live("serializes concurrent edit transactions", () =>
  615. Effect.acquireUseRelease(
  616. Effect.promise(() => tmpdir()),
  617. (tmp) => {
  618. reset()
  619. const target = path.join(tmp.path, "concurrent.txt")
  620. afterRead = () => (reads === 1 ? Effect.sleep("50 millis") : Effect.void)
  621. return Effect.promise(() => fs.writeFile(target, "one\ntwo\n")).pipe(
  622. Effect.andThen(
  623. withTool(tmp.path, (registry) =>
  624. Effect.all(
  625. [
  626. executeTool(
  627. registry,
  628. call({ path: "concurrent.txt", oldString: "one", newString: "ONE" }, "call-edit-one"),
  629. ),
  630. executeTool(
  631. registry,
  632. call({ path: "concurrent.txt", oldString: "two", newString: "TWO" }, "call-edit-two"),
  633. ),
  634. ],
  635. { concurrency: "unbounded" },
  636. ),
  637. ),
  638. ),
  639. Effect.andThen((results) =>
  640. Effect.gen(function* () {
  641. expect(results.map((result) => result.status)).toEqual(["completed", "completed"])
  642. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("ONE\nTWO\n")
  643. }),
  644. ),
  645. )
  646. },
  647. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  648. ),
  649. )
  650. it.live("applies the edit when content changes after matching", () =>
  651. Effect.acquireUseRelease(
  652. Effect.promise(() => tmpdir()),
  653. (tmp) => {
  654. reset()
  655. const target = path.join(tmp.path, "concurrent.txt")
  656. afterRead = () => (reads === 1 ? Effect.promise(() => fs.writeFile(target, "newer\n")) : Effect.void)
  657. return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
  658. Effect.andThen(
  659. withTool(tmp.path, (registry) =>
  660. executeTool(registry, call({ path: "concurrent.txt", oldString: "before", newString: "after" })),
  661. ),
  662. ),
  663. Effect.andThen((result) =>
  664. Effect.gen(function* () {
  665. expect(result).toMatchObject({ status: "completed", output: { replacements: 1 } })
  666. expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
  667. expect(writes).toEqual([target])
  668. }),
  669. ),
  670. )
  671. },
  672. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  673. ),
  674. )
  675. })