1/**
2 * WorkspacePaths - Effect service contract for workspace path handling.
3 *
4 * Owns normalization and validation of workspace roots plus safe resolution of
5 * workspace-root-relative paths.
6 *
7 * @module WorkspacePaths
8 */
9
10import * as Context from "effect/Context";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
16
17import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
18import * as HostProcess from "@t3tools/shared/HostProcess";
19
20export class WorkspaceRootNotExistsError extends Schema.TaggedError<WorkspaceRootNotExistsError>()(
21 "WorkspaceRootNotExistsError",
22 {
23 workspaceRoot: Schema.String,
24 normalizedWorkspaceRoot: Schema.String,
25 },
26) {
27 override get message(): string {
28 return `Workspace root does not exist: ${this.normalizedWorkspaceRoot}`;
29 }
30}
31
32export class WorkspaceRootCreateFailedError extends Schema.TaggedError<WorkspaceRootCreateFailedError>()(
33 "WorkspaceRootCreateFailedError",
34 {
35 workspaceRoot: Schema.String,
36 normalizedWorkspaceRoot: Schema.String,
37 cause: Schema.Defect(),
38 },
39) {
40 override get message(): string {
41 return `Failed to create workspace root: ${this.normalizedWorkspaceRoot}`;
42 }
43}
44
45export class WorkspaceRootStatFailedError extends Schema.TaggedError<WorkspaceRootStatFailedError>()(
46 "WorkspaceRootStatFailedError",
47 {
48 workspaceRoot: Schema.String,
49 normalizedWorkspaceRoot: Schema.String,
50 phase: Schema.Literals(["validate-existing", "verify-created"]),
51 cause: Schema.Defect(),
52 },
53) {
54 override get message(): string {
55 return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`;
56 }
57}
58
59export class WorkspaceRootNotDirectoryError extends Schema.TaggedError<WorkspaceRootNotDirectoryError>()(
60 "WorkspaceRootNotDirectoryError",
61 {
62 workspaceRoot: Schema.String,
63 normalizedWorkspaceRoot: Schema.String,
64 },
65) {
66 override get message(): string {
67 return `Workspace root is not a directory: ${this.normalizedWorkspaceRoot}`;
68 }
69}
70
71export class WorkspacePathOutsideRootError extends Schema.TaggedError<WorkspacePathOutsideRootError>()(
72 "WorkspacePathOutsideRootError",
73 {
74 workspaceRoot: Schema.String,
75 relativePath: Schema.String,
76 },
77) {
78 override get message(): string {
79 return `Workspace file path must be relative to the project root: ${this.relativePath}`;
80 }
81}
82
83export const WorkspacePathsError = Schema.Union([
84 WorkspaceRootNotExistsError,
85 WorkspaceRootCreateFailedError,
86 WorkspaceRootStatFailedError,
87 WorkspaceRootNotDirectoryError,
88 WorkspacePathOutsideRootError,
89]);
90export type WorkspacePathsError = typeof WorkspacePathsError.Type;
91
92/** Service tag for workspace path normalization and resolution. */
93export class WorkspacePaths extends Context.Service<
94 WorkspacePaths,
95 {
96 /** Normalize a user-provided workspace root and verify it exists as a directory. */
97 readonly normalizeWorkspaceRoot: (
98 workspaceRoot: string,
99 options?: { readonly createIfMissing?: boolean },
100 ) => Effect.Effect<
101 string,
102 | WorkspaceRootNotExistsError
103 | WorkspaceRootCreateFailedError
104 | WorkspaceRootStatFailedError
105 | WorkspaceRootNotDirectoryError
106 >;
107 /**
108 * Resolve a relative path within a validated workspace root.
109 *
110 * Rejects absolute paths and traversal attempts outside the workspace root.
111 */
112 readonly resolveRelativePathWithinRoot: (input: {
113 workspaceRoot: string;
114 relativePath: string;
115 }) => Effect.Effect<
116 { absolutePath: string; relativePath: string },
117 WorkspacePathOutsideRootError
118 >;
119 }
120>()("t3/workspace/WorkspacePaths") {}
121
122function toPosixRelativePath(input: string): string {
123 return input.replaceAll("\\", "/");
124}
125
126export const make = Effect.gen(function* () {
127 const fileSystem = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129
130 const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* (
131 workspaceRoot: string,
132 normalizedWorkspaceRoot: string,
133 phase: WorkspaceRootStatFailedError["phase"],
134 ) {
135 return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe(
136 Effect.matchEffect({
137 onFailure: (cause) =>
138 cause.reason._tag === "NotFound"
139 ? Effect.succeed(null)
140 : Effect.fail(
141 new WorkspaceRootStatFailedError({
142 workspaceRoot,
143 normalizedWorkspaceRoot,
144 phase,
145 cause,
146 }),
147 ),
148 onSuccess: Effect.succeed,
149 }),
150 );
151 });
152
153 const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
154 "WorkspacePaths.normalizeWorkspaceRoot",
155 )(function* (workspaceRoot, options) {
156 const normalizedWorkspaceRoot = path.resolve(
157 expandHomePath(workspaceRoot.trim(), yield* HostProcess.HomeDirectory),
158 );
159 let workspaceStat = yield* statWorkspaceRoot(
160 workspaceRoot,
161 normalizedWorkspaceRoot,
162 "validate-existing",
163 );
164 if (!workspaceStat && options?.createIfMissing) {
165 yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
166 Effect.mapError(
167 (cause) =>
168 new WorkspaceRootCreateFailedError({
169 workspaceRoot,
170 normalizedWorkspaceRoot,
171 cause,
172 }),
173 ),
174 );
175 workspaceStat = yield* statWorkspaceRoot(
176 workspaceRoot,
177 normalizedWorkspaceRoot,
178 "verify-created",
179 );
180 }
181 if (!workspaceStat) {
182 return yield* new WorkspaceRootNotExistsError({
183 workspaceRoot,
184 normalizedWorkspaceRoot,
185 });
186 }
187 if (workspaceStat.type !== "Directory") {
188 return yield* new WorkspaceRootNotDirectoryError({
189 workspaceRoot,
190 normalizedWorkspaceRoot,
191 });
192 }
193 return normalizedWorkspaceRoot;
194 });
195
196 const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] =
197 Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) {
198 const normalizedInputPath = input.relativePath.trim();
199 if (path.isAbsolute(normalizedInputPath)) {
200 return yield* new WorkspacePathOutsideRootError({
201 workspaceRoot: input.workspaceRoot,
202 relativePath: input.relativePath,
203 });
204 }
205
206 const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath);
207 const relativeToRoot = toPosixRelativePath(path.relative(input.workspaceRoot, absolutePath));
208 if (
209 relativeToRoot.length === 0 ||
210 relativeToRoot === "." ||
211 relativeToRoot.startsWith("../") ||
212 relativeToRoot === ".." ||
213 path.isAbsolute(relativeToRoot)
214 ) {
215 return yield* new WorkspacePathOutsideRootError({
216 workspaceRoot: input.workspaceRoot,
217 relativePath: input.relativePath,
218 });
219 }
220
221 return {
222 absolutePath,
223 relativePath: relativeToRoot,
224 };
225 });
226
227 return WorkspacePaths.of({ normalizeWorkspaceRoot, resolveRelativePathWithinRoot });
228});
229
230export const layer = Layer.effect(WorkspacePaths, make);