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.?); +}