1import {
2 ProviderReplayEntry,
3 ProviderReplayNdjsonRecord,
4 ProviderReplayTranscript,
5 type ProviderReplayTranscriptHeader,
6} from "@t3tools/contracts";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11
12export class ProviderReplayNdjsonLineParseError extends Schema.TaggedError<ProviderReplayNdjsonLineParseError>()(
13 "ProviderReplayNdjsonLineParseError",
14 {
15 lineNumber: Schema.Number,
16 line: Schema.String,
17 cause: Schema.optional(Schema.Defect()),
18 },
19) {
20 override get message(): string {
21 return `Failed to parse provider replay NDJSON line ${this.lineNumber}.`;
22 }
23}
24
25export class ProviderReplayNdjsonMissingHeaderError extends Schema.TaggedError<ProviderReplayNdjsonMissingHeaderError>()(
26 "ProviderReplayNdjsonMissingHeaderError",
27 {},
28) {
29 override get message(): string {
30 return "Provider replay NDJSON requires a transcript_start header or fallback transcript metadata.";
31 }
32}
33
34export class ProviderReplayNdjsonEmptyError extends Schema.TaggedError<ProviderReplayNdjsonEmptyError>()(
35 "ProviderReplayNdjsonEmptyError",
36 {},
37) {
38 override get message(): string {
39 return "Provider replay NDJSON did not contain any records.";
40 }
41}
42
43export const ProviderReplayNdjsonParseError = Schema.Union([
44 ProviderReplayNdjsonLineParseError,
45 ProviderReplayNdjsonMissingHeaderError,
46 ProviderReplayNdjsonEmptyError,
47]);
48export type ProviderReplayNdjsonParseError = typeof ProviderReplayNdjsonParseError.Type;
49
50export type ProviderReplayTranscriptMetadata = Omit<ProviderReplayTranscript, "entries">;
51
52const REPLAY_TRANSCRIPT_WORKSPACE_PLACEHOLDER = "<workspace>";
53
54function materializeWorkspacePlaceholder(value: unknown, workspace: string): unknown {
55 if (value === REPLAY_TRANSCRIPT_WORKSPACE_PLACEHOLDER) {
56 return workspace;
57 }
58 if (Array.isArray(value)) {
59 return value.map((entry) => materializeWorkspacePlaceholder(entry, workspace));
60 }
61 if (typeof value !== "object" || value === null) {
62 return value;
63 }
64 return Object.fromEntries(
65 Object.entries(value).map(([key, entry]) => [
66 key,
67 materializeWorkspacePlaceholder(entry, workspace),
68 ]),
69 );
70}
71
72/**
73 * Resolves portable fixture placeholders before a replay driver sees the transcript.
74 * The resulting outbound frames still use exact structural equality during replay.
75 */
76export function materializeReplayTranscriptWorkspace(
77 transcript: ProviderReplayTranscript,
78 workspace: string,
79): ProviderReplayTranscript {
80 return {
81 ...transcript,
82 entries: transcript.entries.map((entry) =>
83 entry.type === "expect_outbound"
84 ? {
85 ...entry,
86 frame: materializeWorkspacePlaceholder(entry.frame, workspace),
87 }
88 : entry,
89 ),
90 };
91}
92
93const decodeReplayRecord = Schema.decodeUnknownSync(
94 Schema.fromJsonString(ProviderReplayNdjsonRecord),
95);
96const decodeTranscript = Schema.decodeUnknownSync(ProviderReplayTranscript);
97
98function parseReplayRecord(
99 line: string,
100 lineNumber: number,
101): Effect.Effect<ProviderReplayNdjsonRecord, ProviderReplayNdjsonLineParseError> {
102 return Effect.try({
103 try: () => decodeReplayRecord(line),
104 catch: (cause) =>
105 new ProviderReplayNdjsonLineParseError({
106 lineNumber,
107 line,
108 cause,
109 }),
110 });
111}
112
113function metadataFromHeader(
114 header: ProviderReplayTranscriptHeader,
115): ProviderReplayTranscriptMetadata {
116 const { type: _type, ...metadata } = header;
117 return metadata;
118}
119
120export function decodeProviderReplayNdjson(
121 input: string,
122 fallbackMetadata?: ProviderReplayTranscriptMetadata,
123): Effect.Effect<ProviderReplayTranscript, ProviderReplayNdjsonParseError> {
124 return Effect.gen(function* () {
125 const lines = input
126 .split(/\r?\n/u)
127 .map((line, index) => ({ line: line.trim(), lineNumber: index + 1 }))
128 .filter(({ line }) => line.length > 0);
129
130 if (lines.length === 0) {
131 return yield* new ProviderReplayNdjsonEmptyError();
132 }
133
134 const firstRecord = yield* parseReplayRecord(lines[0]!.line, lines[0]!.lineNumber);
135 const metadata =
136 firstRecord.type === "transcript_start" ? metadataFromHeader(firstRecord) : fallbackMetadata;
137
138 if (!metadata) {
139 return yield* new ProviderReplayNdjsonMissingHeaderError();
140 }
141
142 const entries: Array<ProviderReplayEntry> = [];
143 if (firstRecord.type !== "transcript_start") {
144 entries.push(firstRecord);
145 }
146
147 for (const { line, lineNumber } of lines.slice(1)) {
148 const record = yield* parseReplayRecord(line, lineNumber);
149 if (record.type === "transcript_start") {
150 return yield* new ProviderReplayNdjsonLineParseError({
151 lineNumber,
152 line,
153 cause: "transcript_start is only valid as the first replay record",
154 });
155 }
156 entries.push(record);
157 }
158
159 return decodeTranscript({
160 ...metadata,
161 entries,
162 });
163 });
164}
165
166/**
167 * Reads a provider replay transcript from a `file:` URL and decodes it.
168 * Conversion goes through the `Path` service so drive-letter and UNC fixture
169 * URLs resolve to native paths on Windows instead of `/C:/...` pathname strings.
170 */
171export const readProviderReplayTranscript = Effect.fn("readProviderReplayTranscript")(function* (
172 file: URL,
173) {
174 const fs = yield* FileSystem.FileSystem;
175 const path = yield* Path.Path;
176 const text = yield* fs.readFileString(yield* path.fromFileUrl(file));
177 return yield* decodeProviderReplayNdjson(text);
178});