summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax <[email protected]>2026-02-18 10:30:52 -0500
committerGitHub <[email protected]>2026-02-18 15:30:52 +0000
commit6b29896a35700805750a53caff7d4c6aad7e1f11 (patch)
treec39953d3839bbf137a1cf70689b1c7cce09228cf
parent1bb8574179bbf7c49a34ad0e5df522a752af08c2 (diff)
downloadopencode-6b29896a35700805750a53caff7d4c6aad7e1f11.tar.gz
opencode-6b29896a35700805750a53caff7d4c6aad7e1f11.zip
feat: Add centralized filesystem module for Bun.file migration (#14117)
-rw-r--r--bun.lock5
-rw-r--r--package.json1
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/src/util/filesystem.ts81
-rw-r--r--packages/opencode/test/file/index.test.ts340
-rw-r--r--packages/opencode/test/file/time.test.ts360
-rw-r--r--packages/opencode/test/tool/edit.test.ts496
-rw-r--r--packages/opencode/test/tool/write.test.ts341
-rw-r--r--packages/opencode/test/util/filesystem.test.ts299
9 files changed, 1889 insertions, 36 deletions
diff --git a/bun.lock b/bun.lock
index 07e239a78..b2bd70b8e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -14,6 +14,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
+ "@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
@@ -324,6 +325,7 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
+ "mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
@@ -356,6 +358,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
+ "@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -1917,6 +1920,8 @@
"@types/mime": ["@types/[email protected]", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
+ "@types/mime-types": ["@types/[email protected]", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
+
"@types/ms": ["@types/[email protected]", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
diff --git a/package.json b/package.json
index 5d9320505..e0008d102 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
+ "@types/mime-types": "3.0.1",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index da5287db9..6e1288b28 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -40,6 +40,7 @@
"@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5",
"@types/bun": "catalog:",
+ "@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
@@ -110,6 +111,7 @@
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
+ "mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opentui-spinner": "0.0.6",
diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts
index 7aff6bd1d..5c63af030 100644
--- a/packages/opencode/src/util/filesystem.ts
+++ b/packages/opencode/src/util/filesystem.ts
@@ -1,18 +1,76 @@
+import { mkdir, readFile, writeFile } from "fs/promises"
+import { existsSync, statSync } from "fs"
+import { lookup } from "mime-types"
import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
export namespace Filesystem {
- export const exists = (p: string) =>
- Bun.file(p)
- .stat()
- .then(() => true)
- .catch(() => false)
-
- export const isDir = (p: string) =>
- Bun.file(p)
- .stat()
- .then((s) => s.isDirectory())
- .catch(() => false)
+ // Fast sync version for metadata checks
+ export async function exists(p: string): Promise<boolean> {
+ return existsSync(p)
+ }
+
+ export async function isDir(p: string): Promise<boolean> {
+ try {
+ return statSync(p).isDirectory()
+ } catch {
+ return false
+ }
+ }
+
+ export async function size(p: string): Promise<number> {
+ try {
+ return statSync(p).size
+ } catch {
+ return 0
+ }
+ }
+
+ export async function readText(p: string): Promise<string> {
+ return readFile(p, "utf-8")
+ }
+
+ export async function readJson<T>(p: string): Promise<T> {
+ return JSON.parse(await readFile(p, "utf-8"))
+ }
+
+ export async function readBytes(p: string): Promise<Buffer> {
+ return readFile(p)
+ }
+
+ function isEnoent(e: unknown): e is { code: "ENOENT" } {
+ return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
+ }
+
+ export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
+ try {
+ if (mode) {
+ await writeFile(p, content, { mode })
+ } else {
+ await writeFile(p, content)
+ }
+ } catch (e) {
+ if (isEnoent(e)) {
+ await mkdir(dirname(p), { recursive: true })
+ if (mode) {
+ await writeFile(p, content, { mode })
+ } else {
+ await writeFile(p, content)
+ }
+ return
+ }
+ throw e
+ }
+ }
+
+ export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
+ return write(p, JSON.stringify(data, null, 2), mode)
+ }
+
+ export function mimeType(p: string): string {
+ return lookup(p) || "application/octet-stream"
+ }
+
/**
* On Windows, normalize a path to its canonical casing using the filesystem.
* This is needed because Windows paths are case-insensitive but LSP servers
@@ -26,6 +84,7 @@ export namespace Filesystem {
return p
}
}
+
export function overlaps(a: string, b: string) {
const relA = relative(a, b)
const relB = relative(b, a)
diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts
new file mode 100644
index 000000000..758886bd5
--- /dev/null
+++ b/packages/opencode/test/file/index.test.ts
@@ -0,0 +1,340 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { File } from "../../src/file"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("file/index Bun.file patterns", () => {
+ describe("File.read() - text content", () => {
+ test("reads text file via Bun.file().text()", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ await fs.writeFile(filepath, "Hello World", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("test.txt")
+ expect(result.type).toBe("text")
+ expect(result.content).toBe("Hello World")
+ },
+ })
+ })
+
+ test("reads with Bun.file().exists() check", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Non-existent file should return empty content
+ const result = await File.read("nonexistent.txt")
+ expect(result.type).toBe("text")
+ expect(result.content).toBe("")
+ },
+ })
+ })
+
+ test("trims whitespace from text content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("test.txt")
+ expect(result.content).toBe("content with spaces")
+ },
+ })
+ })
+
+ test("handles empty text file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "empty.txt")
+ await fs.writeFile(filepath, "", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("empty.txt")
+ expect(result.type).toBe("text")
+ expect(result.content).toBe("")
+ },
+ })
+ })
+
+ test("handles multi-line text files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "multiline.txt")
+ await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("multiline.txt")
+ expect(result.content).toBe("line1\nline2\nline3")
+ },
+ })
+ })
+ })
+
+ describe("File.read() - binary content", () => {
+ test("reads binary file via Bun.file().arrayBuffer()", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "image.png")
+ const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ await fs.writeFile(filepath, binaryContent)
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("image.png")
+ expect(result.type).toBe("text") // Images return as text with base64 encoding
+ expect(result.encoding).toBe("base64")
+ expect(result.mimeType).toBe("image/png")
+ expect(result.content).toBe(binaryContent.toString("base64"))
+ },
+ })
+ })
+
+ test("returns empty for binary non-image files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "binary.so")
+ await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("binary.so")
+ expect(result.type).toBe("binary")
+ expect(result.content).toBe("")
+ },
+ })
+ })
+ })
+
+ describe("File.read() - Bun.file().type", () => {
+ test("detects MIME type via Bun.file().type", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.json")
+ await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const bunFile = Bun.file(filepath)
+ expect(bunFile.type).toContain("application/json")
+
+ const result = await File.read("test.json")
+ expect(result.type).toBe("text")
+ },
+ })
+ })
+
+ test("handles various image MIME types", async () => {
+ await using tmp = await tmpdir()
+ const testCases = [
+ { ext: "jpg", mime: "image/jpeg" },
+ { ext: "png", mime: "image/png" },
+ { ext: "gif", mime: "image/gif" },
+ { ext: "webp", mime: "image/webp" },
+ ]
+
+ for (const { ext, mime } of testCases) {
+ const filepath = path.join(tmp.path, `test.${ext}`)
+ await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const bunFile = Bun.file(filepath)
+ expect(bunFile.type).toContain(mime)
+ },
+ })
+ }
+ })
+ })
+
+ describe("File.list() - Bun.file().exists() and .text()", () => {
+ test("reads .gitignore via Bun.file().exists() and .text()", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const gitignorePath = path.join(tmp.path, ".gitignore")
+ await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
+
+ // This is used internally in File.list()
+ const bunFile = Bun.file(gitignorePath)
+ expect(await bunFile.exists()).toBe(true)
+
+ const content = await bunFile.text()
+ expect(content).toContain("node_modules")
+ },
+ })
+ })
+
+ test("reads .ignore file similarly", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ignorePath = path.join(tmp.path, ".ignore")
+ await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
+
+ const bunFile = Bun.file(ignorePath)
+ expect(await bunFile.exists()).toBe(true)
+ expect(await bunFile.text()).toContain("*.log")
+ },
+ })
+ })
+
+ test("handles missing .gitignore gracefully", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const gitignorePath = path.join(tmp.path, ".gitignore")
+ const bunFile = Bun.file(gitignorePath)
+ expect(await bunFile.exists()).toBe(false)
+
+ // File.list() should still work
+ const nodes = await File.list()
+ expect(Array.isArray(nodes)).toBe(true)
+ },
+ })
+ })
+ })
+
+ describe("File.changed() - Bun.file().text() for untracked files", () => {
+ test("reads untracked files via Bun.file().text()", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const untrackedPath = path.join(tmp.path, "untracked.txt")
+ await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
+
+ // This is how File.changed() reads untracked files
+ const bunFile = Bun.file(untrackedPath)
+ const content = await bunFile.text()
+ const lines = content.split("\n").length
+ expect(lines).toBe(2)
+ },
+ })
+ })
+ })
+
+ describe("Error handling", () => {
+ test("handles errors gracefully in Bun.file().text()", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "readonly.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
+ // Bun.file().text() on non-existent file throws
+ await expect(nonExistentFile.text()).rejects.toThrow()
+
+ // But File.read() handles this gracefully
+ const result = await File.read("does-not-exist.txt")
+ expect(result.content).toBe("")
+ },
+ })
+ })
+
+ test("handles errors in Bun.file().arrayBuffer()", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
+ const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+ expect(buffer.byteLength).toBe(0)
+ },
+ })
+ })
+
+ test("returns empty array buffer on error for images", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "broken.png")
+ // Don't create the file
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const bunFile = Bun.file(filepath)
+ // File.read() handles missing images gracefully
+ const result = await File.read("broken.png")
+ expect(result.type).toBe("text")
+ expect(result.content).toBe("")
+ },
+ })
+ })
+ })
+
+ describe("shouldEncode() logic", () => {
+ test("returns encoding info for text files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ await fs.writeFile(filepath, "simple text", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("test.txt")
+ expect(result.encoding).toBeUndefined()
+ expect(result.type).toBe("text")
+ },
+ })
+ })
+
+ test("returns base64 encoding for images", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.jpg")
+ await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await File.read("test.jpg")
+ expect(result.encoding).toBe("base64")
+ expect(result.mimeType).toBe("image/jpeg")
+ },
+ })
+ })
+ })
+
+ describe("Path security", () => {
+ test("throws for paths outside project directory", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+ },
+ })
+ })
+
+ test("throws for paths outside project directory", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+ },
+ })
+ })
+ })
+})
diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts
new file mode 100644
index 000000000..ab7451276
--- /dev/null
+++ b/packages/opencode/test/file/time.test.ts
@@ -0,0 +1,360 @@
+import { describe, test, expect, beforeEach } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { FileTime } from "../../src/file/time"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("file/time", () => {
+ const sessionID = "test-session-123"
+
+ describe("read() and get()", () => {
+ test("stores read timestamp", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const before = FileTime.get(sessionID, filepath)
+ expect(before).toBeUndefined()
+
+ FileTime.read(sessionID, filepath)
+
+ const after = FileTime.get(sessionID, filepath)
+ expect(after).toBeInstanceOf(Date)
+ expect(after!.getTime()).toBeGreaterThan(0)
+ },
+ })
+ })
+
+ test("tracks separate timestamps per session", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read("session1", filepath)
+ FileTime.read("session2", filepath)
+
+ const time1 = FileTime.get("session1", filepath)
+ const time2 = FileTime.get("session2", filepath)
+
+ expect(time1).toBeDefined()
+ expect(time2).toBeDefined()
+ },
+ })
+ })
+
+ test("updates timestamp on subsequent reads", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+ const first = FileTime.get(sessionID, filepath)!
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ FileTime.read(sessionID, filepath)
+ const second = FileTime.get(sessionID, filepath)!
+
+ expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
+ },
+ })
+ })
+ })
+
+ describe("assert()", () => {
+ test("passes when file has not been modified", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+
+ // Should not throw
+ await FileTime.assert(sessionID, filepath)
+ },
+ })
+ })
+
+ test("throws when file was not read first", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
+ },
+ })
+ })
+
+ test("throws when file was modified after read", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+
+ // Wait to ensure different timestamps
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Modify file after reading
+ await fs.writeFile(filepath, "modified content", "utf-8")
+
+ await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
+ },
+ })
+ })
+
+ test("includes timestamps in error message", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ await fs.writeFile(filepath, "modified", "utf-8")
+
+ let error: Error | undefined
+ try {
+ await FileTime.assert(sessionID, filepath)
+ } catch (e) {
+ error = e as Error
+ }
+ expect(error).toBeDefined()
+ expect(error!.message).toContain("Last modification:")
+ expect(error!.message).toContain("Last read:")
+ },
+ })
+ })
+
+ test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { Flag } = await import("../../src/flag/flag")
+ const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
+ ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
+
+ try {
+ // Should not throw even though file wasn't read
+ await FileTime.assert(sessionID, filepath)
+ } finally {
+ ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
+ }
+ },
+ })
+ })
+ })
+
+ describe("withLock()", () => {
+ test("executes function within lock", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let executed = false
+ await FileTime.withLock(filepath, async () => {
+ executed = true
+ return "result"
+ })
+ expect(executed).toBe(true)
+ },
+ })
+ })
+
+ test("returns function result", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const result = await FileTime.withLock(filepath, async () => {
+ return "success"
+ })
+ expect(result).toBe("success")
+ },
+ })
+ })
+
+ test("serializes concurrent operations on same file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const order: number[] = []
+
+ const op1 = FileTime.withLock(filepath, async () => {
+ order.push(1)
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ order.push(2)
+ })
+
+ const op2 = FileTime.withLock(filepath, async () => {
+ order.push(3)
+ order.push(4)
+ })
+
+ await Promise.all([op1, op2])
+
+ // Operations should be serialized
+ expect(order).toContain(1)
+ expect(order).toContain(2)
+ expect(order).toContain(3)
+ expect(order).toContain(4)
+ },
+ })
+ })
+
+ test("allows concurrent operations on different files", async () => {
+ await using tmp = await tmpdir()
+ const filepath1 = path.join(tmp.path, "file1.txt")
+ const filepath2 = path.join(tmp.path, "file2.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let started1 = false
+ let started2 = false
+
+ const op1 = FileTime.withLock(filepath1, async () => {
+ started1 = true
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(started2).toBe(true) // op2 should have started while op1 is running
+ })
+
+ const op2 = FileTime.withLock(filepath2, async () => {
+ started2 = true
+ })
+
+ await Promise.all([op1, op2])
+
+ expect(started1).toBe(true)
+ expect(started2).toBe(true)
+ },
+ })
+ })
+
+ test("releases lock even if function throws", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await expect(
+ FileTime.withLock(filepath, async () => {
+ throw new Error("Test error")
+ }),
+ ).rejects.toThrow("Test error")
+
+ // Lock should be released, subsequent operations should work
+ let executed = false
+ await FileTime.withLock(filepath, async () => {
+ executed = true
+ })
+ expect(executed).toBe(true)
+ },
+ })
+ })
+
+ test("deadlocks on nested locks (expected behavior)", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Nested locks on same file cause deadlock - this is expected
+ // The outer lock waits for inner to complete, but inner waits for outer to release
+ const timeout = new Promise<never>((_, reject) =>
+ setTimeout(() => reject(new Error("Deadlock detected")), 100),
+ )
+
+ const nestedLock = FileTime.withLock(filepath, async () => {
+ return FileTime.withLock(filepath, async () => {
+ return "inner"
+ })
+ })
+
+ // Should timeout due to deadlock
+ await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
+ },
+ })
+ })
+ })
+
+ describe("stat() Bun.file pattern", () => {
+ test("reads file modification time via Bun.file().stat()", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+
+ const stats = await Bun.file(filepath).stat()
+ expect(stats.mtime).toBeInstanceOf(Date)
+ expect(stats.mtime.getTime()).toBeGreaterThan(0)
+
+ // FileTime.assert uses this stat internally
+ await FileTime.assert(sessionID, filepath)
+ },
+ })
+ })
+
+ test("detects modification via stat mtime", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "original", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(sessionID, filepath)
+
+ const originalStat = await Bun.file(filepath).stat()
+
+ // Wait and modify
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ await fs.writeFile(filepath, "modified", "utf-8")
+
+ const newStat = await Bun.file(filepath).stat()
+ expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
+
+ await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
+ },
+ })
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
new file mode 100644
index 000000000..c3cf0404b
--- /dev/null
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -0,0 +1,496 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { EditTool } from "../../src/tool/edit"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+import { FileTime } from "../../src/file/time"
+
+const ctx = {
+ sessionID: "test-edit-session",
+ messageID: "",
+ callID: "",
+ agent: "build",
+ abort: AbortSignal.any([]),
+ messages: [],
+ metadata: () => {},
+ ask: async () => {},
+}
+
+describe("tool.edit", () => {
+ describe("creating new files", () => {
+ test("creates new file when oldString is empty", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "newfile.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await EditTool.init()
+ const result = await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "",
+ newString: "new content",
+ },
+ ctx,
+ )
+
+ expect(result.metadata.diff).toContain("new content")
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("new content")
+ },
+ })
+ })
+
+ test("creates new file with nested directories", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "",
+ newString: "nested file",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("nested file")
+ },
+ })
+ })
+
+ test("emits add event for new files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "new.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { Bus } = await import("../../src/bus")
+ const { File } = await import("../../src/file")
+ const { FileWatcher } = await import("../../src/file/watcher")
+
+ const events: string[] = []
+ const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
+ const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "",
+ newString: "content",
+ },
+ ctx,
+ )
+
+ expect(events).toContain("edited")
+ expect(events).toContain("updated")
+ unsubEdited()
+ unsubUpdated()
+ },
+ })
+ })
+ })
+
+ describe("editing existing files", () => {
+ test("replaces text in existing file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "existing.txt")
+ await fs.writeFile(filepath, "old content here", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ const result = await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "old content",
+ newString: "new content",
+ },
+ ctx,
+ )
+
+ expect(result.output).toContain("Edit applied successfully")
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("new content here")
+ },
+ })
+ })
+
+ test("throws error when file does not exist", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "nonexistent.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "old",
+ newString: "new",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("not found")
+ },
+ })
+ })
+
+ test("throws error when oldString equals newString", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "same",
+ newString: "same",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("identical")
+ },
+ })
+ })
+
+ test("throws error when oldString not found in file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "actual content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "not in file",
+ newString: "replacement",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow()
+ },
+ })
+ })
+
+ test("throws error when file was not read first (FileTime)", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "content",
+ newString: "modified",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("You must read file")
+ },
+ })
+ })
+
+ test("throws error when file has been modified since read", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "original content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ // Read first
+ FileTime.read(ctx.sessionID, filepath)
+
+ // Wait a bit to ensure different timestamps
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Simulate external modification
+ await fs.writeFile(filepath, "modified externally", "utf-8")
+
+ // Try to edit with the new content
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "modified externally",
+ newString: "edited",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("modified since it was last read")
+ },
+ })
+ })
+
+ test("replaces all occurrences with replaceAll option", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "foo",
+ newString: "qux",
+ replaceAll: true,
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("qux bar qux baz qux")
+ },
+ })
+ })
+
+ test("emits change event for existing files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "original", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const { Bus } = await import("../../src/bus")
+ const { File } = await import("../../src/file")
+ const { FileWatcher } = await import("../../src/file/watcher")
+
+ const events: string[] = []
+ const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
+ const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "original",
+ newString: "modified",
+ },
+ ctx,
+ )
+
+ expect(events).toContain("edited")
+ expect(events).toContain("updated")
+ unsubEdited()
+ unsubUpdated()
+ },
+ })
+ })
+ })
+
+ describe("edge cases", () => {
+ test("handles multiline replacements", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "line2",
+ newString: "new line 2\nextra line",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("line1\nnew line 2\nextra line\nline3")
+ },
+ })
+ })
+
+ test("handles CRLF line endings", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "old",
+ newString: "new",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("line1\r\nnew\r\nline3")
+ },
+ })
+ })
+
+ test("throws error when oldString equals newString", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "",
+ newString: "",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("identical")
+ },
+ })
+ })
+
+ test("throws error when path is directory", async () => {
+ await using tmp = await tmpdir()
+ const dirpath = path.join(tmp.path, "adir")
+ await fs.mkdir(dirpath)
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, dirpath)
+
+ const edit = await EditTool.init()
+ await expect(
+ edit.execute(
+ {
+ filePath: dirpath,
+ oldString: "old",
+ newString: "new",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow("directory")
+ },
+ })
+ })
+
+ test("tracks file diff statistics", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+ const result = await edit.execute(
+ {
+ filePath: filepath,
+ oldString: "line2",
+ newString: "new line a\nnew line b",
+ },
+ ctx,
+ )
+
+ expect(result.metadata.filediff).toBeDefined()
+ expect(result.metadata.filediff.file).toBe(filepath)
+ expect(result.metadata.filediff.additions).toBeGreaterThan(0)
+ },
+ })
+ })
+ })
+
+ describe("concurrent editing", () => {
+ test("serializes concurrent edits to same file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "0", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ FileTime.read(ctx.sessionID, filepath)
+
+ const edit = await EditTool.init()
+
+ // Two concurrent edits
+ const promise1 = edit.execute(
+ {
+ filePath: filepath,
+ oldString: "0",
+ newString: "1",
+ },
+ ctx,
+ )
+
+ // Need to read again since FileTime tracks per-session
+ FileTime.read(ctx.sessionID, filepath)
+
+ const promise2 = edit.execute(
+ {
+ filePath: filepath,
+ oldString: "0",
+ newString: "2",
+ },
+ ctx,
+ )
+
+ // Both should complete without error (though one might fail due to content mismatch)
+ const results = await Promise.allSettled([promise1, promise2])
+ expect(results.some((r) => r.status === "fulfilled")).toBe(true)
+ },
+ })
+ })
+ })
+})
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
new file mode 100644
index 000000000..4f1a7d28e
--- /dev/null
+++ b/packages/opencode/test/tool/write.test.ts
@@ -0,0 +1,341 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { WriteTool } from "../../src/tool/write"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const ctx = {
+ sessionID: "test-write-session",
+ messageID: "",
+ callID: "",
+ agent: "build",
+ abort: AbortSignal.any([]),
+ messages: [],
+ metadata: () => {},
+ ask: async () => {},
+}
+
+describe("tool.write", () => {
+ describe("new file creation", () => {
+ test("writes content to new file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "newfile.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ const result = await write.execute(
+ {
+ filePath: filepath,
+ content: "Hello, World!",
+ },
+ ctx,
+ )
+
+ expect(result.output).toContain("Wrote file successfully")
+ expect(result.metadata.exists).toBe(false)
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("Hello, World!")
+ },
+ })
+ })
+
+ test("creates parent directories if needed", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content: "nested content",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("nested content")
+ },
+ })
+ })
+
+ test("handles relative paths by resolving to instance directory", async () => {
+ await using tmp = await tmpdir()
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: "relative.txt",
+ content: "relative content",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
+ expect(content).toBe("relative content")
+ },
+ })
+ })
+ })
+
+ describe("existing file overwrite", () => {
+ test("overwrites existing file content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "existing.txt")
+ await fs.writeFile(filepath, "old content", "utf-8")
+
+ // First read the file to satisfy FileTime requirement
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { FileTime } = await import("../../src/file/time")
+ FileTime.read(ctx.sessionID, filepath)
+
+ const write = await WriteTool.init()
+ const result = await write.execute(
+ {
+ filePath: filepath,
+ content: "new content",
+ },
+ ctx,
+ )
+
+ expect(result.output).toContain("Wrote file successfully")
+ expect(result.metadata.exists).toBe(true)
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("new content")
+ },
+ })
+ })
+
+ test("returns diff in metadata for existing files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "file.txt")
+ await fs.writeFile(filepath, "old", "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const { FileTime } = await import("../../src/file/time")
+ FileTime.read(ctx.sessionID, filepath)
+
+ const write = await WriteTool.init()
+ const result = await write.execute(
+ {
+ filePath: filepath,
+ content: "new",
+ },
+ ctx,
+ )
+
+ // Diff should be in metadata
+ expect(result.metadata).toHaveProperty("filepath", filepath)
+ expect(result.metadata).toHaveProperty("exists", true)
+ },
+ })
+ })
+ })
+
+ describe("file permissions", () => {
+ test("sets file permissions when writing sensitive data", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "sensitive.json")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content: JSON.stringify({ secret: "data" }),
+ },
+ ctx,
+ )
+
+ // On Unix systems, check permissions
+ if (process.platform !== "win32") {
+ const stats = await fs.stat(filepath)
+ expect(stats.mode & 0o777).toBe(0o644)
+ }
+ },
+ })
+ })
+ })
+
+ describe("content types", () => {
+ test("writes JSON content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "data.json")
+ const data = { key: "value", nested: { array: [1, 2, 3] } }
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content: JSON.stringify(data, null, 2),
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(JSON.parse(content)).toEqual(data)
+ },
+ })
+ })
+
+ test("writes binary-safe content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "binary.bin")
+ const content = "Hello\x00World\x01\x02\x03"
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content,
+ },
+ ctx,
+ )
+
+ const buf = await fs.readFile(filepath)
+ expect(buf.toString()).toBe(content)
+ },
+ })
+ })
+
+ test("writes empty content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "empty.txt")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content: "",
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe("")
+
+ const stats = await fs.stat(filepath)
+ expect(stats.size).toBe(0)
+ },
+ })
+ })
+
+ test("writes multi-line content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "multiline.txt")
+ const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content: lines,
+ },
+ ctx,
+ )
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toBe(lines)
+ },
+ })
+ })
+
+ test("handles different line endings", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "crlf.txt")
+ const content = "Line 1\r\nLine 2\r\nLine 3"
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await write.execute(
+ {
+ filePath: filepath,
+ content,
+ },
+ ctx,
+ )
+
+ const buf = await fs.readFile(filepath)
+ expect(buf.toString()).toBe(content)
+ },
+ })
+ })
+ })
+
+ describe("error handling", () => {
+ test("throws error for paths outside project", async () => {
+ await using tmp = await tmpdir()
+ const outsidePath = "/etc/passwd"
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ await expect(
+ write.execute(
+ {
+ filePath: outsidePath,
+ content: "test",
+ },
+ ctx,
+ ),
+ ).rejects.toThrow()
+ },
+ })
+ })
+ })
+
+ describe("title generation", () => {
+ test("returns relative path as title", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
+ await fs.mkdir(path.dirname(filepath), { recursive: true })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const write = await WriteTool.init()
+ const result = await write.execute(
+ {
+ filePath: filepath,
+ content: "export const Button = () => {}",
+ },
+ ctx,
+ )
+
+ expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
+ },
+ })
+ })
+ })
+})
diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts
index 0e5f0ba38..3c3da0fc7 100644
--- a/packages/opencode/test/util/filesystem.test.ts
+++ b/packages/opencode/test/util/filesystem.test.ts
@@ -1,39 +1,288 @@
-import { describe, expect, test } from "bun:test"
-import os from "node:os"
-import path from "node:path"
-import { mkdtemp, mkdir, rm } from "node:fs/promises"
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
-describe("util.filesystem", () => {
- test("exists() is true for files and directories", async () => {
- const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
- const dir = path.join(tmp, "dir")
- const file = path.join(tmp, "file.txt")
- const missing = path.join(tmp, "missing")
+describe("filesystem", () => {
+ describe("exists()", () => {
+ test("returns true for existing file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
- await mkdir(dir, { recursive: true })
- await Bun.write(file, "hello")
+ expect(await Filesystem.exists(filepath)).toBe(true)
+ })
- const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)])
+ test("returns false for non-existent file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist.txt")
- expect(cases).toEqual([true, true, false])
+ expect(await Filesystem.exists(filepath)).toBe(false)
+ })
- await rm(tmp, { recursive: true, force: true })
+ test("returns true for existing directory", async () => {
+ await using tmp = await tmpdir()
+ const dirpath = path.join(tmp.path, "subdir")
+ await fs.mkdir(dirpath)
+
+ expect(await Filesystem.exists(dirpath)).toBe(true)
+ })
+ })
+
+ describe("isDir()", () => {
+ test("returns true for directory", async () => {
+ await using tmp = await tmpdir()
+ const dirpath = path.join(tmp.path, "testdir")
+ await fs.mkdir(dirpath)
+
+ expect(await Filesystem.isDir(dirpath)).toBe(true)
+ })
+
+ test("returns false for file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ await fs.writeFile(filepath, "content", "utf-8")
+
+ expect(await Filesystem.isDir(filepath)).toBe(false)
+ })
+
+ test("returns false for non-existent path", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist")
+
+ expect(await Filesystem.isDir(filepath)).toBe(false)
+ })
+ })
+
+ describe("size()", () => {
+ test("returns file size", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ const content = "Hello, World!"
+ await fs.writeFile(filepath, content, "utf-8")
+
+ expect(await Filesystem.size(filepath)).toBe(content.length)
+ })
+
+ test("returns 0 for non-existent file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist.txt")
+
+ expect(await Filesystem.size(filepath)).toBe(0)
+ })
+
+ test("returns directory size", async () => {
+ await using tmp = await tmpdir()
+ const dirpath = path.join(tmp.path, "testdir")
+ await fs.mkdir(dirpath)
+
+ // Directories have size on some systems
+ const size = await Filesystem.size(dirpath)
+ expect(typeof size).toBe("number")
+ })
+ })
+
+ describe("readText()", () => {
+ test("reads file content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ const content = "Hello, World!"
+ await fs.writeFile(filepath, content, "utf-8")
+
+ expect(await Filesystem.readText(filepath)).toBe(content)
+ })
+
+ test("throws for non-existent file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist.txt")
+
+ await expect(Filesystem.readText(filepath)).rejects.toThrow()
+ })
+
+ test("reads UTF-8 content correctly", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "unicode.txt")
+ const content = "Hello δΈ–η•Œ 🌍"
+ await fs.writeFile(filepath, content, "utf-8")
+
+ expect(await Filesystem.readText(filepath)).toBe(content)
+ })
+ })
+
+ describe("readJson()", () => {
+ test("reads and parses JSON", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.json")
+ const data = { key: "value", nested: { array: [1, 2, 3] } }
+ await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
+
+ const result: typeof data = await Filesystem.readJson(filepath)
+ expect(result).toEqual(data)
+ })
+
+ test("throws for invalid JSON", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "invalid.json")
+ await fs.writeFile(filepath, "{ invalid json", "utf-8")
+
+ await expect(Filesystem.readJson(filepath)).rejects.toThrow()
+ })
+
+ test("throws for non-existent file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist.json")
+
+ await expect(Filesystem.readJson(filepath)).rejects.toThrow()
+ })
+
+ test("returns typed data", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "typed.json")
+ interface Config {
+ name: string
+ version: number
+ }
+ const data: Config = { name: "test", version: 1 }
+ await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
+
+ const result = await Filesystem.readJson<Config>(filepath)
+ expect(result.name).toBe("test")
+ expect(result.version).toBe(1)
+ })
+ })
+
+ describe("readBytes()", () => {
+ test("reads file as buffer", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ const content = "Hello, World!"
+ await fs.writeFile(filepath, content, "utf-8")
+
+ const buffer = await Filesystem.readBytes(filepath)
+ expect(buffer).toBeInstanceOf(Buffer)
+ expect(buffer.toString("utf-8")).toBe(content)
+ })
+
+ test("throws for non-existent file", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "does-not-exist.bin")
+
+ await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
+ })
+ })
+
+ describe("write()", () => {
+ test("writes text content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.txt")
+ const content = "Hello, World!"
+
+ await Filesystem.write(filepath, content)
+
+ expect(await fs.readFile(filepath, "utf-8")).toBe(content)
+ })
+
+ test("writes buffer content", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "test.bin")
+ const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
+
+ await Filesystem.write(filepath, content)
+
+ const read = await fs.readFile(filepath)
+ expect(read).toEqual(content)
+ })
+
+ test("writes with permissions", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "protected.txt")
+ const content = "secret"
+
+ await Filesystem.write(filepath, content, 0o600)
+
+ const stats = await fs.stat(filepath)
+ // Check permissions on Unix
+ if (process.platform !== "win32") {
+ expect(stats.mode & 0o777).toBe(0o600)
+ }
+ })
+
+ test("creates parent directories", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
+ const content = "nested content"
+
+ await Filesystem.write(filepath, content)
+
+ expect(await fs.readFile(filepath, "utf-8")).toBe(content)
+ })
})
- test("isDir() is true only for directories", async () => {
- const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
- const dir = path.join(tmp, "dir")
- const file = path.join(tmp, "file.txt")
- const missing = path.join(tmp, "missing")
+ describe("writeJson()", () => {
+ test("writes JSON data", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "data.json")
+ const data = { key: "value", number: 42 }
+
+ await Filesystem.writeJson(filepath, data)
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(JSON.parse(content)).toEqual(data)
+ })
+
+ test("writes formatted JSON", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "pretty.json")
+ const data = { key: "value" }
+
+ await Filesystem.writeJson(filepath, data)
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content).toContain("\n")
+ expect(content).toContain(" ")
+ })
+
+ test("writes with permissions", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "config.json")
+ const data = { secret: "data" }
+
+ await Filesystem.writeJson(filepath, data, 0o600)
+
+ const stats = await fs.stat(filepath)
+ if (process.platform !== "win32") {
+ expect(stats.mode & 0o777).toBe(0o600)
+ }
+ })
+ })
+
+ describe("mimeType()", () => {
+ test("returns correct MIME type for JSON", () => {
+ expect(Filesystem.mimeType("test.json")).toContain("application/json")
+ })
+
+ test("returns correct MIME type for JavaScript", () => {
+ expect(Filesystem.mimeType("test.js")).toContain("javascript")
+ })
- await mkdir(dir, { recursive: true })
- await Bun.write(file, "hello")
+ test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
+ const mime = Filesystem.mimeType("test.ts")
+ // .ts is ambiguous: TypeScript vs MPEG-2 TS video
+ expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
+ })
- const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)])
+ test("returns correct MIME type for images", () => {
+ expect(Filesystem.mimeType("test.png")).toContain("image/png")
+ expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
+ })
- expect(cases).toEqual([true, false, false])
+ test("returns default for unknown extension", () => {
+ expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
+ })
- await rm(tmp, { recursive: true, force: true })
+ test("handles files without extension", () => {
+ expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
+ })
})
})