From 681931fb3b16478d149bfc69076b22579535d961 Mon Sep 17 00:00:00 2001 From: spencer Date: Sun, 31 May 2026 15:39:38 -0400 Subject: [PATCH] feat: Added table viewer. --- .envrc | 3 +- .gitignore | 1 + src/Db.zig | 22 +++- src/root.zig | 104 +++++++++++++++-- src/tabula.zig | 309 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 src/tabula.zig diff --git a/.envrc b/.envrc index 680227f..9a7514f 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,4 @@ use flake -export PATH=".:/home/spencer/github.com/envr-zig/deps/zig:/home/spencer/github.com/envr-zig/deps/zls:$PATH" +ROOT="/home/spencer/Desktop/envr" +export PATH=".:${ROOT}/deps/zig:${ROOT}/deps/zls:$PATH" diff --git a/.gitignore b/.gitignore index 8461b76..8c93171 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # dev env .direnv +/.env # dependencies deps diff --git a/src/Db.zig b/src/Db.zig index c297deb..93d66ed 100644 --- a/src/Db.zig +++ b/src/Db.zig @@ -46,11 +46,27 @@ pub fn open( defer private_keys.deinit(gpa); for (opts.config.keys) |key| { - private_keys.appendAssumeCapacity(key.private); + // FIXME: cheating here + if (std.mem.startsWith(u8, key.private, "~/")) { + const key_path = try std.fs.path.join(gpa, &.{ + opts.home, + key.private[2..], + }); + private_keys.appendAssumeCapacity(key_path); + // defer gpa.free(key_path); + } else { + private_keys.appendAssumeCapacity(key.private); + } } // TODO: Pass key(s) from Config try age.decrypt(io, gpa, private_keys.items, db_path, tmp_db_path); + + for (opts.config.keys, 0..) |key, i| { + if (std.mem.startsWith(u8, key.private, "~/")) { + gpa.free(private_keys.items[i]); + } + } } } @@ -161,7 +177,7 @@ pub fn list(self: *@This(), gpa: std.mem.Allocator) ![]EnvFile { return stmt.all(EnvFile, gpa, .{}, .{}); } -const EnvFile = struct { +pub const EnvFile = struct { // TODO: Should use file_name in the struct and derive from the path. path: []const u8, @@ -173,7 +189,7 @@ const EnvFile = struct { sha256: []const u8, contents: []const u8, - fn deinit(self: *EnvFile, alloc: std.mem.Allocator) void { + pub fn deinit(self: *EnvFile, alloc: std.mem.Allocator) void { alloc.free(self.path); alloc.free(self.remotes); alloc.free(self.sha256); diff --git a/src/root.zig b/src/root.zig index 9a196c4..d6c8b92 100644 --- a/src/root.zig +++ b/src/root.zig @@ -2,11 +2,11 @@ const std = @import("std"); const Io = std.Io; -const comma = @import("comma"); -const Command = comma.Command; +const Command = @import("comma").Command; const Config = @import("Config.zig"); const Db = @import("Db.zig"); +const tabula = @import("./tabula.zig"); pub const root: Command = .new(.{ .name = "envr", @@ -72,23 +72,29 @@ pub fn list( ) !void { // TODO: Don't hardcode const cfgPath = try std.fs.path.join(arena, &.{ home, ".envr", "config.json" }); - const cfg: Config = (try Config.load(io, arena, cfgPath)).value; + defer arena.free(cfgPath); + + var cfg = (try Config.load(io, arena, cfgPath)); + defer cfg.deinit(); var db: Db = try .open(io, arena, .{ - .config = cfg, + .config = cfg.value, .home = home, .tmp = tmp, }); - _ = try out.write("Path\n"); const files = try db.list(arena); - for (files) |file| { - // TODO: Table printer - try out.print("{s}\n", .{file.path}); - } + defer arena.free(files); + + const table: tabula.Table(Db.EnvFile, .initOne(.path)) = .{ .items = files }; + try out.print("{f}", .{table}); try out.flush(); - return db.close(io, arena); // TODO: Defer this + try db.close(io, arena); // TODO: Defer this + + for (files) |*file| { + file.deinit(arena); + } } test { @@ -129,3 +135,81 @@ test "parse unknown" { try std.testing.expectEqual(.unknown, cmd); } + +test "list returns a table" { + const io = std.testing.io; + const gpa = std.testing.allocator; + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + try tmp_dir.dir.createDir(io, "home", .default_dir); + try tmp_dir.dir.createDir(io, "home/.envr", .default_dir); + try tmp_dir.dir.createDir(io, "home/.ssh", .default_dir); + try tmp_dir.dir.createDir(io, "tmp", .default_dir); + + const tmp_dir_path = try tmp_dir.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(tmp_dir_path); + + const home = try std.fs.path.join(gpa, &.{ tmp_dir_path, "home" }); + defer gpa.free(home); + const tmp = try std.fs.path.join(gpa, &.{ tmp_dir_path, "tmp" }); + defer gpa.free(tmp); + + try std.Io.Dir.cwd().copyFile( + "fixtures/encrypted-single-file.db.age", + tmp_dir.dir, + "home/.envr/data.age", + io, + .{}, + ); + + try std.Io.Dir.cwd().copyFile( + "fixtures/default_config.json", + tmp_dir.dir, + "home/.envr/config.json", + io, + .{}, + ); + + try std.Io.Dir.cwd().copyFile( + "fixtures/insecure-test-key", + tmp_dir.dir, + "home/.ssh/id_ed25519", + io, + .{}, + ); + + try std.Io.Dir.cwd().copyFile( + "fixtures/insecure-test-key.pub", + tmp_dir.dir, + "home/.ssh/id_ed25519.pub", + io, + .{}, + ); + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + // Run Test + + try list( + io, + std.testing.allocator, + &out.writer, + home, + tmp, + ); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌────────────────────────┐ + \\│ path │ + \\├────────────────────────┤ + \\│ ~/project/.env.example │ + \\└────────────────────────┘ + \\ + , got); +} diff --git a/src/tabula.zig b/src/tabula.zig new file mode 100644 index 0000000..6cc5cfe --- /dev/null +++ b/src/tabula.zig @@ -0,0 +1,309 @@ +const std = @import("std"); + +const hor = "─"; +const tl = "┌"; +const tm = "┬"; +const tr = "┐"; +const sep = "│"; +const ml = "├"; +const mm = "┼"; +const mr = "┤"; +const bl = "└"; +const bm = "┴"; +const br = "┘"; + +/// Prepare a TUI table to be written to a writer. +pub fn Table( + comptime T: type, + comptime fields: std.EnumSet(std.meta.FieldEnum(T)), +) type { + return struct { + items: []const T, + + pub fn format(self: @This(), writer: *std.Io.Writer) !void { + const max_column_widths = determine_col_widths(T, self.items); + + try header(T, fields, &max_column_widths, writer); + + // Print body + for (self.items) |item| { + _ = try writer.write(sep); + + comptime var itr = fields.iterator(); + comptime var i: usize = 0; + inline while (comptime itr.next()) |c| : (i += 1) { + _ = try writer.write(" "); + try write_aligned(writer, @field(item, @tagName(c)), max_column_widths[i], .left); + try writer.print(" {s}", .{sep}); + } + + _ = try writer.write("\n"); + } + + // Print post-body + { + _ = try writer.write(bl); + + var itr = fields.iterator(); + var i: usize = 0; + while (itr.next()) |_| : (i += 1) { + if (i > 0) { + _ = try writer.write(bm); + } + + const padding = max_column_widths[i] + 2; + for (0..padding) |_| { + _ = try writer.write(hor); + } + } + + _ = try writer.write(br); + _ = try writer.write("\n"); + } + } + }; +} + +fn determine_col_widths( + T: type, + items: []const T, +) [@typeInfo(T).@"struct".fields.len]usize { + const all_fields = @typeInfo(T).@"struct".fields; + + var max_column_widths: [all_fields.len]usize = @splat(0); + for (items) |item| { + inline for (all_fields, 0..) |field, i| { + // TODO: Get str len of item + const value_len = @field(item, field.name).len; + max_column_widths[i] = @max( + max_column_widths[i], + field.name.len, + value_len, + ); + } + } + + return max_column_widths; +} + +// Print the header of a table +fn header( + T: type, + comptime fields: std.EnumSet(std.meta.FieldEnum(T)), + max_column_widths: []const usize, + writer: *std.Io.Writer, +) !void { + + // Print Pre-Header + { + _ = try writer.write(tl); + + inline for (0..comptime fields.count()) |i| { + if (i > 0) { + _ = try writer.write(tm); + } + const padding = max_column_widths[i] + 2; + for (0..padding) |_| { + _ = try writer.write(hor); + } + } + + _ = try writer.write(tr ++ "\n"); + } + + // Main Header + { + _ = try writer.write(sep); + + comptime var itr = fields.iterator(); + comptime var i: usize = 0; + inline while (comptime itr.next()) |field| : (i += 1) { + _ = try writer.write(" "); + try write_aligned( + writer, + @tagName(field), + max_column_widths[i], + .center, + ); + try writer.print(" {s}", .{sep}); + } + + try writer.print("\n", .{}); + } + + // Print post-header + { + _ = try writer.write(ml); + + inline for (0..comptime fields.count()) |i| { + if (i > 0) { + _ = try writer.write(mm); + } + const padding = max_column_widths[i] + 2; + for (0..padding) |_| { + _ = try writer.write(hor); + } + } + + _ = try writer.write(mr ++ "\n"); + } +} + +fn write_aligned( + writer: *std.Io.Writer, + data: []const u8, + max_width: usize, + alignment: Alignment, +) !void { + const padding: [2]usize = switch (alignment) { + .left => .{ 0, max_width - data.len }, + .right => .{ max_width - data.len, 0 }, + .center => blk: { + // Faster to inline the divFloor? + const half = @divFloor(max_width - data.len, 2); + break :blk .{ half, max_width - data.len - half }; + }, + }; + + for (0..padding[0]) |_| { + _ = try writer.write(" "); + } + + _ = try writer.write(data); + + for (0..padding[1]) |_| { + _ = try writer.write(" "); + } +} + +const Alignment = enum { left, center, right }; + +test "can print a simple table" { + const gpa = std.testing.allocator; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + const F = struct { foo: []const u8, bar: []const u8 }; + const table: Table(F, .full) = .{ + .items = &.{.{ .foo = "bat", .bar = "baz" }}, + }; + + try out.writer.print("{f}", .{table}); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌─────┬─────┐ + \\│ foo │ bar │ + \\├─────┼─────┤ + \\│ bat │ baz │ + \\└─────┴─────┘ + \\ + , got); +} + +test "can print a table with varying header widths" { + const gpa = std.testing.allocator; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + const F = struct { foo: []const u8, abart: []const u8 }; + const table: Table(F, .full) = .{ + .items = &.{.{ .foo = "bat", .abart = "baz" }}, + }; + try out.writer.print("{f}", .{table}); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌─────┬───────┐ + \\│ foo │ abart │ + \\├─────┼───────┤ + \\│ bat │ baz │ + \\└─────┴───────┘ + \\ + , got); +} + +test "can print a table with varying column widths" { + const gpa = std.testing.allocator; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + const F = struct { foo: []const u8, bar: []const u8 }; + const table: Table(F, .full) = .{ .items = &.{.{ .foo = "bat", .bar = "bazzar" }} }; + + try out.writer.print("{f}", .{table}); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌─────┬────────┐ + \\│ foo │ bar │ + \\├─────┼────────┤ + \\│ bat │ bazzar │ + \\└─────┴────────┘ + \\ + , got); +} + +test "can print a multi row table with varying column widths" { + const gpa = std.testing.allocator; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + const F = struct { foo: []const u8, bar: []const u8 }; + const table: Table(F, .full) = .{ + .items = &.{ + .{ .foo = "baz", .bar = "quz" }, + .{ .foo = "bat", .bar = "bazzar" }, + }, + }; + try out.writer.print("{f}", .{table}); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌─────┬────────┐ + \\│ foo │ bar │ + \\├─────┼────────┤ + \\│ baz │ quz │ + \\│ bat │ bazzar │ + \\└─────┴────────┘ + \\ + , got); +} + +test "can print a table with limited columns" { + const gpa = std.testing.allocator; + + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + + const F = struct { foo: []const u8, bar: []const u8 }; + const table: Table(F, .initOne(.foo)) = .{ + .items = &.{.{ .foo = "bat", .bar = "baz" }}, + }; + + try out.writer.print("{f}", .{table}); + + const got = try out.toOwnedSlice(); + defer gpa.free(got); + + try std.testing.expectEqualStrings( + \\┌─────┐ + \\│ foo │ + \\├─────┤ + \\│ bat │ + \\└─────┘ + \\ + , got); +}