/**
* WorkspacePaths - Effect service contract for workspace path handling.
*
* Owns normalization and validation of workspace roots plus safe resolution of
* workspace-root-relative paths.
*
* @module WorkspacePaths
*/
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 { expandHomePathWith } from "../pathExpansion.ts";
export class WorkspaceRootNotExistsError extends Schema.TaggedError<WorkspaceRootNotExistsError>()(
"WorkspaceRootNotExistsError",
{
workspaceRoot: Schema.String,
normalizedWorkspaceRoot: Schema.String,
},
) {
override get message(): string {
return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
}
}
export class WorkspaceRootCreateFailedError extends Schema.TaggedError<WorkspaceRootCreateFailedError>()(
"WorkspaceRootCreateFailedError",
{
workspaceRoot: Schema.String,
normalizedWorkspaceRoot: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`;
}
}
export class WorkspaceRootStatFailedError extends Schema.TaggedError<WorkspaceRootStatFailedError>()(
"WorkspaceRootStatFailedError",
{
workspaceRoot: Schema.String,
normalizedWorkspaceRoot: Schema.String,
phase: Schema.Literals(["validate-existing", "verify-created"]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`;
}
}
export class WorkspaceRootNotDirectoryError extends Schema.TaggedError<WorkspaceRootNotDirectoryError>()(
"WorkspaceRootNotDirectoryError",
{
workspaceRoot: Schema.String,
normalizedWorkspaceRoot: Schema.String,
},
) {
override get message(): string {
return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`;
}
}
export class WorkspacePathOutsideRootError extends Schema.TaggedError<WorkspacePathOutsideRootError>()(
"WorkspacePathOutsideRootError",
{
workspaceRoot: Schema.String,
relativePath: Schema.String,
},
) {
override get message(): string {
return `Workspace file path must be relative to the project root: ${this.relativePath}`;
}
}
export const WorkspacePathsError = Schema.Union([
WorkspaceRootNotExistsError,
WorkspaceRootCreateFailedError,
WorkspaceRootStatFailedError,
WorkspaceRootNotDirectoryError,
WorkspacePathOutsideRootError,
]);
export type WorkspacePathsError = typeof WorkspacePathsError.Type;
/** Service tag for workspace path normalization and resolution. */
export class WorkspacePaths extends Context.Service<
WorkspacePaths,
{
/** Normalize a user-provided workspace root and verify it exists as a directory. */
readonly normalizeWorkspaceRoot: (
workspaceRoot: string,
options?: { readonly createIfMissing?: boolean },
) => Effect.Effect<
string,
| WorkspaceRootNotExistsError
| WorkspaceRootCreateFailedError
| WorkspaceRootStatFailedError
| WorkspaceRootNotDirectoryError
>;
/**
* Resolve a relative path within a validated workspace root.
*
* Rejects absolute paths and traversal attempts outside the workspace root.
*/
readonly resolveRelativePathWithinRoot: (input: {
workspaceRoot: string;
relativePath: string;
}) => Effect.Effect<
{ absolutePath: string; relativePath: string },
WorkspacePathOutsideRootError
>;
}
>()("t3/workspace/WorkspacePaths") {}
function toPosixRelativePath(input: string): string {
return input.replaceAll("\\", "/");
}
export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* (
workspaceRoot: string,
normalizedWorkspaceRoot: string,
phase: WorkspaceRootStatFailedError["phase"],
) {
return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe(
Effect.matchEffect({
onFailure: (cause) =>
cause.reason._tag === "NotFound"
? Effect.succeed(null)
: Effect.fail(
new WorkspaceRootStatFailedError({
workspaceRoot,
normalizedWorkspaceRoot,
phase,
cause,
}),
),
onSuccess: Effect.succeed,
}),
);
});
const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
"WorkspacePaths.normalizeWorkspaceRoot",
)(function* (workspaceRoot, options) {
const normalizedWorkspaceRoot = path.resolve(expandHomePathWith(workspaceRoot.trim(), path));
let workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"validate-existing",
);
if (!workspaceStat && options?.createIfMissing) {
yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
Effect.mapError(
(cause) =>
new WorkspaceRootCreateFailedError({
workspaceRoot,
normalizedWorkspaceRoot,
cause,
}),
),
);
workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"verify-created",
);
}
if (!workspaceStat) {
return yield* new WorkspaceRootNotExistsError({
workspaceRoot,
normalizedWorkspaceRoot,
});
}
if (workspaceStat.type !== "Directory") {
return yield* new WorkspaceRootNotDirectoryError({
workspaceRoot,
normalizedWorkspaceRoot,
});
}
return normalizedWorkspaceRoot;
});
const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] =
Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) {
const normalizedInputPath = input.relativePath.trim();
if (path.isAbsolute(normalizedInputPath)) {
return yield* new WorkspacePathOutsideRootError({
workspaceRoot: input.workspaceRoot,
relativePath: input.relativePath,
});
}
const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
if (
relativeToRoot.length === 0 ||
relativeToRoot === "." ||
relativeToRoot.startsWith("../") ||
relativeToRoot === ".." ||
path.isAbsolute(relativeToRoot)
) {
return yield* new WorkspacePathOutsideRootError({
workspaceRoot: input.workspaceRoot,
relativePath: input.relativePath,
});
}
return {
absolutePath,
relativePath: relativeToRoot,
};
});
return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot });
});
export const layer = Layer.effect(WorkspacePaths, make);