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
↓ nina-f1e9d723.tar.gzatlas at this save
files changed3 files
src/commands.zig+83 −6src/exec.zig+24 −0src/output.zig+8 −3src/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 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 }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 ---