1import * as Effect from "effect/Effect"
2import * as FileSystem from "effect/FileSystem"
3import * as Path from "effect/Path"
4import * as Yaml from "yaml"
5import { ApiDiffError, isApiDiffError } from "./Error.ts"
6
7export interface MigrationAnnotation {
8 readonly replacement: string
9 readonly note: string
10 readonly example?: string | undefined
11 readonly include?: boolean | undefined
12}
13
14const compareStrings = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0
15
16const parseAnnotation = (id: string, value: unknown, file: string): MigrationAnnotation => {
17 if (
18 typeof value !== "object" || value === null ||
19 typeof Reflect.get(value, "replacement") !== "string" ||
20 typeof Reflect.get(value, "note") !== "string" ||
21 (Reflect.get(value, "example") !== undefined && typeof Reflect.get(value, "example") !== "string") ||
22 (Reflect.get(value, "include") !== undefined && typeof Reflect.get(value, "include") !== "boolean")
23 ) {
24 throw new Error(`Invalid annotation for ${id} in ${file}`)
25 }
26 return {
27 replacement: Reflect.get(value, "replacement"),
28 note: Reflect.get(value, "note"),
29 ...(Reflect.get(value, "example") === undefined ? {} : { example: Reflect.get(value, "example") }),
30 ...(Reflect.get(value, "include") === undefined ? {} : { include: Reflect.get(value, "include") })
31 }
32}
33
34const loadAnnotationsInternal = Effect.fnUntraced(function*(directory: string) {
35 const fs = yield* FileSystem.FileSystem
36 const path = yield* Path.Path
37 if (!(yield* fs.exists(directory))) {
38 return new Map<string, MigrationAnnotation>()
39 }
40
41 const annotations = new Map<string, MigrationAnnotation>()
42 const files = (yield* fs.readDirectory(directory))
43 .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
44 .sort(compareStrings)
45 for (const file of files) {
46 const source = yield* fs.readFileString(path.join(directory, file))
47 const document = yield* Effect.try({
48 try: () => Yaml.parse(source) as unknown,
49 catch: (cause) => new ApiDiffError({ message: `Could not parse annotation file ${file}`, cause })
50 })
51 if (typeof document !== "object" || document === null || Array.isArray(document)) {
52 return yield* new ApiDiffError({ message: `Annotation file ${file} must contain an object` })
53 }
54 for (const [id, value] of Object.entries(document).sort(([left], [right]) => compareStrings(left, right))) {
55 if (annotations.has(id)) {
56 return yield* new ApiDiffError({ message: `Duplicate annotation id ${id}` })
57 }
58 const annotation = yield* Effect.try({
59 try: () => parseAnnotation(id, value, file),
60 catch: (cause) => new ApiDiffError({ message: String(cause), cause })
61 })
62 annotations.set(id, annotation)
63 }
64 }
65 return annotations
66})
67
68export const loadAnnotations = (directory: string) =>
69 loadAnnotationsInternal(directory).pipe(
70 Effect.mapError((cause) =>
71 isApiDiffError(cause)
72 ? cause
73 : new ApiDiffError({ message: `Could not load annotations from ${directory}`, cause })
74 )
75 )