PranitInitial commit439f83c3 months agoHistory
937 lines · 34.9 KB · Zig
RawDownload
//! `fx replay <tape>` implementation.//!//! This module keeps tape parsing and virtual-terminal replay isolated from//! the top-level CLI dispatch. const std = @import("std");const debug_trace = @import("../shared/debug_trace.zig");const io_mod = @import("../shared/io.zig");const output_contracts = @import("../output/output_contracts.zig");const record_tape = @import("../workspace/record_tape.zig");const vt_emulator = @import("../terminal/engine.zig"); const Allocator = std.mem.Allocator; pub const Options = struct {    path: []const u8 = "",    frames: bool = false,    json: bool = false,    golden_path: ?[]const u8 = null,    frames_dir: ?[]const u8 = null,}; pub const Error = error{    MissingTapePath,    TooManyArgs,    UnknownFlag,    MissingGoldenPath,    MissingFramesDirPath,} || std.mem.Allocator.Error; pub fn parseArgs(args: []const [:0]const u8) Error!Options {    var opts: Options = .{};    var positional_seen = false;    var i: usize = 0;    while (i < args.len) : (i += 1) {        const arg = args[i];        if (std.mem.eql(u8, arg, "--frames")) {            opts.frames = true;            continue;        }        if (std.mem.eql(u8, arg, "--json")) {            opts.json = true;            continue;        }        if (std.mem.eql(u8, arg, "--golden")) {            i += 1;            if (i >= args.len) return Error.MissingGoldenPath;            opts.golden_path = args[i];            continue;        }        if (std.mem.eql(u8, arg, "--frames-dir")) {            i += 1;            if (i >= args.len) return Error.MissingFramesDirPath;            opts.frames_dir = args[i];            continue;        }        if (std.mem.startsWith(u8, arg, "--")) return Error.UnknownFlag;        if (positional_seen) return Error.TooManyArgs;        opts.path = arg;        positional_seen = true;    }    if (!positional_seen) return Error.MissingTapePath;    return opts;} pub fn run(alloc: std.mem.Allocator, args: []const [:0]const u8) !u8 {    var output: ProcessOutput = .{};    return runWithOutput(alloc, args, &output);} fn runWithOutput(alloc: Allocator, args: []const [:0]const u8, output: anytype) !u8 {    const opts = parseArgs(args) catch |err| {        return replyParseError(alloc, output, err, argsContainJson(args));    };     debug_trace.configureFromEnv(alloc, ".");    defer debug_trace.shutdown();    debug_trace.logf("render", "replay_start json={s} frames={s} golden={s} frames_dir={s}", .{        if (opts.json) "true" else "false",        if (opts.frames) "true" else "false",        if (opts.golden_path != null) "true" else "false",        if (opts.frames_dir != null) "true" else "false",    });     const file = std.Io.Dir.cwd().openFile(io_mod.getIo(), opts.path, .{}) catch |err| {        return replyFormattedError(alloc, output, opts.json, @errorName(err), 512, "fx replay: cannot open {s}: {s}\n", .{ opts.path, @errorName(err) }, "fx replay: open failed\n");    };    var file_mut = file;    defer file_mut.close(io_mod.getIo());     const bytes = io_mod.readFileToEnd(alloc, &file_mut, 64 * 1024 * 1024) catch |err| {        return replyFormattedError(alloc, output, opts.json, @errorName(err), 256, "fx replay: read failed: {s}\n", .{@errorName(err)}, "fx replay: read failed\n");    };    defer alloc.free(bytes);     var parser = record_tape.Parser.init(bytes) catch |err| {        return replyFormattedError(alloc, output, opts.json, @errorName(err), 256, "fx replay: bad tape: {s}\n", .{@errorName(err)}, "fx replay: bad tape\n");    };    debug_trace.logf("render", "replay_header cols={d} rows={d} version_bytes={d}", .{        parser.header.cols,        parser.header.rows,        parser.header.version.len,    });     var summary: std.Io.Writer.Allocating = .init(alloc);    defer summary.deinit();     var grid = try vt_emulator.Grid.init(alloc, parser.header.cols, parser.header.rows);    defer grid.deinit();     if (opts.json) {        try summary.writer.print(            "{{\"cols\":{d},\"rows\":{d},\"epoch_ms\":{d},\"version\":",            .{ parser.header.cols, parser.header.rows, parser.header.epoch_ms },        );        try std.json.Stringify.value(parser.header.version, .{}, &summary.writer);        try summary.writer.writeAll(",\"frames\":[");    }     var frame_count: usize = 0;    var resize_count: usize = 0;    var stdout_bytes: usize = 0;    var first_json_frame = true;    var elapsed_ms: i64 = 0;    var markers: std.ArrayList([]const u8) = .empty;    defer markers.deinit(alloc);    var ignored_incomplete_tail = false;     if (opts.frames_dir) |dir| {        prepareFramesDir(alloc, dir) catch |err| {            return replyFormattedError(alloc, output, opts.json, @errorName(err), 512, "fx replay: cannot prepare frames dir {s}: {s}\n", .{ dir, @errorName(err) }, "fx replay: cannot prepare frames dir\n");        };    }     while (true) {        const maybe_frame = parser.next() catch |err| switch (err) {            error.TruncatedFrameHeader, error.TruncatedFramePayload => {                ignored_incomplete_tail = true;                break;            },        };        const frame = maybe_frame orelse break;        frame_count += 1;        elapsed_ms += frame.delta_ms;         switch (frame.kind) {            .stdout => {                stdout_bytes += frame.payload.len;                try grid.feed(frame.payload);            },            .resize => {                if (frame.payload.len >= 4) {                    const cols = std.mem.readInt(u16, frame.payload[0..2], .little);                    const rows = std.mem.readInt(u16, frame.payload[2..4], .little);                    try grid.resize(cols, rows);                    resize_count += 1;                }            },            .marker => try markers.append(alloc, frame.payload),            .stdin, .sigint => {},            _ => {},        }         if (opts.json) {            if (!first_json_frame) try summary.writer.writeAll(",");            first_json_frame = false;            try summary.writer.print("{{\"delta_ms\":{d},\"kind\":", .{frame.delta_ms});            try std.json.Stringify.value(frameKindName(frame.kind), .{}, &summary.writer);            try summary.writer.print(",\"len\":{d}}}", .{frame.payload.len});        }         if (opts.frames and frame.kind != .marker) {            const header_text = try std.fmt.allocPrint(alloc, "\n--- frame {d} ({s}, +{d}ms) ---\n", .{ frame_count, frameKindName(frame.kind), frame.delta_ms });            defer alloc.free(header_text);            try output.writeStdout(header_text);            var snapshot: std.ArrayList(u8) = .empty;            defer snapshot.deinit(alloc);            try grid.snapshot(&snapshot);            try output.writeStdout(snapshot.items);        }         if (opts.frames_dir) |dir| {            writeFrameArtifacts(alloc, dir, frame_count, frame, elapsed_ms, grid, markers.items) catch |err| {                return replyFormattedError(alloc, output, opts.json, @errorName(err), 512, "fx replay: cannot write frame artifacts to {s}: {s}\n", .{ dir, @errorName(err) }, "fx replay: cannot write frame artifacts\n");            };        }    }    debug_trace.logf("frame_diff", "replay_complete frames={d} resizes={d} stdout_bytes={d}", .{        frame_count,        resize_count,        stdout_bytes,    });     if (opts.json) {        try summary.writer.print(            "],\"frame_count\":{d},\"resize_count\":{d},\"stdout_bytes\":{d}}}\n",            .{ frame_count, resize_count, stdout_bytes },        );        try output.writeStdout(summary.written());    }    if (ignored_incomplete_tail) {        try output.writeStderr("fx replay: ignored incomplete final tape frame\n");    }     if (opts.frames_dir) |dir| {        writeFramesManifest(alloc, dir, parser.header, frame_count, resize_count, stdout_bytes) catch |err| {            return replyFormattedError(alloc, output, opts.json, @errorName(err), 512, "fx replay: cannot write frames manifest to {s}: {s}\n", .{ dir, @errorName(err) }, "fx replay: cannot write frames manifest\n");        };    }     var final: std.ArrayList(u8) = .empty;    defer final.deinit(alloc);    try grid.snapshot(&final);     if (opts.golden_path) |out_path| {        var out_file = std.Io.Dir.cwd().createFile(io_mod.getIo(), out_path, .{ .truncate = true }) catch |err| {            return replyFormattedError(alloc, output, opts.json, @errorName(err), 256, "fx replay: cannot write {s}: {s}\n", .{ out_path, @errorName(err) }, "fx replay: write failed\n");        };        defer out_file.close(io_mod.getIo());        try out_file.writeStreamingAll(io_mod.getIo(), final.items);        return 0;    }     if (!opts.frames and !opts.json) {        try output.writeStdout(final.items);    }    return 0;} fn frameKindName(kind: record_tape.Kind) []const u8 {    const raw = @intFromEnum(kind);    inline for (std.meta.fields(record_tape.Kind)) |field| {        if (raw == field.value) return field.name;    }    return "unknown";} fn prepareFramesDir(alloc: Allocator, root: []const u8) !void {    try io_mod.makeDirRecursive(root);    const frames_path = try std.fs.path.join(alloc, &.{ root, "frames" });    defer alloc.free(frames_path);    try io_mod.makeDirRecursive(frames_path);} fn writeFrameArtifacts(    alloc: Allocator,    root: []const u8,    index: usize,    frame: record_tape.ReplayFrame,    elapsed_ms: i64,    grid: vt_emulator.Grid,    markers: []const []const u8,) !void {    var snapshot: std.ArrayList(u8) = .empty;    defer snapshot.deinit(alloc);    try grid.snapshot(&snapshot);     const stem = try std.fmt.allocPrint(alloc, "{d:0>4}", .{index});    defer alloc.free(stem);    const json_name = try std.fmt.allocPrint(alloc, "{s}.json", .{stem});    defer alloc.free(json_name);    const grid_name = try std.fmt.allocPrint(alloc, "{s}.grid.txt", .{stem});    defer alloc.free(grid_name);    const json_path = try std.fs.path.join(alloc, &.{ root, "frames", json_name });    defer alloc.free(json_path);    const grid_path = try std.fs.path.join(alloc, &.{ root, "frames", grid_name });    defer alloc.free(grid_path);     var out: std.Io.Writer.Allocating = .init(alloc);    defer out.deinit();    try out.writer.print(        "{{\"index\":{d},\"delta_ms\":{d},\"elapsed_ms\":{d},\"kind\":",        .{ index, frame.delta_ms, elapsed_ms },    );    try std.json.Stringify.value(frameKindName(frame.kind), .{}, &out.writer);    try out.writer.print(        ",\"payload_len\":{d},\"size\":{{\"cols\":{d},\"rows\":{d}}},\"cursor\":{{\"row\":{d},\"col\":{d},\"visible\":{s}}},\"footer_candidates\":",        .{            frame.payload.len,            grid.cols,            grid.rows,            grid.cursor_row,            grid.cursor_col,            if (grid.cursor_visible) "true" else "false",        },    );    try writeFooterCandidates(&out.writer, alloc, snapshot.items);    try out.writer.writeAll(",\"visible_markers\":");    try writeVisibleMarkers(&out.writer, snapshot.items, markers);    try out.writer.writeAll("}\n");     try writeFile(json_path, out.written());    try writeFile(grid_path, snapshot.items);} fn writeFramesManifest(    alloc: Allocator,    root: []const u8,    header: record_tape.ReplayHeader,    frame_count: usize,    resize_count: usize,    stdout_bytes: usize,) !void {    var out: std.Io.Writer.Allocating = .init(alloc);    defer out.deinit();    try out.writer.print(        "{{\"cols\":{d},\"rows\":{d},\"epoch_ms\":{d},\"version\":",        .{ header.cols, header.rows, header.epoch_ms },    );    try std.json.Stringify.value(header.version, .{}, &out.writer);    try out.writer.print(        ",\"frame_count\":{d},\"resize_count\":{d},\"stdout_bytes\":{d},\"frames_dir\":\"frames\"}}\n",        .{ frame_count, resize_count, stdout_bytes },    );     const manifest_path = try std.fs.path.join(alloc, &.{ root, "manifest.json" });    defer alloc.free(manifest_path);    try writeFile(manifest_path, out.written());} fn writeFooterCandidates(writer: anytype, alloc: Allocator, snapshot: []const u8) !void {    var lines: std.ArrayList([]const u8) = .empty;    defer lines.deinit(alloc);     var it = std.mem.splitScalar(u8, snapshot, '\n');    while (it.next()) |line| {        if (line.len == 0) continue;        try lines.append(alloc, line);    }     try writer.writeByte('[');    var first = true;    for (lines.items, 0..) |line, i| {        if (!isInputSnapshotRow(line)) continue;        if (i == 0 or i + 1 >= lines.items.len) continue;        if (!isDividerSnapshotRow(lines.items[i - 1]) or !isDividerSnapshotRow(lines.items[i + 1])) continue;        if (!first) try writer.writeByte(',');        first = false;        try writer.print(            "{{\"top_divider\":{d},\"input\":{d},\"bottom_divider\":{d}}}",            .{ i, i + 1, i + 2 },        );    }    try writer.writeByte(']');} fn writeVisibleMarkers(writer: anytype, snapshot: []const u8, markers: []const []const u8) !void {    try writer.writeByte('[');    var first = true;    for (markers) |marker| {        if (marker.len == 0) continue;        if (std.mem.find(u8, snapshot, marker) == null) continue;        if (!first) try writer.writeByte(',');        first = false;        try std.json.Stringify.value(marker, .{}, writer);    }    try writer.writeByte(']');} fn isInputSnapshotRow(line: []const u8) bool {    const text = trimSnapshotFrame(line);    return std.mem.startsWith(u8, text, "❯") or        std.mem.startsWith(u8, text, ">") or        (std.mem.startsWith(u8, text, "[") and std.mem.find(u8, text, "] ❯") != null) or        (std.mem.startsWith(u8, text, "[") and std.mem.find(u8, text, "] >") != null);} fn isDividerSnapshotRow(line: []const u8) bool {    const text = trimSnapshotFrame(line);    return std.mem.find(u8, text, "──") != null or        std.mem.find(u8, text, "━━") != null or        std.mem.find(u8, text, "══") != null;} fn trimSnapshotFrame(line: []const u8) []const u8 {    if (line.len >= 2 and line[0] == '|' and line[line.len - 1] == '|') {        return std.mem.trimEnd(u8, line[1 .. line.len - 1], " ");    }    return std.mem.trimEnd(u8, line, " ");} fn writeFile(path: []const u8, bytes: []const u8) !void {    const zio = io_mod.getIo();    var file = if (std.fs.path.isAbsolute(path))        try std.Io.Dir.createFileAbsolute(zio, path, .{ .truncate = true })    else        try std.Io.Dir.cwd().createFile(zio, path, .{ .truncate = true });    defer file.close(zio);    try file.writeStreamingAll(zio, bytes);} fn replyFormattedError(    alloc: Allocator,    output: anytype,    json: bool,    code: []const u8,    comptime buffer_len: usize,    comptime fmt: []const u8,    args: anytype,    fallback: []const u8,) !u8 {    var buf: [buffer_len]u8 = undefined;    const msg = std.fmt.bufPrint(&buf, fmt, args) catch fallback;    return replyError(alloc, output, json, code, msg);} fn replyError(    alloc: Allocator,    output: anytype,    json: bool,    code: []const u8,    message: []const u8,) !u8 {    if (!json) {        output.writeStderr(message) catch {};        return 1;    }    const rendered = try (output_contracts.CommandFailureSnapshot{        .kind = "replay",        .message = std.mem.trimEnd(u8, message, "\r\n"),        .code = code,    }).renderJson(alloc);    defer alloc.free(rendered);    output.writeStdout(rendered) catch {};    output.writeStdout("\n") catch {};    return 1;} fn replyParseError(    alloc: Allocator,    output: anytype,    err: Error,    json: bool,) !u8 {    const msg = switch (err) {        Error.MissingTapePath => "fx replay: missing tape path\nusage: fx replay <tape> [--frames] [--json] [--golden <path>] [--frames-dir <path>]\n",        Error.TooManyArgs => "fx replay: too many positional arguments\n",        Error.UnknownFlag => "fx replay: unknown flag\n",        Error.MissingGoldenPath => "fx replay: --golden requires a path\n",        Error.MissingFramesDirPath => "fx replay: --frames-dir requires a path\n",        else => "fx replay: argument error\n",    };    return replyError(alloc, output, json, @errorName(err), msg);} fn argsContainJson(args: []const [:0]const u8) bool {    for (args) |arg| {        if (std.mem.eql(u8, arg, "--json")) return true;    }    return false;} const ProcessOutput = struct {    fn writeStdout(_: *@This(), text: []const u8) !void {        try std.Io.File.stdout().writeStreamingAll(io_mod.getIo(), text);    }     fn writeStderr(_: *@This(), text: []const u8) !void {        try std.Io.File.stderr().writeStreamingAll(io_mod.getIo(), text);    }}; const CaptureOutput = struct {    stdout: std.Io.Writer.Allocating,    stderr: std.Io.Writer.Allocating,     fn init(alloc: Allocator) CaptureOutput {        return .{            .stdout = .init(alloc),            .stderr = .init(alloc),        };    }     fn deinit(self: *@This()) void {        self.stdout.deinit();        self.stderr.deinit();    }     fn writeStdout(self: *@This(), text: []const u8) !void {        try self.stdout.writer.writeAll(text);    }     fn writeStderr(self: *@This(), text: []const u8) !void {        try self.stderr.writer.writeAll(text);    }}; fn buildTape(alloc: Allocator, cols: u16, rows: u16, version: []const u8, frames: []const TestFrame) ![]u8 {    var out: std.Io.Writer.Allocating = .init(alloc);    errdefer out.deinit();     try out.writer.writeAll(record_tape.magic);    var header: [2 + 2 + 8 + 1]u8 = undefined;    std.mem.writeInt(u16, header[0..2], cols, .little);    std.mem.writeInt(u16, header[2..4], rows, .little);    std.mem.writeInt(i64, header[4..12], 123456789, .little);    header[12] = @intCast(@min(version.len, @as(usize, 255)));    try out.writer.writeAll(&header);    try out.writer.writeAll(version[0..@min(version.len, 255)]); 

Lines 1–500 of 937.