Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 111 additions & 4 deletions apps/server/src/vcs/VcsProjectConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";

import * as VcsProjectConfig from "./VcsProjectConfig.ts";
Expand All @@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe(
);

describe("VcsProjectConfig", () => {
it("keeps operation context and the original cause on config errors", () => {
const cause = new Error("permission denied");
const error = new VcsProjectConfig.VcsProjectConfigError({
operation: "read",
cwd: "/repo/packages/app",
configPath: "/repo/.t3code/vcs.json",
cause,
});

assert.equal(error.operation, "read");
assert.equal(error.cwd, "/repo/packages/app");
assert.equal(error.configPath, "/repo/.t3code/vcs.json");
assert.strictEqual(error.cause, cause);
assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json.");
});

it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => {
it.effect("returns the requested kind", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -53,6 +70,46 @@ describe("VcsProjectConfig", () => {
);
});

it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => {
it.effect("logs the failed candidate and returns the parent config", () => {
const messages: unknown[] = [];
const logger = Logger.make<unknown, void>(({ message }) => {
messages.push(message);
});

return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configDir = path.join(root, ".t3code");
const cwd = path.join(root, "invalid\0child");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.writeFileString(
path.join(configDir, "vcs.json"),
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({ vcs: { kind: "jj" } }),
);

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd });

assert.equal(kind, "jj");
const [message, context] = messages[0] as [string, Record<string, unknown>];
const failedCandidate = path.join(cwd, ".t3code", "vcs.json");
assert.equal(message, "Failed to inspect VCS project config at " + failedCandidate + ".");
assert.deepInclude(context, {
operation: "inspect",
cwd,
configPath: failedCandidate,
errorTag: "VcsProjectConfigError",
});
assert.equal("cause" in context, false);
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
});
});

it.layer(TestLayer)("falls back to auto when no config exists", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
Expand All @@ -69,8 +126,13 @@ describe("VcsProjectConfig", () => {
});

it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
it.effect("returns auto and logs the failed operation and path", () => {
const messages: unknown[] = [];
const logger = Logger.make<unknown, void>(({ message }) => {
messages.push(message);
});

return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
Expand All @@ -84,8 +146,53 @@ describe("VcsProjectConfig", () => {
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
}),
);
const [message, context] = messages[0] as [string, Record<string, unknown>];
assert.equal(
message,
"Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".",
);
assert.deepInclude(context, {
operation: "decode",
cwd: root,
configPath: path.join(configDir, "vcs.json"),
errorTag: "VcsProjectConfigError",
});
assert.equal("cause" in context, false);
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
});
});

it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => {
it.effect("retains the read failure context", () => {
const messages: unknown[] = [];
const logger = Logger.make<unknown, void>(({ message }) => {
messages.push(message);
});

return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configPath = path.join(root, ".t3code", "vcs.json");
yield* fileSystem.makeDirectory(configPath, { recursive: true });

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
const [message, context] = messages[0] as [string, Record<string, unknown>];
assert.equal(message, "Failed to read VCS project config at " + configPath + ".");
assert.deepInclude(context, {
operation: "read",
cwd: root,
configPath,
errorTag: "VcsProjectConfigError",
});
assert.equal("cause" in context, false);
}).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
});
});

it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => {
Expand Down
99 changes: 71 additions & 28 deletions apps/server/src/vcs/VcsProjectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const ProjectVcsConfig = Schema.Struct({
vcsKind: Schema.optional(VcsDriverKind),
});
const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig);
const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson);

type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;

Expand All @@ -27,6 +27,20 @@ export interface VcsProjectConfigResolveInput {
readonly requestedKind?: VcsDriverKindType | "auto";
}

export class VcsProjectConfigError extends Schema.TaggedErrorClass<VcsProjectConfigError>()(
"VcsProjectConfigError",
{
operation: Schema.Literals(["inspect", "read", "decode"]),
cwd: Schema.String,
configPath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to ${this.operation} VCS project config at ${this.configPath}.`;
}
}

export class VcsProjectConfig extends Context.Service<
VcsProjectConfig,
{
Expand All @@ -40,8 +54,14 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
return config.vcs?.kind ?? config.vcsKind ?? "auto";
}

const parseConfig = (raw: string): Option.Option<ProjectVcsConfigFile> =>
decodeProjectVcsConfigJson(raw);
const logVcsProjectConfigError = (error: VcsProjectConfigError) =>
Effect.logWarning(error.message, {
operation: error.operation,
cwd: error.cwd,
configPath: error.configPath,
errorTag: error._tag,
stack: error.stack,
});

export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -51,7 +71,21 @@ export const make = Effect.gen(function* () {
let current = cwd;
while (true) {
const candidate = path.join(current, ".t3code", "vcs.json");
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
const exists = yield* fileSystem.exists(candidate).pipe(
Effect.mapError(
(cause) =>
new VcsProjectConfigError({
operation: "inspect",
cwd,
configPath: candidate,
cause,
}),
),
Effect.catchTags({
VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)),
}),
);
if (exists) {
return Option.some(candidate);
}

Expand All @@ -64,30 +98,32 @@ export const make = Effect.gen(function* () {
});

const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* (
cwd: string,
configPath: string,
) {
const raw = yield* fileSystem.readFileString(configPath).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
Effect.logWarning("failed to read VCS project config", {
configPath,
error,
}).pipe(Effect.as(Option.none())),
Effect.mapError(
(cause) =>
new VcsProjectConfigError({
operation: "read",
cwd,
configPath,
cause,
}),
),
);
if (Option.isNone(raw)) {
return "auto" as const;
}

const parsed = parseConfig(raw.value);
if (Option.isNone(parsed)) {
yield* Effect.logWarning("invalid VCS project config", {
configPath,
Comment thread
cursor[bot] marked this conversation as resolved.
});
return "auto" as const;
}

return configuredKind(parsed.value);
const parsed = yield* decodeProjectVcsConfigJson(raw).pipe(
Effect.mapError(
(cause) =>
new VcsProjectConfigError({
operation: "decode",
cwd,
configPath,
cause,
}),
),
);
return configuredKind(parsed);
});

const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn(
Expand All @@ -97,11 +133,18 @@ export const make = Effect.gen(function* () {
return input.requestedKind;
}

const configPath = yield* findConfigPath(input.cwd);
return yield* Option.match(configPath, {
onNone: () => Effect.succeed("auto" as const),
onSome: readConfiguredKind,
});
return yield* findConfigPath(input.cwd).pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed("auto" as const),
onSome: (configPath) => readConfiguredKind(input.cwd, configPath),
}),
),
Effect.catchTags({
VcsProjectConfigError: (error) =>
logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)),
}),
);
});

return VcsProjectConfig.of({
Expand Down
Loading