mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-29 21:33:54 +00:00
feat(filesystem): add AppFileSystem service, migrate Snapshot (#18138)
This commit is contained in:
parent
773c1192dc
commit
81be544981
@ -6,6 +6,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prepare": "effect-language-service patch || true",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "bun test --timeout 30000",
|
"test": "bun test --timeout 30000",
|
||||||
"build": "bun run script/build.ts",
|
"build": "bun run script/build.ts",
|
||||||
|
|||||||
197
packages/opencode/src/filesystem/index.ts
Normal file
197
packages/opencode/src/filesystem/index.ts
Normal file
@ -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("..")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodePath } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
|
import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
|
||||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
@ -24,12 +25,12 @@ export namespace Discovery {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
|
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(
|
Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const log = Log.create({ service: "skill-discovery" })
|
const log = Log.create({ service: "skill-discovery" })
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* AppFileSystem.Service
|
||||||
const path = yield* Path.Path
|
const path = yield* Path.Path
|
||||||
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
|
||||||
const cache = path.join(Global.Path.cache, "skills")
|
const cache = path.join(Global.Path.cache, "skills")
|
||||||
@ -40,11 +41,7 @@ export namespace Discovery {
|
|||||||
return yield* HttpClientRequest.get(url).pipe(
|
return yield* HttpClientRequest.get(url).pipe(
|
||||||
http.execute,
|
http.execute,
|
||||||
Effect.flatMap((res) => res.arrayBuffer),
|
Effect.flatMap((res) => res.arrayBuffer),
|
||||||
Effect.flatMap((body) =>
|
Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
|
||||||
fs
|
|
||||||
.makeDirectory(path.dirname(dest), { recursive: true })
|
|
||||||
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
|
|
||||||
),
|
|
||||||
Effect.as(true),
|
Effect.as(true),
|
||||||
Effect.catch((err) =>
|
Effect.catch((err) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
@ -113,7 +110,7 @@ export namespace Discovery {
|
|||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
|
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 { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { InstanceContext } from "@/effect/instance-context"
|
import { InstanceContext } from "@/effect/instance-context"
|
||||||
import { runPromiseInstance } from "@/effect/runtime"
|
import { runPromiseInstance } from "@/effect/runtime"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@ -85,12 +86,12 @@ export namespace Snapshot {
|
|||||||
export const layer: Layer.Layer<
|
export const layer: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
|
InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceContext
|
const ctx = yield* InstanceContext
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* AppFileSystem.Service
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const directory = ctx.directory
|
const directory = ctx.directory
|
||||||
const worktree = ctx.worktree
|
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 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 read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
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 sync = Effect.fnUntraced(function* () {
|
||||||
const file = yield* excludes()
|
const file = yield* excludes()
|
||||||
const target = path.join(gitdir, "info", "exclude")
|
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) {
|
if (!file) {
|
||||||
yield* write(target, "")
|
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
yield* write(target, yield* read(file))
|
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const add = Effect.fnUntraced(function* () {
|
const add = Effect.fnUntraced(function* () {
|
||||||
@ -178,7 +178,7 @@ export namespace Snapshot {
|
|||||||
const track = Effect.fn("Snapshot.track")(function* () {
|
const track = Effect.fn("Snapshot.track")(function* () {
|
||||||
if (!(yield* enabled())) return
|
if (!(yield* enabled())) return
|
||||||
const existed = yield* exists(gitdir)
|
const existed = yield* exists(gitdir)
|
||||||
yield* mkdir(gitdir)
|
yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
|
||||||
if (!existed) {
|
if (!existed) {
|
||||||
yield* git(["init"], {
|
yield* git(["init"], {
|
||||||
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
|
||||||
@ -342,7 +342,8 @@ export namespace Snapshot {
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(NodeChildProcessSpawner.layer),
|
Layer.provide(NodeChildProcessSpawner.layer),
|
||||||
Layer.provide(NodeFileSystem.layer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
|
||||||
Layer.provide(NodePath.layer),
|
Layer.provide(NodePath.layer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodePath } from "@effect/platform-node"
|
||||||
import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
|
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { PermissionNext } from "../permission"
|
import { PermissionNext } from "../permission"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
@ -44,7 +45,7 @@ export namespace TruncateEffect {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
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 preview = out.join("\n")
|
||||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
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)
|
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
||||||
|
|
||||||
const hint = hasTaskTool(agent)
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
319
packages/opencode/test/filesystem/filesystem.test.ts
Normal file
319
packages/opencode/test/filesystem/filesystem.test.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user