Skip to content

Commit 41c0639

Browse files
xesrevinuGit Agent
andcommitted
refactor(config): improve types and error handling
- Update imports to include .ts extensions for consistency - Refactor config schemas and decoding with stricter types - Unify error handling messages across config readers - Adjust tsconfig to use node types and minor formatting fixes the changes refactor the config layer for better type safety and error handling, including schema improvements and import normalization, with minor tsconfig updates. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent 6cfd6c3 commit 41c0639

5 files changed

Lines changed: 193 additions & 157 deletions

File tree

src/config/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,6 @@ export const buildEnvironment = Effect.gen(function* () {
4444
gitignoreBaseUrl,
4545
xdgConfigHome,
4646
};
47-
});
47+
}).pipe(Effect.orDie);
4848

4949
export const cwdEnvironment = envStringWithDefault("PWD", process.cwd());

src/config/keys.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ConfigError } from "../shared/errors";
1+
import { ConfigError } from "../shared/errors.ts";
22

33
export const ScopeUser = "user";
44
export const ScopeProject = "project";
@@ -8,9 +8,9 @@ export type ConfigScope = typeof ScopeUser | typeof ScopeProject | typeof ScopeL
88

99
interface KeyDef {
1010
readonly type: "string" | "bool" | "int" | "stringslice";
11-
readonly allowUser?: boolean;
12-
readonly allowProject?: boolean;
13-
readonly allowLocal?: boolean;
11+
readonly allowUser?: boolean | undefined;
12+
readonly allowProject?: boolean | undefined;
13+
readonly allowLocal?: boolean | undefined;
1414
}
1515

1616
const keyRegistry: Record<string, KeyDef> = {

src/config/project.ts

Lines changed: 104 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,79 @@
1-
import { Effect, FileSystem, Path, Schema } from "effect";
1+
import { Effect, FileSystem, Path, Schema, SchemaTransformation } from "effect";
22
import { parse, stringify } from "yaml";
3-
import type { ProjectConfig, ProjectScope } from "../domain/project";
4-
import { emptyProjectConfig } from "../domain/project";
5-
import { ConfigError } from "../shared/errors";
6-
import { getKeyDef } from "./keys";
3+
import type { ProjectConfig } from "../domain/project.ts";
4+
import { ProjectScope, emptyProjectConfig } from "../domain/project.ts";
5+
import { ConfigError } from "../shared/errors.ts";
6+
import { getKeyDef } from "./keys.ts";
77

8+
const TrimmedString = Schema.String.pipe(Schema.decode(SchemaTransformation.trim()));
9+
const NonEmptyTrimmedString = TrimmedString.check(Schema.isNonEmpty());
10+
const CompactTrimmedStringArray = Schema.Array(TrimmedString).pipe(
11+
Schema.decodeTo(
12+
Schema.Array(TrimmedString),
13+
SchemaTransformation.transform({
14+
decode: (items) => items.filter((item) => item.length > 0) as ReadonlyArray<string>,
15+
encode: (items) => items,
16+
}),
17+
),
18+
);
19+
const NonEmptyCompactTrimmedStringArray = CompactTrimmedStringArray.pipe(
20+
Schema.decodeTo(
21+
Schema.Array(NonEmptyTrimmedString).check(Schema.isNonEmpty()),
22+
SchemaTransformation.transform({
23+
decode: (items) => items,
24+
encode: (items) => items,
25+
}),
26+
),
27+
);
28+
29+
const RawYamlMapSchema = Schema.Record(Schema.String, Schema.Unknown);
830
type RawYamlMap = Record<string, unknown>;
9-
type RawScopeInput = string | { readonly name: string; readonly description?: string | undefined };
10-
type RawHookInput = string | ReadonlyArray<string>;
1131

12-
const RawScopeSchema = Schema.Union([
32+
const RawScopeInput = Schema.Union([
1333
Schema.String,
1434
Schema.Struct({
1535
name: Schema.String,
16-
description: Schema.optional(Schema.String),
36+
description: Schema.optionalKey(Schema.String),
1737
}),
1838
]);
19-
const RawHooksSchema = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
39+
const ScopeListField = Schema.Array(RawScopeInput).pipe(
40+
Schema.decodeTo(
41+
Schema.Array(ProjectScope),
42+
SchemaTransformation.transform({
43+
decode: (scopes) =>
44+
scopes.map((scope) =>
45+
typeof scope === "string"
46+
? { name: scope }
47+
: {
48+
name: scope.name,
49+
...(scope.description?.trim().length ? { description: scope.description } : {}),
50+
},
51+
) as ReadonlyArray<{ readonly name: string; readonly description?: string }>,
52+
encode: (scopes) =>
53+
scopes.map((scope) => ({
54+
name: scope.name,
55+
...(scope.description != null ? { description: scope.description } : {}),
56+
})) as ReadonlyArray<{ readonly name: string; readonly description?: string }>,
57+
}),
58+
),
59+
);
60+
61+
const RawHookInput = Schema.Union([Schema.String, Schema.Array(Schema.String)]);
62+
const HookListField = RawHookInput.pipe(
63+
Schema.decodeTo(
64+
NonEmptyCompactTrimmedStringArray,
65+
SchemaTransformation.transform({
66+
decode: (input) =>
67+
(typeof input === "string" ? input.split(",") : [...input]) as ReadonlyArray<string>,
68+
encode: (input) => input,
69+
}),
70+
),
71+
);
2072

21-
const configError = (pathValue: string, message: string) =>
73+
const configError = (pathValue: string, message: string, cause?: unknown | undefined) =>
2274
new ConfigError({
2375
message: `invalid config ${pathValue}: ${message}`,
76+
cause,
2477
});
2578

2679
const decodeConfigField = <S extends Schema.Top>(
@@ -32,107 +85,62 @@ const decodeConfigField = <S extends Schema.Top>(
3285
input === undefined
3386
? Effect.succeed(undefined)
3487
: Schema.decodeUnknownEffect(schema)(input).pipe(
35-
Effect.mapError((cause) => configError(pathValue, `${key}: ${cause.message}`)),
88+
Effect.mapError((cause) => configError(pathValue, key, cause)),
3689
);
3790

3891
const readYamlMap = Effect.fn(function* (pathValue: string) {
3992
const fs = yield* FileSystem.FileSystem;
4093
const exists = yield* fs.exists(pathValue);
4194
if (!exists) {
42-
return {};
95+
return {} as RawYamlMap;
4396
}
4497

4598
const text = yield* fs.readFileString(pathValue, "utf8").pipe(
4699
Effect.mapError(
47100
(cause) =>
48101
new ConfigError({
49-
message: `failed to read config ${pathValue}: ${cause.message}`,
102+
message: `failed to read config ${pathValue}`,
103+
cause,
50104
}),
51105
),
52106
);
53107

54108
return yield* Effect.try({
55-
try: () => {
56-
const parsed = parse(text);
57-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
58-
throw configError(pathValue, "expected a YAML mapping");
59-
}
60-
return parsed as RawYamlMap;
61-
},
109+
try: () => parse(text),
62110
catch: (cause) =>
63-
ConfigError.is(cause)
64-
? cause
65-
: new ConfigError({
66-
message: `failed to read config ${pathValue}: ${cause instanceof Error ? cause.message : String(cause)}`,
67-
}),
68-
});
111+
new ConfigError({
112+
message: `failed to read config ${pathValue}`,
113+
cause,
114+
}),
115+
}).pipe(
116+
Effect.flatMap((parsed) => Schema.decodeUnknownEffect(RawYamlMapSchema)(parsed)),
117+
Effect.map((parsed) => ({ ...parsed })),
118+
Effect.mapError((cause) => configError(pathValue, "expected a YAML mapping", cause)),
119+
);
69120
});
70121

71-
const normalizeScope = (
72-
pathValue: string,
73-
input: RawScopeInput,
74-
index: number,
75-
): Effect.Effect<ProjectScope, ConfigError> => {
76-
if (typeof input === "string") {
77-
const name = input.trim();
78-
return name.length > 0
79-
? Effect.succeed({ name })
80-
: Effect.failSync(() => configError(pathValue, `scopes[${index}] must not be empty`));
81-
}
82-
83-
const name = input.name.trim();
84-
if (name.length === 0) {
85-
return Effect.failSync(() => configError(pathValue, `scopes[${index}].name must not be empty`));
86-
}
87-
88-
const description = input.description?.trim();
89-
return Effect.succeed({
90-
name,
91-
...(description != null && description.length > 0 ? { description } : {}),
92-
});
93-
};
94-
95122
const decodeScopes = Effect.fn(function* (pathValue: string, rawMap: RawYamlMap) {
96-
const scopes = yield* decodeConfigField(
97-
pathValue,
98-
"scopes",
99-
rawMap["scopes"],
100-
Schema.Array(RawScopeSchema),
101-
);
102-
if (scopes == null) {
103-
return [] as Array<ProjectScope>;
104-
}
105-
return yield* Effect.forEach(scopes, (scope, index) => normalizeScope(pathValue, scope, index));
123+
return ((yield* decodeConfigField(pathValue, "scopes", rawMap["scopes"], ScopeListField)) ??
124+
[]) as Array<ProjectScope>;
106125
});
107126

108-
const normalizeHookValues = (
109-
pathValue: string,
110-
key: "hook" | "hook_type",
111-
input: RawHookInput,
112-
): Effect.Effect<Array<string>, ConfigError> => {
113-
const normalized = (typeof input === "string" ? input.split(",") : [...input])
114-
.map((item) => item.trim())
115-
.filter((item) => item.length > 0);
116-
return normalized.length > 0
117-
? Effect.succeed(normalized)
118-
: Effect.failSync(() => configError(pathValue, `${key} must not be empty`));
119-
};
120-
121127
const decodeHooks = Effect.fn(function* (pathValue: string, rawMap: RawYamlMap) {
122-
const hooks = yield* decodeConfigField(pathValue, "hook", rawMap["hook"], RawHooksSchema);
128+
const hooks = yield* decodeConfigField(pathValue, "hook", rawMap["hook"], HookListField);
123129
if (hooks != null) {
124-
return yield* normalizeHookValues(pathValue, "hook", hooks);
130+
return hooks;
125131
}
126132

127133
const legacyHook = yield* decodeConfigField(
128134
pathValue,
129135
"hook_type",
130136
rawMap["hook_type"],
131-
Schema.String,
137+
HookListField,
132138
);
139+
133140
if (legacyHook != null) {
134-
return yield* normalizeHookValues(pathValue, "hook_type", legacyHook);
141+
return legacyHook;
135142
}
143+
136144
return [] as Array<string>;
137145
});
138146

@@ -158,7 +166,9 @@ export const projectConfigWritePath = (repoRoot: string) => gitAgentPath(repoRoo
158166

159167
export const localConfigPath = (repoRoot: string) => gitAgentPath(repoRoot, "config.local.yml");
160168

161-
export const loadProjectConfig = Effect.fn(function* (repoRoot: string) {
169+
export const loadProjectConfig = Effect.fn("Config.LoadProjectConfig")(function* (
170+
repoRoot: string,
171+
) {
162172
const projectPath = yield* projectConfigPath(repoRoot);
163173
const localPath = yield* localConfigPath(repoRoot);
164174
const projectRaw = yield* readYamlMap(projectPath);
@@ -235,7 +245,7 @@ export const loadProjectConfig = Effect.fn(function* (repoRoot: string) {
235245
} satisfies ProjectConfig;
236246
});
237247

238-
export const mergeAndSaveScopes = Effect.fn(function* (
248+
export const mergeScopes = Effect.fn("Config.MergeScopes")(function* (
239249
pathValue: string,
240250
nextScopes: ReadonlyArray<ProjectScope>,
241251
) {
@@ -268,19 +278,23 @@ export const mergeAndSaveScopes = Effect.fn(function* (
268278
}
269279

270280
rawMap["scopes"] = merged;
281+
271282
yield* fs.makeDirectory(path.dirname(pathValue), { recursive: true }).pipe(
272283
Effect.mapError(
273284
(cause) =>
274285
new ConfigError({
275-
message: `failed to save scopes to ${pathValue}: ${cause.message}`,
286+
message: `failed to save scopes to ${pathValue}`,
287+
cause,
276288
}),
277289
),
278290
);
291+
279292
yield* fs.writeFileString(pathValue, stringify(rawMap), { mode: 0o644 }).pipe(
280293
Effect.mapError(
281294
(cause) =>
282295
new ConfigError({
283-
message: `failed to save scopes to ${pathValue}: ${cause.message}`,
296+
message: `failed to save scopes to ${pathValue}`,
297+
cause,
284298
}),
285299
),
286300
);
@@ -307,14 +321,16 @@ export const readProjectField = (pathValue: string, key: string) =>
307321
return yamlValueToString(rawMap[key]);
308322
});
309323

310-
export const writeProjectField = Effect.fn(function* (
324+
export const writeProjectField = Effect.fn("Config.WriteProjectField")(function* (
311325
pathValue: string,
312326
key: string,
313327
value: string,
314328
) {
315329
const fs = yield* FileSystem.FileSystem;
316330
const path = yield* Path.Path;
331+
317332
const rawMap = yield* readYamlMap(pathValue);
333+
318334
const def = getKeyDef(key);
319335
if (def == null) {
320336
return yield* new ConfigError({ message: `unknown config key "${key}"` });
@@ -341,15 +357,18 @@ export const writeProjectField = Effect.fn(function* (
341357
Effect.mapError(
342358
(cause) =>
343359
new ConfigError({
344-
message: `failed to write config ${pathValue}: ${cause.message}`,
360+
message: `failed to write config ${pathValue}`,
361+
cause,
345362
}),
346363
),
347364
);
365+
348366
yield* fs.writeFileString(pathValue, stringify(rawMap), { mode: 0o644 }).pipe(
349367
Effect.mapError(
350368
(cause) =>
351369
new ConfigError({
352-
message: `failed to write config ${pathValue}: ${cause.message}`,
370+
message: `failed to write config ${pathValue}`,
371+
cause,
353372
}),
354373
),
355374
);

0 commit comments

Comments
 (0)