1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
7import * as Path from "effect/Path";
8
9import * as VcsProjectConfig from "./VcsProjectConfig.ts";
10
11const layerTest = VcsProjectConfig.layer.pipe(
12 Layer.provide(NodeServices.layer),
13 Layer.provideMerge(NodeServices.layer),
14);
15
16describe("VcsProjectConfig", () => {
17 it.layer(layerTest)("uses an explicit requested VCS kind before config", (it) => {
18 it.effect("returns the requested kind", () =>
19 Effect.gen(function* () {
20 const config = yield* VcsProjectConfig.VcsProjectConfig;
21 const kind = yield* config.resolveKind({
22 cwd: "/repo",
23 requestedKind: "jj",
24 });
25
26 assert.equal(kind, "jj");
27 }),
28 );
29 });
30
31 it.layer(layerTest)("discovers .t3code/vcs.json from nested workspaces", (it) => {
32 it.effect("returns the configured kind", () =>
33 Effect.gen(function* () {
34 const fileSystem = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const root = yield* fileSystem.makeTempDirectoryScoped({
37 prefix: "t3-vcs-config-test-",
38 });
39 const configDir = path.join(root, ".t3code");
40 const nested = path.join(root, "packages", "app");
41 yield* fileSystem.makeDirectory(configDir, { recursive: true });
42 yield* fileSystem.makeDirectory(nested, { recursive: true });
43 yield* fileSystem.writeFileString(
44 path.join(configDir, "vcs.json"),
45 JSON.stringify({ vcs: { kind: "jj" } }),
46 );
47
48 const config = yield* VcsProjectConfig.VcsProjectConfig;
49 const kind = yield* config.resolveKind({ cwd: nested });
50
51 assert.equal(kind, "jj");
52 }),
53 );
54 });
55
56 it.layer(layerTest)("continues to parent configs after a candidate inspect failure", (it) => {
57 it.effect("logs the failed candidate and returns the parent config", () => {
58 const messages: unknown[] = [];
59 const logger = Logger.make<unknown, void>(({ message }) => {
60 messages.push(message);
61 });
62
63 return Effect.gen(function* () {
64 const fileSystem = yield* FileSystem.FileSystem;
65 const path = yield* Path.Path;
66 const root = yield* fileSystem.makeTempDirectoryScoped({
67 prefix: "t3-vcs-config-test-",
68 });
69 const configDir = path.join(root, ".t3code");
70 const cwd = path.join(root, "invalid\0child");
71 yield* fileSystem.makeDirectory(configDir, { recursive: true });
72 yield* fileSystem.writeFileString(
73 path.join(configDir, "vcs.json"),
74 JSON.stringify({ vcs: { kind: "jj" } }),
75 );
76
77 const config = yield* VcsProjectConfig.VcsProjectConfig;
78 const kind = yield* config.resolveKind({ cwd });
79
80 assert.equal(kind, "jj");
81 const failedCandidate = path.join(cwd, ".t3code", "vcs.json");
82 const [error] = messages[0] as ReadonlyArray<unknown>;
83 assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError);
84 assert.equal(
85 error.message,
86 "Failed to inspect VCS project config at " + failedCandidate + ".",
87 );
88 assert.deepInclude(error, {
89 operation: "inspect",
90 cwd,
91 configPath: failedCandidate,
92 _tag: "VcsProjectConfigError",
93 });
94 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
95 });
96 });
97
98 it.layer(layerTest)("falls back to auto when no config exists", (it) => {
99 it.effect("returns auto", () =>
100 Effect.gen(function* () {
101 const fileSystem = yield* FileSystem.FileSystem;
102 const root = yield* fileSystem.makeTempDirectoryScoped({
103 prefix: "t3-vcs-config-test-",
104 });
105 const config = yield* VcsProjectConfig.VcsProjectConfig;
106 const kind = yield* config.resolveKind({ cwd: root });
107
108 assert.equal(kind, "auto");
109 }),
110 );
111 });
112
113 it.layer(layerTest)("falls back to auto when config JSON is malformed", (it) => {
114 it.effect("returns auto and logs the failed operation and path", () => {
115 const messages: unknown[] = [];
116 const logger = Logger.make<unknown, void>(({ message }) => {
117 messages.push(message);
118 });
119
120 return Effect.gen(function* () {
121 const fileSystem = yield* FileSystem.FileSystem;
122 const path = yield* Path.Path;
123 const root = yield* fileSystem.makeTempDirectoryScoped({
124 prefix: "t3-vcs-config-test-",
125 });
126 const configDir = path.join(root, ".t3code");
127 yield* fileSystem.makeDirectory(configDir, { recursive: true });
128 yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json");
129
130 const config = yield* VcsProjectConfig.VcsProjectConfig;
131 const kind = yield* config.resolveKind({ cwd: root });
132
133 assert.equal(kind, "auto");
134 const [error] = messages[0] as ReadonlyArray<unknown>;
135 assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError);
136 assert.equal(
137 error.message,
138 "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".",
139 );
140 assert.deepInclude(error.cause, { _tag: "SchemaError" });
141 assert.deepInclude(error, {
142 operation: "decode",
143 cwd: root,
144 configPath: path.join(configDir, "vcs.json"),
145 _tag: "VcsProjectConfigError",
146 });
147 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
148 });
149 });
150
151 it.layer(layerTest)("falls back to auto when the config path cannot be read", (it) => {
152 it.effect("retains the read failure context", () => {
153 const messages: unknown[] = [];
154 const logger = Logger.make<unknown, void>(({ message }) => {
155 messages.push(message);
156 });
157
158 return Effect.gen(function* () {
159 const fileSystem = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const root = yield* fileSystem.makeTempDirectoryScoped({
162 prefix: "t3-vcs-config-test-",
163 });
164 const configPath = path.join(root, ".t3code", "vcs.json");
165 yield* fileSystem.makeDirectory(configPath, { recursive: true });
166
167 const config = yield* VcsProjectConfig.VcsProjectConfig;
168 const kind = yield* config.resolveKind({ cwd: root });
169
170 assert.equal(kind, "auto");
171 const [error] = messages[0] as ReadonlyArray<unknown>;
172 assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError);
173 assert.equal(error.message, "Failed to read VCS project config at " + configPath + ".");
174 assert.deepInclude(error.cause, { _tag: "PlatformError" });
175 assert.deepInclude(error, {
176 operation: "read",
177 cwd: root,
178 configPath,
179 _tag: "VcsProjectConfigError",
180 });
181 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
182 });
183 });
184
185 it.layer(layerTest)("falls back to auto when config kind is invalid", (it) => {
186 it.effect("returns auto", () =>
187 Effect.gen(function* () {
188 const fileSystem = yield* FileSystem.FileSystem;
189 const path = yield* Path.Path;
190 const root = yield* fileSystem.makeTempDirectoryScoped({
191 prefix: "t3-vcs-config-test-",
192 });
193 const configDir = path.join(root, ".t3code");
194 yield* fileSystem.makeDirectory(configDir, { recursive: true });
195 yield* fileSystem.writeFileString(
196 path.join(configDir, "vcs.json"),
197 `{"vcs":{"kind":"svn"}}`,
198 );
199
200 const config = yield* VcsProjectConfig.VcsProjectConfig;
201 const kind = yield* config.resolveKind({ cwd: root });
202
203 assert.equal(kind, "auto");
204 }),
205 );
206 });
207});