1/**
2 * T3ProjectFileLoader - Effect service that loads the checked-in `t3.json`
3 * project file from a workspace root.
4 *
5 * Loading is best-effort: a missing file resolves to `Option.none`, and
6 * unreadable or invalid files are logged and treated as absent so callers
7 * can fall back to their defaults.
8 *
9 * @module T3ProjectFileLoader
10 */
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18
19import { T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts";
20import { T3ProjectFileFromJson } from "@t3tools/shared/t3ProjectFile";
21
22const decodeT3ProjectFileJson = Schema.decodeEffect(T3ProjectFileFromJson);
23
24export class T3ProjectFileLoadError extends Schema.TaggedError<T3ProjectFileLoadError>()(
25 "T3ProjectFileLoadError",
26 {
27 operation: Schema.Literals(["read", "decode"]),
28 workspaceRoot: Schema.String,
29 filePath: Schema.String,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Failed to ${this.operation} ${T3_PROJECT_FILE_NAME} at ${this.filePath}.`;
35 }
36}
37
38/** Service tag for t3.json project file loading. */
39export class T3ProjectFileLoader extends Context.Service<
40 T3ProjectFileLoader,
41 {
42 /**
43 * Load and decode `t3.json` at the workspace root.
44 *
45 * Never fails: missing, unreadable, or invalid files resolve to
46 * `Option.none` (invalid files are logged as warnings).
47 */
48 readonly load: (workspaceRoot: string) => Effect.Effect<Option.Option<T3ProjectFile>>;
49 }
50>()("t3/project/T3ProjectFileLoader") {}
51
52const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) =>
53 Effect.logWarning(error).pipe(
54 Effect.annotateLogs({
55 operation: error.operation,
56 workspaceRoot: error.workspaceRoot,
57 filePath: error.filePath,
58 errorTag: error._tag,
59 }),
60 );
61
62/** @public Service construction is part of the canonical Effect module API. */
63export const make = Effect.gen(function* () {
64 const fileSystem = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66
67 const load: T3ProjectFileLoader["Service"]["load"] = Effect.fn("T3ProjectFileLoader.load")(
68 function* (workspaceRoot) {
69 const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME);
70 const raw = yield* fileSystem.readFileString(filePath).pipe(
71 Effect.asSome,
72 Effect.catchTags({
73 PlatformError: (error) =>
74 error.reason._tag === "NotFound"
75 ? Effect.succeed(Option.none<string>())
76 : logT3ProjectFileLoadError(
77 new T3ProjectFileLoadError({
78 operation: "read",
79 workspaceRoot,
80 filePath,
81 cause: error,
82 }),
83 ).pipe(Effect.as(Option.none<string>())),
84 }),
85 );
86 if (Option.isNone(raw)) {
87 return Option.none<T3ProjectFile>();
88 }
89 return yield* decodeT3ProjectFileJson(raw.value).pipe(
90 Effect.asSome,
91 Effect.catchTags({
92 SchemaError: (error) =>
93 logT3ProjectFileLoadError(
94 new T3ProjectFileLoadError({
95 operation: "decode",
96 workspaceRoot,
97 filePath,
98 cause: error,
99 }),
100 ).pipe(Effect.as(Option.none<T3ProjectFile>())),
101 }),
102 );
103 },
104 );
105
106 return T3ProjectFileLoader.of({ load });
107});
108
109export const layer = Layer.effect(T3ProjectFileLoader, make);