/** * 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 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 Option from "effect/Option"; 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 WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( "WorkspaceFileSystemOperationError", { workspaceRoot: Schema.String, relativePath: Schema.String, resolvedPath: Schema.String, operationPath: Schema.String, operation: Schema.Literals([ "realpath-workspace-root", "realpath-target", "open", "stat", "read", "close", "make-directory", "write-file", ]), cause: Schema.Defect(), }, ) { override get message(): string { return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; } } export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass()( "WorkspaceFilePathEscapeError", { workspaceRoot: Schema.String, relativePath: Schema.String, resolvedWorkspaceRoot: Schema.String, resolvedPath: Schema.String, }, ) { override get message(): string { return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`; } } export class WorkspacePathNotFileError extends Schema.TaggedErrorClass()( "WorkspacePathNotFileError", { workspaceRoot: Schema.String, relativePath: Schema.String, resolvedPath: Schema.String, }, ) { override get message(): string { return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`; } } export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass()( "WorkspaceBinaryFileError", { workspaceRoot: Schema.String, relativePath: Schema.String, resolvedPath: Schema.String, }, ) { override get message(): string { return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`; } } export const WorkspaceFileSystemError = Schema.Union([ WorkspaceFileSystemOperationError, WorkspaceFilePathEscapeError, WorkspacePathNotFileError, WorkspaceBinaryFileError, ]); export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; /** 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, }); const realWorkspaceRoot = yield* fileSystem.realPath(input.cwd).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, operationPath: input.cwd, operation: "realpath-workspace-root", cause, }), ), ); const realTargetPath = yield* fileSystem.realPath(target.absolutePath).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, operationPath: target.absolutePath, operation: "realpath-target", cause, }), ), ); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( relativeRealPath.startsWith(`..${path.sep}`) || relativeRealPath === ".." || path.isAbsolute(relativeRealPath) ) { return yield* new WorkspaceFilePathEscapeError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedWorkspaceRoot: realWorkspaceRoot, resolvedPath: realTargetPath, }); } return yield* Effect.gen(function* () { const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, operationPath: realTargetPath, operation: "open", cause, }), ), ); const stat = yield* handle.stat.pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, operationPath: realTargetPath, operation: "stat", cause, }), ), ); if (stat.type !== "File") { return yield* new WorkspacePathNotFileError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, }); } const byteLength = Number(stat.size); const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES); const fileBytes = yield* handle.readAlloc(FileSystem.Size(bytesToRead)).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, operationPath: realTargetPath, operation: "read", cause, }), ), Effect.map(Option.getOrElse(() => new Uint8Array())), ); if (fileBytes.includes(0)) { return yield* new WorkspaceBinaryFileError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, }); } return { relativePath: target.relativePath, contents: new TextDecoder("utf-8").decode(fileBytes), byteLength, truncated: byteLength > PROJECT_READ_FILE_MAX_BYTES, }; }).pipe(Effect.scoped); }); 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 WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, operationPath: path.dirname(target.absolutePath), operation: "make-directory", cause, }), ), ); yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, operationPath: target.absolutePath, operation: "write-file", cause, }), ), ); yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; }); return WorkspaceFileSystem.of({ readFile, writeFile }); }); export const layer = Layer.effect(WorkspaceFileSystem, make);