// @effect-diagnostics nodeBuiltinImport:off /** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated * safety checks and cache invalidation hooks. * * @module WorkspaceFileSystem */ import * as NodeFSP from "node:fs/promises"; import type { ProjectReadFileInput, ProjectReadFileResult, ProjectWriteFileInput, ProjectWriteFileResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( "WorkspaceFileSystemError", { cwd: Schema.String, relativePath: Schema.optional(Schema.String), operation: Schema.Literals([ "workspaceFileSystem.readFile", "workspaceFileSystem.makeDirectory", "workspaceFileSystem.writeFile", ]), cause: Schema.Defect(), }, ) { override get message(): string { const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; return `Workspace file operation '${this.operation}' failed for ${target}.`; } } /** Service tag for workspace file operations. */ export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { /** Read a UTF-8 text file relative to the workspace root. */ readonly readFile: ( input: ProjectReadFileInput, ) => Effect.Effect< ProjectReadFileResult, WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError >; /** * Write a file relative to the workspace root. * * Creates parent directories as needed and rejects paths that escape the * workspace root. */ readonly writeFile: ( input: ProjectWriteFileInput, ) => Effect.Effect< ProjectWriteFileResult, WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError >; } >()("t3/workspace/WorkspaceFileSystem") {} export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, }); return yield* Effect.tryPromise({ try: async () => { const [realWorkspaceRoot, realTargetPath] = await Promise.all([ NodeFSP.realpath(input.cwd), NodeFSP.realpath(target.absolutePath), ]); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( relativeRealPath.startsWith(`..${path.sep}`) || relativeRealPath === ".." || path.isAbsolute(relativeRealPath) ) { throw new Error("Workspace file path resolves outside the project root."); } const handle = await NodeFSP.open(realTargetPath, "r"); try { const stat = await handle.stat(); if (!stat.isFile()) { throw new Error("Workspace path is not a file."); } const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); const buffer = Buffer.alloc(bytesToRead); const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); const fileBytes = buffer.subarray(0, bytesRead); if (fileBytes.includes(0)) { throw new Error("Binary files cannot be previewed as text."); } const contents = new TextDecoder("utf-8").decode(fileBytes); return { relativePath: target.relativePath, contents, byteLength: stat.size, truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, }; } finally { await handle.close(); } }, catch: (cause) => new WorkspaceFileSystemError({ cwd: input.cwd, relativePath: input.relativePath, operation: "workspaceFileSystem.readFile", cause, }), }); }); const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( "WorkspaceFileSystem.writeFile", )(function* (input) { const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, }); yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemError({ cwd: input.cwd, relativePath: input.relativePath, operation: "workspaceFileSystem.makeDirectory", cause, }), ), ); yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemError({ cwd: input.cwd, relativePath: input.relativePath, operation: "workspaceFileSystem.writeFile", cause, }), ), ); yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; }); return WorkspaceFileSystem.of({ readFile, writeFile }); }); export const layer = Layer.effect(WorkspaceFileSystem, make);