const std = @import("std"); const builtin = @import("builtin"); // scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope // wholesale (ghostty and uucode log there too), and a corrupt dump's parse // diagnostic is the only thing that says WHICH field went bad. const log = std.log.scoped(.dump); /// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-.zon — timestamped so dumps never overwrite each other. /// Creates the pardes dir (parents assumed; a failure surfaces at open). pub fn outPath(buf: *[1024:0]u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; var dir_buf: [900]u8 = undefined; const dir = if (std.c.getenv("XDG_DATA_HOME")) |x| std.fmt.bufPrint(&dir_buf, "{s}/pardes", .{std.mem.span(x)}) catch return null else if (std.c.getenv("HOME")) |h| std.fmt.bufPrint(&dir_buf, "{s}/.local/share/pardes", .{std.mem.span(h)}) catch return null else return null; var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.REALTIME, &ts); const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) }; const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}.zon", .{ dir, yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), }, 0) catch null; } pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = 16; pub const max_cols: usize = 6; pub const Size = struct { cols: u16, rows: u16, }; pub const Cursor = struct { col: u16 = 0, row: u16 = 0, }; pub const PaneKind = enum { terminal, file, image, }; pub const Terminal = struct { cwd: []const u8 = "", stream: []const u8 = "", stream_b64: []const u8 = "", cursor: Cursor = .{}, }; pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", /// an output buffer (no file behind the name) — see pardes.File.output output: bool = false, }; pub const Image = struct { path: []const u8 = "", bytes_b64: []const u8 = "", }; pub const Pane = struct { kind: PaneKind, tag: []const u8, body: []const u8, scroll: usize = 0, cols: u16 = 0, rows: u16 = 0, vweight: f32 = 1, terminal: ?Terminal = null, file: ?File = null, image: ?Image = null, }; pub const Column = struct { weight: f32 = 1, panes: []const usize = &.{}, }; pub const State = struct { magic: []const u8 = magic, version: u32 = version, screen: Size, active: usize = 0, topbar: []const u8 = "", theme: []const u8 = "dark", columns: []const Column = &.{}, panes: []const Pane = &.{}, }; pub fn validate(state: State) !void { if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; if (state.version != version) return error.BadDumpVersion; if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; for (state.columns) |col| { if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns; for (col.panes) |pane| { if (pane >= state.panes.len) return error.BadDumpPaneRef; } } for (state.panes) |pane| { switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, .image => if (pane.image == null) return error.BadDumpPaneKind, } } } pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void { try validate(state); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var file = try std.Io.Dir.createFileAbsolute(io, path, .{}); defer file.close(io); try file.writeStreamingAll(io, out.written()); } pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State { const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024)); defer gpa.free(bytes); return readZon(gpa, bytes, path); } pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State { const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); return readZonZ(gpa, source, label); } pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State { if (builtin.os.tag == .emscripten) { const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| { log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); return err; }; errdefer std.zon.parse.free(gpa, state); try validate(state); return state; } var diag: std.zon.parse.Diagnostics = .{}; defer diag.deinit(gpa); const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| { log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; errdefer std.zon.parse.free(gpa, state); try validate(state); return state; } pub fn free(gpa: std.mem.Allocator, state: State) void { std.zon.parse.free(gpa, state); } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { const enc = std.base64.standard.Encoder; const out = try alloc.alloc(u8, enc.calcSize(bytes.len)); return enc.encode(out, bytes); } pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 { const dec = std.base64.standard.Decoder; const n = try dec.calcSizeForSlice(b64); const out = try alloc.alloc(u8, n); errdefer alloc.free(out); try dec.decode(out, b64); return out; } test "dump zon roundtrip" { const gpa = std.testing.allocator; const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld"); defer gpa.free(tty_b64); const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n"); defer gpa.free(file_b64); const panes = [_]Pane{ .{ .kind = .terminal, .tag = "nm /tmp Del", .body = "hello\nworld", .scroll = 1, .cols = 40, .rows = 10, .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } }, }, .{ .kind = .file, .tag = "nm /tmp/a.txt Save Del", .body = " 1 alpha", .cols = 20, .rows = 5, .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 }, }, }; const col_panes = [_]usize{ 0, 1 }; const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }}; const state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .topbar = "Kill Newcol Dump", .theme = "dark", .columns = &columns, .panes = &panes, }; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); const source = try out.toOwnedSliceSentinel(0); defer gpa.free(source); var diag: std.zon.parse.Diagnostics = .{}; defer diag.deinit(gpa); const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}); defer std.zon.parse.free(gpa, parsed); try validate(parsed); try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); try std.testing.expectEqualStrings("dark", parsed.theme); try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); { const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("old\nhello\nworld", bytes); } { const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); } }