import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" const provide = (directory: string) => Effect.provide( FileSystem.layer.pipe( Layer.provide( Layer.mergeAll( FSUtil.defaultLayer, Ripgrep.defaultLayer, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), ), ), ), ) const withTmp = (f: (directory: string) => Effect.Effect) => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ).pipe(Effect.flatMap((tmp) => f(tmp.path))) describe("FileSystem", () => { const expectFail = (exit: Exit.Exit) => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isSuccess(exit)) return expect(Cause.hasFails(exit.cause)).toBe(true) expect(Cause.hasDies(exit.cause)).toBe(false) } it.live("reads text and binary files", () => withTmp((directory) => Effect.gen(function* () { yield* Effect.promise(() => fs.writeFile(path.join(directory, "text.txt"), "hello")) yield* Effect.promise(() => fs.writeFile(path.join(directory, "data.bin"), Buffer.from([0, 1, 2]))) const service = yield* FileSystem.Service const text = yield* service.read({ path: RelativePath.make("text.txt") }) const binary = yield* service.read({ path: RelativePath.make("data.bin") }) expect(new TextDecoder().decode(text.content)).toBe("hello") expect(text.mime).toBe("text/plain") expect(binary.content).toEqual(new Uint8Array([0, 1, 2])) }).pipe(provide(directory)), ), ) it.live("lists direct children", () => withTmp((directory) => Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) const entries = yield* (yield* FileSystem.Service).list() expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([ { path: RelativePath.make("src" + path.sep), type: "directory" }, { path: RelativePath.make("README.md"), type: "file" }, ]) }).pipe(provide(directory)), ), ) it.live("fails for missing paths", () => withTmp((directory) => Effect.gen(function* () { const exit = yield* (yield* FileSystem.Service) .read({ path: RelativePath.make("missing.txt") }) .pipe(Effect.exit) expectFail(exit) }).pipe(provide(directory)), ), ) it.live("fails for wrong path kinds", () => withTmp((directory) => Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) const service = yield* FileSystem.Service expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit)) expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit)) }).pipe(provide(directory)), ), ) it.live("fails for lexical and symlink escapes", () => withTmp((directory) => Effect.gen(function* () { const outside = path.join(directory, "..", "outside.txt") yield* Effect.promise(() => fs.writeFile(outside, "outside")) yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt"))) const service = yield* FileSystem.Service expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)) expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit)) yield* Effect.promise(() => fs.rm(outside)) }).pipe(provide(directory)), ), ) })