f1e9d723cc0f

fix list parse nix profile json + show system packages (OF-3) — list() parses nix profile list --json (object/array forms) into name+version rows, adds system section from /run/current-system/sw references; pkgFromStorePath splits <hash>-<name>-<version>; aligned two-column table via padded listPkg; header now 'packages <machine>' — build+tests green incl new parse test

time
2026-06-05 02:17 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
nina finishing brief
parent
cec9ca91b68d
3 files src/commands.zig+83 −6src/exec.zig+24 −0src/output.zig+8 −3
src/commands.zig+83 −6
@@ -621,17 +621,94 @@621621 }622622 623623 pub fn list(ctx: Ctx) !void {624-    output.listHeader();625-    const profile_out = exec.profileList(ctx.gpa, ctx.io, &ctx.machine) catch null;626-    if (profile_out) |out| {627-        defer ctx.gpa.free(out);624+    output.listHeader(ctx.machine.name);625+626+    // Profile packages: parse `nix profile list --json` into clean name+version627+    // rows instead of dumping nix's verbose multi-line "Name:/Flake attribute:"628+    // blocks.629+    if (exec.profileListJson(ctx.gpa, ctx.io, &ctx.machine) catch null) |json| {630+        defer ctx.gpa.free(json);628631         output.listSection("profile");629-        var lines = std.mem.splitScalar(u8, out, '\n');632+        renderProfileJson(ctx.gpa, json);633+    }634+635+    // System packages: the references of /run/current-system/sw, each a636+    // <hash>-<name>-<version> store path.637+    if (exec.systemPackagePaths(ctx.gpa, ctx.io, &ctx.machine) catch null) |paths| {638+        defer ctx.gpa.free(paths);639+        output.blankLine();640+        output.listSection("system");641+        var lines = std.mem.splitScalar(u8, paths, '\n');630642         while (lines.next()) |line| {631643             const t = std.mem.trim(u8, line, " \t\r");632644             if (t.len == 0) continue;633-            output.listPkg(t, "");645+            const row = pkgFromStorePath(t);646+            // Skip support paths with no parseable version (man pages, wrappers).647+            if (row.version.len == 0) continue;648+            output.listPkg(row.name, row.version);634649         }650+    }651+}652+653+const PkgRow = struct { name: []const u8, version: []const u8 };654+655+// Split a nix store path basename (<hash>-<name>-<version>) into name + version.656+// Version begins at the first '-' followed by a digit, which reliably lands on657+// the name/version boundary since the 32-char hash and package names start with658+// non-digits.659+fn pkgFromStorePath(path: []const u8) PkgRow {660+    const base = std.fs.path.basename(path);661+    const after_hash = if (base.len > 33 and base[32] == '-') base[33..] else base;662+    var i: usize = 0;663+    while (i + 1 < after_hash.len) : (i += 1) {664+        if (after_hash[i] == '-' and std.ascii.isDigit(after_hash[i + 1])) {665+            return .{ .name = after_hash[0..i], .version = after_hash[i + 1 ..] };666+        }667+    }668+    return .{ .name = after_hash, .version = "" };669+}670+671+test "pkgFromStorePath splits name and version at first dash-digit" {672+    const a = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-ripgrep-15.1.0");673+    try std.testing.expectEqualStrings("ripgrep", a.name);674+    try std.testing.expectEqualStrings("15.1.0", a.version);675+676+    const b = pkgFromStorePath("/nix/store/abcdefghijklmnopqrstuvwxyz012345-helix-25.01");677+    try std.testing.expectEqualStrings("helix", b.name);678+    try std.testing.expectEqualStrings("25.01", b.version);679+}680+681+fn firstStorePath(el: std.json.Value) ?[]const u8 {682+    if (el != .object) return null;683+    const sp = el.object.get("storePaths") orelse return null;684+    if (sp != .array or sp.array.items.len == 0) return null;685+    const first = sp.array.items[0];686+    return if (first == .string) first.string else null;687+}688+689+// `nix profile list --json` has two shapes across nix versions: newer keys each690+// element by name (object), older lists them positionally (array). Handle both.691+fn renderProfileJson(gpa: std.mem.Allocator, json: []const u8) void {692+    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json, .{}) catch return;693+    defer parsed.deinit();694+    if (parsed.value != .object) return;695+    const elements = parsed.value.object.get("elements") orelse return;696+    switch (elements) {697+        .object => |obj| {698+            var it = obj.iterator();699+            while (it.next()) |entry| {700+                const ver = if (firstStorePath(entry.value_ptr.*)) |p| pkgFromStorePath(p).version else "";701+                output.listPkg(entry.key_ptr.*, ver);702+            }703+        },704+        .array => |arr| {705+            for (arr.items) |el| {706+                const path = firstStorePath(el) orelse continue;707+                const row = pkgFromStorePath(path);708+                output.listPkg(row.name, row.version);709+            }710+        },711+        else => {},635712     }636713 }637714 

view file

src/exec.zig+24 −0
@@ -346,6 +346,30 @@346346     return r.stdout;347347 }348348 349+// Machine-readable profile listing — caller parses elements -> name + version.350+pub fn profileListJson(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {351+    const r = try capture(gpa, io, machine, &.{ "nix", "profile", "list", "--json" });352+    defer gpa.free(r.stderr);353+    if (r.exit_code != 0) {354+        gpa.free(r.stdout);355+        return error.ProcessFailed;356+    }357+    return r.stdout;358+}359+360+// The store paths that make up the running system environment. Each reference is361+// a <hash>-<name>-<version> path, so the caller can list system packages with362+// versions — there is no cleaner per-package list for environment.systemPackages.363+pub fn systemPackagePaths(gpa: std.mem.Allocator, io: std.Io, machine: *const types.Machine) ![]u8 {364+    const r = try capture(gpa, io, machine, &.{ "nix-store", "-q", "--references", "/run/current-system/sw" });365+    defer gpa.free(r.stderr);366+    if (r.exit_code != 0) {367+        gpa.free(r.stdout);368+        return error.ProcessFailed;369+    }370+    return r.stdout;371+}372+349373 pub fn profileUpgrade(io: std.Io, machine: *const types.Machine, gpa: std.mem.Allocator) !u8 {350374     return stream(io, machine, gpa, &.{ "nix", "profile", "upgrade", ".*" });351375 }

view file

src/output.zig+8 −3
@@ -553,16 +553,21 @@553553 554554 // --- List ---555555 556-pub fn listHeader() void {557-    p("{s}installed packages\n\n", .{hdr()});556+pub fn listHeader(machine: []const u8) void {557+    p("{s}packages  {s}\n\n", .{ hdr(), machine });558558 }559559 560560 pub fn listSection(title: []const u8) void {561561     p("   {s}{s}{s}\n", .{ c(DIM), title, c(RESET) });562562 }563563 564+// Padded name so the version column lines up into a clean two-column table.564565 pub fn listPkg(name: []const u8, pkg_version: []const u8) void {565-    p("   {s}{s}{s}   {s}{s}{s}\n", .{ c(PINK), name, c(RESET), c(DIM), pkg_version, c(RESET) });566+    p("   {s}{s:<16}{s}  {s}{s}{s}\n", .{ c(PINK), name, c(RESET), c(DIM), pkg_version, c(RESET) });567+}568+569+pub fn blankLine() void {570+    p("\n", .{});566571 }567572 568573 // --- Hello ---

view file