summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-18 19:52:43 -0400
committerGitHub <[email protected]>2026-03-18 19:52:43 -0400
commit81be544981d04cc48b2aa5193c1b2b7096ec8bc4 (patch)
tree993019ff90bcc2d2c781a8098abcd4f61dc82a9a
parent773c1192dcbb7c6e720e5f1728fb948181e0c272 (diff)
downloadopencode-81be544981d04cc48b2aa5193c1b2b7096ec8bc4.tar.gz
opencode-81be544981d04cc48b2aa5193c1b2b7096ec8bc4.zip
feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138)
-rw-r--r--packages/opencode/package.json1
-rw-r--r--packages/opencode/src/filesystem/index.ts197
-rw-r--r--packages/opencode/src/skill/discovery.ts17
-rw-r--r--packages/opencode/src/snapshot/index.ts21
-rw-r--r--packages/opencode/src/tool/truncate-effect.ts11
-rw-r--r--packages/opencode/test/filesystem/filesystem.test.ts319
6 files changed, 541 insertions, 25 deletions
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 4bdc3a963..a6caca8ad 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -6,6 +6,7 @@
"license": "MIT",
"private": true,
"scripts": {
+ "prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts
new file mode 100644
index 000000000..d8f7d6053
--- /dev/null
+++ b/packages/opencode/src/filesystem/index.ts
@@ -0,0 +1,197 @@
+import { NodeFileSystem } from "@effect/platform-node"
+import { dirname, join, relative, resolve as pathResolve } from "path"
+import { realpathSync } from "fs"
+import { lookup } from "mime-types"
+import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
+import type { PlatformError } from "effect/PlatformError"
+import { Glob } from "../util/glob"
+
+export namespace AppFileSystem {
+ export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
+ method: Schema.String,
+ cause: Schema.optional(Schema.Defect),
+ }) {}
+
+ export type Error = PlatformError | FileSystemError
+
+ export interface Interface extends FileSystem.FileSystem {
+ readonly isDir: (path: string) => Effect.Effect<boolean, Error>
+ readonly isFile: (path: string) => Effect.Effect<boolean, Error>
+ readonly readJson: (path: string) => Effect.Effect<unknown, Error>
+ readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
+ readonly ensureDir: (path: string) => Effect.Effect<void, Error>
+ readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
+ readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
+ readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
+ readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
+ readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
+ readonly globMatch: (pattern: string, filepath: string) => boolean
+ }
+
+ export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
+
+ export const layer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+
+ const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
+ const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
+ return info?.type === "Directory"
+ })
+
+ const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
+ const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
+ return info?.type === "File"
+ })
+
+ const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
+ const text = yield* fs.readFileString(path)
+ return JSON.parse(text)
+ })
+
+ const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
+ const content = JSON.stringify(data, null, 2)
+ yield* fs.writeFileString(path, content)
+ if (mode) yield* fs.chmod(path, mode)
+ })
+
+ const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
+ yield* fs.makeDirectory(path, { recursive: true })
+ })
+
+ const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
+ path: string,
+ content: string | Uint8Array,
+ mode?: number,
+ ) {
+ const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
+
+ yield* write.pipe(
+ Effect.catchIf(
+ (e) => e.reason._tag === "NotFound",
+ () =>
+ Effect.gen(function* () {
+ yield* fs.makeDirectory(dirname(path), { recursive: true })
+ yield* write
+ }),
+ ),
+ )
+ if (mode) yield* fs.chmod(path, mode)
+ })
+
+ const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
+ return yield* Effect.tryPromise({
+ try: () => Glob.scan(pattern, options),
+ catch: (cause) => new FileSystemError({ method: "glob", cause }),
+ })
+ })
+
+ const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
+ const result: string[] = []
+ let current = start
+ while (true) {
+ const search = join(current, target)
+ if (yield* fs.exists(search)) result.push(search)
+ if (stop === current) break
+ const parent = dirname(current)
+ if (parent === current) break
+ current = parent
+ }
+ return result
+ })
+
+ const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
+ const result: string[] = []
+ let current = options.start
+ while (true) {
+ for (const target of options.targets) {
+ const search = join(current, target)
+ if (yield* fs.exists(search)) result.push(search)
+ }
+ if (options.stop === current) break
+ const parent = dirname(current)
+ if (parent === current) break
+ current = parent
+ }
+ return result
+ })
+
+ const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
+ const result: string[] = []
+ let current = start
+ while (true) {
+ const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
+ Effect.catch(() => Effect.succeed([] as string[])),
+ )
+ result.push(...matches)
+ if (stop === current) break
+ const parent = dirname(current)
+ if (parent === current) break
+ current = parent
+ }
+ return result
+ })
+
+ return Service.of({
+ ...fs,
+ isDir,
+ isFile,
+ readJson,
+ writeJson,
+ ensureDir,
+ writeWithDirs,
+ findUp,
+ up,
+ globUp,
+ glob,
+ globMatch: Glob.match,
+ })
+ }),
+ )
+
+ export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
+
+ // Pure helpers that don't need Effect (path manipulation, sync operations)
+ export function mimeType(p: string): string {
+ return lookup(p) || "application/octet-stream"
+ }
+
+ export function normalizePath(p: string): string {
+ if (process.platform !== "win32") return p
+ try {
+ return realpathSync.native(p)
+ } catch {
+ return p
+ }
+ }
+
+ export function resolve(p: string): string {
+ const resolved = pathResolve(windowsPath(p))
+ try {
+ return normalizePath(realpathSync(resolved))
+ } catch (e: any) {
+ if (e?.code === "ENOENT") return normalizePath(resolved)
+ throw e
+ }
+ }
+
+ export function windowsPath(p: string): string {
+ if (process.platform !== "win32") return p
+ return p
+ .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+ .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
+ }
+
+ export function overlaps(a: string, b: string) {
+ const relA = relative(a, b)
+ const relB = relative(b, a)
+ return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
+ }
+
+ export function contains(parent: string, child: string) {
+ return !relative(parent, child).startsWith("..")
+ }
+}
diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts
index e5279503d..e10397503 100644
--- a/packages/opencode/src/skill/discovery.ts
+++ b/packages/opencode/src/skill/discovery.ts
@@ -1,7 +1,8 @@
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
+import { NodePath } from "@effect/platform-node"
+import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
+import { AppFileSystem } from "@/filesystem"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -24,12 +25,12 @@ export namespace Discovery {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
- export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
+ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Path.Path | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
- const fs = yield* FileSystem.FileSystem
+ const fs = yield* AppFileSystem.Service
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
@@ -40,11 +41,7 @@ export namespace Discovery {
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
- Effect.flatMap((body) =>
- fs
- .makeDirectory(path.dirname(dest), { recursive: true })
- .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
- ),
+ Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
@@ -113,7 +110,7 @@ export namespace Discovery {
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
- Layer.provide(NodeFileSystem.layer),
+ Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
}
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 9f0eef56b..887bce334 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,10 +1,11 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
+import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -85,12 +86,12 @@ export namespace Snapshot {
export const layer: Layer.Layer<
Service,
never,
- InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
+ InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
- const fs = yield* FileSystem.FileSystem
+ const fs = yield* AppFileSystem.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
@@ -124,9 +125,8 @@ export namespace Snapshot {
),
)
+ // Snapshot-specific error handling on top of AppFileSystem
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
- const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
- const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
@@ -148,12 +148,12 @@ export namespace Snapshot {
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
- yield* mkdir(path.join(gitdir, "info"))
+ yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
if (!file) {
- yield* write(target, "")
+ yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
- yield* write(target, yield* read(file))
+ yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
})
const add = Effect.fnUntraced(function* () {
@@ -178,7 +178,7 @@ export namespace Snapshot {
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
- yield* mkdir(gitdir)
+ yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
@@ -342,7 +342,8 @@ export namespace Snapshot {
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
- Layer.provide(NodeFileSystem.layer),
+ Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
Layer.provide(NodePath.layer),
)
}
diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts
index 60b9d0fa8..4431c18f8 100644
--- a/packages/opencode/src/tool/truncate-effect.ts
+++ b/packages/opencode/src/tool/truncate-effect.ts
@@ -1,7 +1,8 @@
-import { NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
+import { NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
+import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
@@ -44,7 +45,7 @@ export namespace TruncateEffect {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem
+ const fs = yield* AppFileSystem.Service
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
@@ -101,7 +102,7 @@ export namespace TruncateEffect {
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
- yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
+ yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
@@ -132,5 +133,5 @@ export namespace TruncateEffect {
}),
)
- export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
}
diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts
new file mode 100644
index 000000000..ca73b3336
--- /dev/null
+++ b/packages/opencode/test/filesystem/filesystem.test.ts
@@ -0,0 +1,319 @@
+import { describe, test, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import { NodeFileSystem } from "@effect/platform-node"
+import { AppFileSystem } from "../../src/filesystem"
+import { testEffect } from "../lib/effect"
+import path from "path"
+
+const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer))
+const { effect: it } = testEffect(live)
+
+describe("AppFileSystem", () => {
+ describe("isDir", () => {
+ it(
+ "returns true for directories",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ expect(yield* fs.isDir(tmp)).toBe(true)
+ }),
+ )
+
+ it(
+ "returns false for files",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "test.txt")
+ yield* fs.writeFileString(file, "hello")
+ expect(yield* fs.isDir(file)).toBe(false)
+ }),
+ )
+
+ it(
+ "returns false for non-existent paths",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
+ }),
+ )
+ })
+
+ describe("isFile", () => {
+ it(
+ "returns true for files",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "test.txt")
+ yield* fs.writeFileString(file, "hello")
+ expect(yield* fs.isFile(file)).toBe(true)
+ }),
+ )
+
+ it(
+ "returns false for directories",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ expect(yield* fs.isFile(tmp)).toBe(false)
+ }),
+ )
+ })
+
+ describe("readJson / writeJson", () => {
+ it(
+ "round-trips JSON data",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "data.json")
+ const data = { name: "test", count: 42, nested: { ok: true } }
+
+ yield* fs.writeJson(file, data)
+ const result = yield* fs.readJson(file)
+
+ expect(result).toEqual(data)
+ }),
+ )
+ })
+
+ describe("ensureDir", () => {
+ it(
+ "creates nested directories",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const nested = path.join(tmp, "a", "b", "c")
+
+ yield* fs.ensureDir(nested)
+
+ const info = yield* fs.stat(nested)
+ expect(info.type).toBe("Directory")
+ }),
+ )
+
+ it(
+ "is idempotent",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const dir = path.join(tmp, "existing")
+ yield* fs.makeDirectory(dir)
+
+ yield* fs.ensureDir(dir)
+
+ const info = yield* fs.stat(dir)
+ expect(info.type).toBe("Directory")
+ }),
+ )
+ })
+
+ describe("writeWithDirs", () => {
+ it(
+ "creates parent directories if missing",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "deep", "nested", "file.txt")
+
+ yield* fs.writeWithDirs(file, "hello")
+
+ expect(yield* fs.readFileString(file)).toBe("hello")
+ }),
+ )
+
+ it(
+ "writes directly when parent exists",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "direct.txt")
+
+ yield* fs.writeWithDirs(file, "world")
+
+ expect(yield* fs.readFileString(file)).toBe("world")
+ }),
+ )
+
+ it(
+ "writes Uint8Array content",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "binary.bin")
+ const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
+
+ yield* fs.writeWithDirs(file, content)
+
+ const result = yield* fs.readFile(file)
+ expect(new Uint8Array(result)).toEqual(content)
+ }),
+ )
+ })
+
+ describe("findUp", () => {
+ it(
+ "finds target in start directory",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
+
+ const result = yield* fs.findUp("target.txt", tmp)
+ expect(result).toEqual([path.join(tmp, "target.txt")])
+ }),
+ )
+
+ it(
+ "finds target in parent directories",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "marker"), "root")
+ const child = path.join(tmp, "a", "b")
+ yield* fs.makeDirectory(child, { recursive: true })
+
+ const result = yield* fs.findUp("marker", child, tmp)
+ expect(result).toEqual([path.join(tmp, "marker")])
+ }),
+ )
+
+ it(
+ "returns empty array when not found",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const result = yield* fs.findUp("nonexistent", tmp, tmp)
+ expect(result).toEqual([])
+ }),
+ )
+ })
+
+ describe("up", () => {
+ it(
+ "finds multiple targets walking up",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
+ yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
+ const child = path.join(tmp, "sub")
+ yield* fs.makeDirectory(child)
+ yield* fs.writeFileString(path.join(child, "a.txt"), "a-child")
+
+ const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
+
+ expect(result).toContain(path.join(child, "a.txt"))
+ expect(result).toContain(path.join(tmp, "a.txt"))
+ expect(result).toContain(path.join(tmp, "b.txt"))
+ }),
+ )
+ })
+
+ describe("glob", () => {
+ it(
+ "finds files matching pattern",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
+ yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
+ yield* fs.writeFileString(path.join(tmp, "c.json"), "c")
+
+ const result = yield* fs.glob("*.ts", { cwd: tmp })
+ expect(result.sort()).toEqual(["a.ts", "b.ts"])
+ }),
+ )
+
+ it(
+ "supports absolute paths",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
+
+ const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
+ expect(result).toEqual([path.join(tmp, "file.txt")])
+ }),
+ )
+ })
+
+ describe("globMatch", () => {
+ it(
+ "matches patterns",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
+ expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
+ expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
+ }),
+ )
+ })
+
+ describe("globUp", () => {
+ it(
+ "finds files walking up directories",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
+ const child = path.join(tmp, "a", "b")
+ yield* fs.makeDirectory(child, { recursive: true })
+ yield* fs.writeFileString(path.join(child, "leaf.md"), "leaf")
+
+ const result = yield* fs.globUp("*.md", child, tmp)
+ expect(result).toContain(path.join(child, "leaf.md"))
+ expect(result).toContain(path.join(tmp, "root.md"))
+ }),
+ )
+ })
+
+ describe("built-in passthrough", () => {
+ it(
+ "exists works",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "exists.txt")
+ yield* fs.writeFileString(file, "yes")
+
+ expect(yield* fs.exists(file)).toBe(true)
+ expect(yield* fs.exists(file + ".nope")).toBe(false)
+ }),
+ )
+
+ it(
+ "remove works",
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const tmp = yield* fs.makeTempDirectoryScoped()
+ const file = path.join(tmp, "delete-me.txt")
+ yield* fs.writeFileString(file, "bye")
+
+ yield* fs.remove(file)
+
+ expect(yield* fs.exists(file)).toBe(false)
+ }),
+ )
+ })
+
+ describe("pure helpers", () => {
+ test("mimeType returns correct types", () => {
+ expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
+ expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
+ expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
+ })
+
+ test("contains checks path containment", () => {
+ expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
+ expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
+ })
+
+ test("overlaps detects overlapping paths", () => {
+ expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
+ expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
+ expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
+ })
+ })
+})