| 1 | import * as Effect from "effect/Effect" |
| 2 | import * as FileSystem from "effect/FileSystem" |
| 3 | import * as Path from "effect/Path" |
| 4 | import * as Yaml from "yaml" |
| 5 | import { ApiDiffError, isApiDiffError } from "./Error.ts" |
| 6 | |
| 7 | export interface MigrationAnnotation { |
| 8 | readonly replacement: string |
| 9 | readonly note: string |
| 10 | readonly example?: string | undefined |
| 11 | readonly include?: boolean | undefined |
| 12 | } |
| 13 | |
| 14 | const compareStrings = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0 |
| 15 | |
| 16 | const 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 | |
| 34 | const 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 | |
| 68 | export 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 | ) |