tool-edit.test.ts 26 KB

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