From 7d58462323df4df5bc938f13a4fd51ae78414e95 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sun, 23 Aug 2026 13:12:39 -0400 Subject: [PATCH] Show active profile packages Add a profile packages command that reads the active generation manifest and presents package names with their version and release. Support both the system profile and explicitly named profiles, with deterministic ordering and clear output for missing or empty active state. --- README.md | 5 +- src/cli/commands/profile.zig | 112 ++++++++++++++++++++++++++++++++++ src/cli/commands_test.zig | 113 +++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8469f6..3fc409b 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,9 @@ mere search python # install more packages mere install -p test busybox curl git -# see what's in your profile -mere profile list +# see which package versions are active +mere profile packages +mere profile packages -p test # inspect the store ls /mere/store/ diff --git a/src/cli/commands/profile.zig b/src/cli/commands/profile.zig index c1da168..01aec2d 100644 --- a/src/cli/commands/profile.zig +++ b/src/cli/commands/profile.zig @@ -51,6 +51,21 @@ const list_meta = command.CommandMeta{ .description = "List all profiles", }; +/// Packages subcommand metadata +const packages_meta = command.CommandMeta{ + .name = "packages", + .description = "List packages in a profile's active state", + .flags = &[_]types.Flag{ + .{ + .name = "profile", + .short = 'p', + .description = "Profile to inspect (default: system)", + .flag_type = .string, + .value_name = "name", + }, + }, +}; + /// Create subcommand metadata const create_meta = command.CommandMeta{ .name = "create", @@ -221,6 +236,99 @@ pub fn handleList(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!t }; } +/// List package names and versions from a profile's active realized state. +pub fn handlePackages(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { + const profile_name = args.getString("profile") orelse "system"; + const profile_dir = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "profiles", profile_name }) catch { + return MereError.OutOfMemory; + }; + defer ctx.allocator.free(profile_dir); + + const store_root = std.fs.path.join(ctx.allocator, &.{ ctx.root_path, "mere", "store" }) catch { + return MereError.OutOfMemory; + }; + defer ctx.allocator.free(store_root); + + var active_generation: ?u32 = null; + const active_path = if (std.mem.eql(u8, profile_name, "system")) blk: { + active_generation = generation_mod.getCurrentGeneration(profile_dir) catch |err| { + ctx.setDiagnosticContextFmt(profile_dir, "failed to read current generation: {s}", .{@errorName(err)}); + return try command.errorResult(ctx, err, "failed to read current generation"); + }; + const generation = active_generation orelse { + return types.CommandResult{ + .success = true, + .message = try std.fmt.allocPrint(ctx.allocator, "Profile '{s}' has no active generation", .{profile_name}), + }; + }; + break :blk generation_mod.getGenerationPath(ctx.allocator, profile_dir, generation) catch return MereError.OutOfMemory; + } else blk: { + const root_path = profile_mod.getRootPath(ctx.allocator, profile_dir) catch return MereError.OutOfMemory; + std.Io.Dir.accessAbsolute(path.currentIo(), root_path, .{}) catch |err| switch (err) { + error.FileNotFound => { + ctx.allocator.free(root_path); + return types.CommandResult{ + .success = true, + .message = try std.fmt.allocPrint(ctx.allocator, "Profile '{s}' has no active realized state", .{profile_name}), + }; + }, + else => { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(root_path)); + const failure = try command.errorResult(ctx, err, "failed to inspect profile state"); + ctx.allocator.free(root_path); + return failure; + }, + }; + break :blk root_path; + }; + defer ctx.allocator.free(active_path); + + var manifest = generation_mod.readManifest(ctx.allocator, store_root, active_path) catch |err| { + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(active_path)); + return try command.errorResult(ctx, err, "failed to read active profile manifest"); + }; + defer manifest.deinit(); + + const packages = ctx.allocator.dupe(generation_mod.PackageEntry, manifest.packages.items) catch { + return MereError.OutOfMemory; + }; + defer ctx.allocator.free(packages); + std.mem.sort(generation_mod.PackageEntry, packages, {}, struct { + fn lessThan(_: void, left: generation_mod.PackageEntry, right: generation_mod.PackageEntry) bool { + const name_order = std.mem.order(u8, left.name, right.name); + if (name_order != .eq) return name_order == .lt; + const version_order = std.mem.order(u8, left.version, right.version); + if (version_order != .eq) return version_order == .lt; + return left.release < right.release; + } + }.lessThan); + + var output: std.ArrayList(u8) = .empty; + defer output.deinit(ctx.allocator); + var out_buf: std.Io.Writer.Allocating = .fromArrayList(ctx.allocator, &output); + const out = &out_buf.writer; + + if (active_generation) |generation| { + out.print("Packages in profile '{s}' (gen-{d}):\n", .{ profile_name, generation }) catch return MereError.OutOfMemory; + } else { + out.print("Packages in profile '{s}':\n", .{profile_name}) catch return MereError.OutOfMemory; + } + + if (packages.len == 0) { + out.writeAll(" (none)\n") catch return MereError.OutOfMemory; + } else { + for (packages) |pkg| { + out.print(" {s} {s}-{d}\n", .{ pkg.name, pkg.version, pkg.release }) catch return MereError.OutOfMemory; + } + } + output = out_buf.toArrayList(); + + return types.CommandResult{ + .success = true, + .message = try ctx.allocator.dupe(u8, output.items), + }; +} + /// Create profile handler pub fn handleCreate(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { if (args.positional.len < 1) { @@ -513,6 +621,9 @@ pub fn createCommand(allocator: std.mem.Allocator) !*command.Command { const list_cmd = try allocator.create(command.Command); list_cmd.* = command.Command.init(allocator, list_meta, handleList); + const packages_cmd = try allocator.create(command.Command); + packages_cmd.* = command.Command.init(allocator, packages_meta, handlePackages); + const create_cmd = try allocator.create(command.Command); create_cmd.* = command.Command.init(allocator, create_meta, handleCreate); @@ -523,6 +634,7 @@ pub fn createCommand(allocator: std.mem.Allocator) !*command.Command { apply_cmd.* = command.Command.init(allocator, apply_meta, handleApply); try profile_cmd.addSubcommand(list_cmd); + try profile_cmd.addSubcommand(packages_cmd); try profile_cmd.addSubcommand(create_cmd); try profile_cmd.addSubcommand(delete_cmd); try profile_cmd.addSubcommand(apply_cmd); diff --git a/src/cli/commands_test.zig b/src/cli/commands_test.zig index 9ee4249..7a4762e 100644 --- a/src/cli/commands_test.zig +++ b/src/cli/commands_test.zig @@ -166,3 +166,116 @@ test "pin add fails without ever touching gc-roots when the store lock can't be defer testing.allocator.free(gc_roots_dir); try testing.expectError(error.FileNotFound, std.Io.Dir.accessAbsolute(mere.path.currentIo(), gc_roots_dir, .{})); } + +fn writeProfileManifest( + ctx: *mere.Context, + profile_path: []const u8, + generation: ?u32, + packages: []const struct { name: []const u8, version: []const u8, release: u32 }, +) !void { + var profile_dir = try mere.path.makePathAndOpenDir(profile_path); + profile_dir.close(mere.path.currentIo()); + + var manifest = if (generation) |number| + mere.generation.GenerationManifest.init(ctx.allocator, number) + else + mere.generation.GenerationManifest.initRoot(ctx.allocator); + defer manifest.deinit(); + + for (packages) |pkg| { + const store_path = try std.fmt.allocPrint(ctx.allocator, "/mere/store/{s}-{s}-{s}", .{ "a" ** 64, pkg.name, pkg.version }); + defer ctx.allocator.free(store_path); + try manifest.addPackage(pkg.name, pkg.version, pkg.release, "x86_64", store_path, "a" ** 64); + } + try mere.generation.writeManifest(ctx.allocator, profile_path, &manifest); +} + +test "profile packages lists the active system generation deterministically" { + const testing = std.testing; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = try tmp.dir.realPath(mere.path.currentIo(), &path_buf); + var ctx = mere.Context.init(testing.allocator, path_buf[0..root_len]); + defer ctx.deinit(); + + const profile_dir = try std.fs.path.join(testing.allocator, &.{ ctx.root_path, "mere", "profiles", "system" }); + defer testing.allocator.free(profile_dir); + const gen_path = try std.fs.path.join(testing.allocator, &.{ profile_dir, "gen-2" }); + defer testing.allocator.free(gen_path); + try writeProfileManifest(&ctx, gen_path, 2, &.{ + .{ .name = "zlib", .version = "1.3.1", .release = 2 }, + .{ .name = "busybox", .version = "1.36.1", .release = 4 }, + }); + var profile_handle = try std.Io.Dir.openDirAbsolute(mere.path.currentIo(), profile_dir, .{}); + defer profile_handle.close(mere.path.currentIo()); + try profile_handle.symLink(mere.path.currentIo(), "gen-2", mere.generation.CURRENT_SYMLINK, .{}); + + var args = types.ParsedArgs.init(testing.allocator); + defer args.deinit(); + const result = try profile_cmd.handlePackages(&ctx, &args); + defer if (result.message) |message| testing.allocator.free(message); + + try testing.expect(result.success); + try testing.expectEqualStrings( + "Packages in profile 'system' (gen-2):\n busybox 1.36.1-4\n zlib 1.3.1-2\n", + result.message.?, + ); +} + +test "profile packages reads a named profile's active root" { + const testing = std.testing; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = try tmp.dir.realPath(mere.path.currentIo(), &path_buf); + var ctx = mere.Context.init(testing.allocator, path_buf[0..root_len]); + defer ctx.deinit(); + + const root_path = try std.fs.path.join(testing.allocator, &.{ ctx.root_path, "mere", "profiles", "tools", "root" }); + defer testing.allocator.free(root_path); + try writeProfileManifest(&ctx, root_path, null, &.{ + .{ .name = "git", .version = "2.51.0", .release = 1 }, + }); + + var args = types.ParsedArgs.init(testing.allocator); + defer args.deinit(); + try args.flags.put("profile", .{ .string = "tools" }); + const result = try profile_cmd.handlePackages(&ctx, &args); + defer if (result.message) |message| testing.allocator.free(message); + + try testing.expect(result.success); + try testing.expectEqualStrings("Packages in profile 'tools':\n git 2.51.0-1\n", result.message.?); +} + +test "profile packages distinguishes missing and empty active state" { + const testing = std.testing; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = try tmp.dir.realPath(mere.path.currentIo(), &path_buf); + var ctx = mere.Context.init(testing.allocator, path_buf[0..root_len]); + defer ctx.deinit(); + var args = types.ParsedArgs.init(testing.allocator); + defer args.deinit(); + + const missing = try profile_cmd.handlePackages(&ctx, &args); + defer if (missing.message) |message| testing.allocator.free(message); + try testing.expectEqualStrings("Profile 'system' has no active generation", missing.message.?); + + const profile_dir = try std.fs.path.join(testing.allocator, &.{ ctx.root_path, "mere", "profiles", "system" }); + defer testing.allocator.free(profile_dir); + const gen_path = try std.fs.path.join(testing.allocator, &.{ profile_dir, "gen-1" }); + defer testing.allocator.free(gen_path); + try writeProfileManifest(&ctx, gen_path, 1, &.{}); + var profile_handle = try std.Io.Dir.openDirAbsolute(mere.path.currentIo(), profile_dir, .{}); + defer profile_handle.close(mere.path.currentIo()); + try profile_handle.symLink(mere.path.currentIo(), "gen-1", mere.generation.CURRENT_SYMLINK, .{}); + + const empty = try profile_cmd.handlePackages(&ctx, &args); + defer if (empty.message) |message| testing.allocator.free(message); + try testing.expectEqualStrings("Packages in profile 'system' (gen-1):\n (none)\n", empty.message.?); +}