patch.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import { describe, test, expect, beforeEach, afterEach } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Effect } from "effect"
  4. import * as fs from "fs/promises"
  5. import * as path from "path"
  6. import { tmpdir } from "os"
  7. import { Patch } from "../../src/patch"
  8. import { FSUtil } from "@kirincode-ai/core/fs-util"
  9. import { testEffect } from "../lib/effect"
  10. const it = testEffect(LayerNode.compile(FSUtil.node))
  11. describe("Patch namespace", () => {
  12. let tempDir: string
  13. beforeEach(async () => {
  14. tempDir = await fs.mkdtemp(path.join(tmpdir(), "patch-test-"))
  15. })
  16. afterEach(async () => {
  17. // Clean up temp directory
  18. await fs.rm(tempDir, { recursive: true, force: true })
  19. })
  20. describe("parsePatch", () => {
  21. test("should parse simple add file patch", () => {
  22. const patchText = `*** Begin Patch
  23. *** Add File: test.txt
  24. +Hello World
  25. *** End Patch`
  26. const result = Patch.parsePatch(patchText)
  27. expect(result.hunks).toHaveLength(1)
  28. expect(result.hunks[0]).toEqual({
  29. type: "add",
  30. path: "test.txt",
  31. contents: "Hello World",
  32. })
  33. })
  34. test("should parse delete file patch", () => {
  35. const patchText = `*** Begin Patch
  36. *** Delete File: old.txt
  37. *** End Patch`
  38. const result = Patch.parsePatch(patchText)
  39. expect(result.hunks).toHaveLength(1)
  40. const hunk = result.hunks[0]
  41. expect(hunk.type).toBe("delete")
  42. expect(hunk.path).toBe("old.txt")
  43. })
  44. test("should parse patch with multiple hunks", () => {
  45. const patchText = `*** Begin Patch
  46. *** Add File: new.txt
  47. +This is a new file
  48. *** Update File: existing.txt
  49. @@
  50. old line
  51. -new line
  52. +updated line
  53. *** End Patch`
  54. const result = Patch.parsePatch(patchText)
  55. expect(result.hunks).toHaveLength(2)
  56. expect(result.hunks[0].type).toBe("add")
  57. expect(result.hunks[1].type).toBe("update")
  58. })
  59. test("should parse file move operation", () => {
  60. const patchText = `*** Begin Patch
  61. *** Update File: old-name.txt
  62. *** Move to: new-name.txt
  63. @@
  64. -Old content
  65. +New content
  66. *** End Patch`
  67. const result = Patch.parsePatch(patchText)
  68. expect(result.hunks).toHaveLength(1)
  69. const hunk = result.hunks[0]
  70. expect(hunk.type).toBe("update")
  71. expect(hunk.path).toBe("old-name.txt")
  72. if (hunk.type === "update") {
  73. expect(hunk.move_path).toBe("new-name.txt")
  74. }
  75. })
  76. test("should throw error for invalid patch format", () => {
  77. const invalidPatch = `This is not a valid patch`
  78. expect(() => Patch.parsePatch(invalidPatch)).toThrow("Invalid patch format")
  79. })
  80. })
  81. describe("maybeParseApplyPatch", () => {
  82. test("should parse direct apply_patch command", () => {
  83. const patchText = `*** Begin Patch
  84. *** Add File: test.txt
  85. +Content
  86. *** End Patch`
  87. const result = Patch.maybeParseApplyPatch(["apply_patch", patchText])
  88. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  89. if (result.type === Patch.MaybeApplyPatch.Body) {
  90. expect(result.args.patch).toBe(patchText)
  91. expect(result.args.hunks).toHaveLength(1)
  92. }
  93. })
  94. test("should parse applypatch command", () => {
  95. const patchText = `*** Begin Patch
  96. *** Add File: test.txt
  97. +Content
  98. *** End Patch`
  99. const result = Patch.maybeParseApplyPatch(["applypatch", patchText])
  100. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  101. })
  102. test("should handle bash heredoc format", () => {
  103. const script = `apply_patch <<'PATCH'
  104. *** Begin Patch
  105. *** Add File: test.txt
  106. +Content
  107. *** End Patch
  108. PATCH`
  109. const result = Patch.maybeParseApplyPatch(["bash", "-lc", script])
  110. expect(result.type).toBe(Patch.MaybeApplyPatch.Body)
  111. if (result.type === Patch.MaybeApplyPatch.Body) {
  112. expect(result.args.hunks).toHaveLength(1)
  113. }
  114. })
  115. test("should return NotApplyPatch for non-patch commands", () => {
  116. const result = Patch.maybeParseApplyPatch(["echo", "hello"])
  117. expect(result.type).toBe(Patch.MaybeApplyPatch.NotApplyPatch)
  118. })
  119. })
  120. describe("applyPatch", () => {
  121. it.live("should add a new file", () =>
  122. Effect.gen(function* () {
  123. const patchText = `*** Begin Patch
  124. *** Add File: ${tempDir}/new-file.txt
  125. +Hello World
  126. +This is a new file
  127. *** End Patch`
  128. const result = yield* Patch.applyPatch(patchText)
  129. expect(result.added).toHaveLength(1)
  130. expect(result.modified).toHaveLength(0)
  131. expect(result.deleted).toHaveLength(0)
  132. const content = yield* Effect.promise(() => fs.readFile(result.added[0], "utf-8"))
  133. expect(content).toBe("Hello World\nThis is a new file")
  134. }),
  135. )
  136. it.live("should delete an existing file", () =>
  137. Effect.gen(function* () {
  138. const filePath = path.join(tempDir, "to-delete.txt")
  139. yield* Effect.promise(() => fs.writeFile(filePath, "This file will be deleted"))
  140. const patchText = `*** Begin Patch
  141. *** Delete File: ${filePath}
  142. *** End Patch`
  143. const result = yield* Patch.applyPatch(patchText)
  144. expect(result.deleted).toHaveLength(1)
  145. expect(result.deleted[0]).toBe(filePath)
  146. const exists = yield* Effect.promise(() =>
  147. fs
  148. .access(filePath)
  149. .then(() => true)
  150. .catch(() => false),
  151. )
  152. expect(exists).toBe(false)
  153. }),
  154. )
  155. it.live("should update an existing file", () =>
  156. Effect.gen(function* () {
  157. const filePath = path.join(tempDir, "to-update.txt")
  158. yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\n"))
  159. const patchText = `*** Begin Patch
  160. *** Update File: ${filePath}
  161. @@
  162. line 1
  163. -line 2
  164. +line 2 updated
  165. line 3
  166. *** End Patch`
  167. const result = yield* Patch.applyPatch(patchText)
  168. expect(result.modified).toHaveLength(1)
  169. expect(result.modified[0]).toBe(filePath)
  170. const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
  171. expect(content).toBe("line 1\nline 2 updated\nline 3\n")
  172. }),
  173. )
  174. it.live("should move and update a file", () =>
  175. Effect.gen(function* () {
  176. const oldPath = path.join(tempDir, "old-name.txt")
  177. const newPath = path.join(tempDir, "new-name.txt")
  178. yield* Effect.promise(() => fs.writeFile(oldPath, "old content\n"))
  179. const patchText = `*** Begin Patch
  180. *** Update File: ${oldPath}
  181. *** Move to: ${newPath}
  182. @@
  183. -old content
  184. +new content
  185. *** End Patch`
  186. const result = yield* Patch.applyPatch(patchText)
  187. expect(result.modified).toHaveLength(1)
  188. expect(result.modified[0]).toBe(newPath)
  189. const oldExists = yield* Effect.promise(() =>
  190. fs
  191. .access(oldPath)
  192. .then(() => true)
  193. .catch(() => false),
  194. )
  195. expect(oldExists).toBe(false)
  196. const newContent = yield* Effect.promise(() => fs.readFile(newPath, "utf-8"))
  197. expect(newContent).toBe("new content\n")
  198. }),
  199. )
  200. it.live("should handle multiple operations in one patch", () =>
  201. Effect.gen(function* () {
  202. const file1 = path.join(tempDir, "file1.txt")
  203. const file2 = path.join(tempDir, "file2.txt")
  204. const file3 = path.join(tempDir, "file3.txt")
  205. yield* Effect.promise(() => fs.writeFile(file1, "content 1"))
  206. yield* Effect.promise(() => fs.writeFile(file2, "content 2"))
  207. const patchText = `*** Begin Patch
  208. *** Add File: ${file3}
  209. +new file content
  210. *** Update File: ${file1}
  211. @@
  212. -content 1
  213. +updated content 1
  214. *** Delete File: ${file2}
  215. *** End Patch`
  216. const result = yield* Patch.applyPatch(patchText)
  217. expect(result.added).toHaveLength(1)
  218. expect(result.modified).toHaveLength(1)
  219. expect(result.deleted).toHaveLength(1)
  220. }),
  221. )
  222. it.live("should create parent directories when adding files", () =>
  223. Effect.gen(function* () {
  224. const nestedPath = path.join(tempDir, "deep", "nested", "file.txt")
  225. const patchText = `*** Begin Patch
  226. *** Add File: ${nestedPath}
  227. +Deep nested content
  228. *** End Patch`
  229. const result = yield* Patch.applyPatch(patchText)
  230. expect(result.added).toHaveLength(1)
  231. expect(result.added[0]).toBe(nestedPath)
  232. const exists = yield* Effect.promise(() =>
  233. fs
  234. .access(nestedPath)
  235. .then(() => true)
  236. .catch(() => false),
  237. )
  238. expect(exists).toBe(true)
  239. }),
  240. )
  241. })
  242. describe("error handling", () => {
  243. it.live("should fail when updating non-existent file", () =>
  244. Effect.gen(function* () {
  245. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  246. const patchText = `*** Begin Patch
  247. *** Update File: ${nonExistent}
  248. @@
  249. -old line
  250. +new line
  251. *** End Patch`
  252. const exit = yield* Effect.exit(Patch.applyPatch(patchText))
  253. expect(exit._tag).toBe("Failure")
  254. }),
  255. )
  256. it.live("should fail when deleting non-existent file", () =>
  257. Effect.gen(function* () {
  258. const nonExistent = path.join(tempDir, "does-not-exist.txt")
  259. const patchText = `*** Begin Patch
  260. *** Delete File: ${nonExistent}
  261. *** End Patch`
  262. const exit = yield* Effect.exit(Patch.applyPatch(patchText))
  263. expect(exit._tag).toBe("Failure")
  264. }),
  265. )
  266. })
  267. describe("edge cases", () => {
  268. it.live("should handle empty files", () =>
  269. Effect.gen(function* () {
  270. const emptyFile = path.join(tempDir, "empty.txt")
  271. yield* Effect.promise(() => fs.writeFile(emptyFile, ""))
  272. const patchText = `*** Begin Patch
  273. *** Update File: ${emptyFile}
  274. @@
  275. +First line
  276. *** End Patch`
  277. const result = yield* Patch.applyPatch(patchText)
  278. expect(result.modified).toHaveLength(1)
  279. const content = yield* Effect.promise(() => fs.readFile(emptyFile, "utf-8"))
  280. expect(content).toBe("First line\n")
  281. }),
  282. )
  283. it.live("should handle files with no trailing newline", () =>
  284. Effect.gen(function* () {
  285. const filePath = path.join(tempDir, "no-newline.txt")
  286. yield* Effect.promise(() => fs.writeFile(filePath, "no newline"))
  287. const patchText = `*** Begin Patch
  288. *** Update File: ${filePath}
  289. @@
  290. -no newline
  291. +has newline now
  292. *** End Patch`
  293. const result = yield* Patch.applyPatch(patchText)
  294. expect(result.modified).toHaveLength(1)
  295. const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
  296. expect(content).toBe("has newline now\n")
  297. }),
  298. )
  299. it.live("should handle multiple update chunks in single file", () =>
  300. Effect.gen(function* () {
  301. const filePath = path.join(tempDir, "multi-chunk.txt")
  302. yield* Effect.promise(() => fs.writeFile(filePath, "line 1\nline 2\nline 3\nline 4\n"))
  303. const patchText = `*** Begin Patch
  304. *** Update File: ${filePath}
  305. @@
  306. line 1
  307. -line 2
  308. +LINE 2
  309. @@
  310. line 3
  311. -line 4
  312. +LINE 4
  313. *** End Patch`
  314. const result = yield* Patch.applyPatch(patchText)
  315. expect(result.modified).toHaveLength(1)
  316. const content = yield* Effect.promise(() => fs.readFile(filePath, "utf-8"))
  317. expect(content).toBe("line 1\nLINE 2\nline 3\nLINE 4\n")
  318. }),
  319. )
  320. })
  321. })