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