diff --git a/src/cli/commands/install.zig b/src/cli/commands/install.zig index b47e51e..da7d0d7 100644 --- a/src/cli/commands/install.zig +++ b/src/cli/commands/install.zig @@ -35,6 +35,11 @@ const install_meta = command.CommandMeta{ .description = "Force repository sync even if cache is fresh", .flag_type = .bool, }, + .{ + .name = "dry-run", + .description = "Show changes without creating or activating a generation", + .flag_type = .bool, + }, }, }; @@ -48,6 +53,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty const profile_name = args.getString("profile") orelse "system"; const verify_store = args.getBool("verify-store"); const force_sync = args.getBool("sync"); + const dry_run = args.getBool("dry-run"); // Set initial diagnostic context - the subject is the package being installed const diagnostic_ctx = mere.errors.DiagnosticContext.init() @@ -58,7 +64,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty defer ctx.releaseStoreLock(); // Error boundary: catch all errors and map them to user-friendly messages at CLI boundary - const success_message = performInstallation(ctx, package_names, profile_name, verify_store, force_sync) catch |err| { + const success_message = performInstallation(ctx, package_names, profile_name, verify_store, force_sync, dry_run) catch |err| { return try command.errorResult(ctx, err, null); }; @@ -77,6 +83,7 @@ fn performInstallation( profile_name: []const u8, verify_store: bool, force_sync: bool, + dry_run: bool, ) !?[]const u8 { // Ensure configuration is loaded (no logging - errors propagate) _ = try ctx.getConfig(); @@ -87,7 +94,7 @@ fn performInstallation( const client = curl_client.client(); // Perform installation (no logging - errors propagate) - const outcome = try mere.install.installPackagesFromConfig(ctx, package_names, client, false, verify_store, force_sync, profile_name); + const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, force_sync, profile_name, dry_run); return switch (outcome) { .completed => null, .store_only_system_activation_deferred => null, diff --git a/src/cli/commands/upgrade.zig b/src/cli/commands/upgrade.zig new file mode 100644 index 0000000..42840f4 --- /dev/null +++ b/src/cli/commands/upgrade.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const mere = @import("mere"); +const download = mere.download; +const types = @import("../types.zig"); +const command = @import("../command.zig"); +const MereError = types.MereError; + +const upgrade_meta = command.CommandMeta{ + .group = "Package Management", + .order = 31, + .name = "upgrade", + .description = "Upgrade requested packages in a profile", + .args = &[_]types.Arg{ + .{ + .name = "package", + .description = "Requested package name(s); omit to upgrade all roots", + .required = false, + }, + }, + .flags = &[_]types.Flag{ + .{ + .name = "profile", + .short = 'p', + .description = "Profile to upgrade (default: system)", + .flag_type = .string, + }, + .{ + .name = "verify-store", + .description = "Verify store content hashes during activation (slow)", + .flag_type = .bool, + }, + .{ + .name = "sync", + .description = "Force repository sync even if cache is fresh", + .flag_type = .bool, + }, + .{ + .name = "dry-run", + .description = "Show changes without creating or activating a generation", + .flag_type = .bool, + }, + }, +}; + +fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { + const package_names = args.positional; + const profile_name = args.getString("profile") orelse "system"; + const verify_store = args.getBool("verify-store"); + const force_sync = args.getBool("sync"); + const dry_run = args.getBool("dry-run"); + + ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject( + if (package_names.len > 0) package_names[0] else profile_name, + )); + + if (try command.acquireStoreLockOrResult(ctx)) |result| return result; + defer ctx.releaseStoreLock(); + + _ = ctx.getConfig() catch |err| return try command.errorResult(ctx, err, null); + var curl_client = download.CurlTransferClient.init(ctx, command.user_agent) catch |err| { + return try command.errorResult(ctx, err, null); + }; + defer download.CurlTransferClient.cleanupFn(ctx, curl_client); + + _ = mere.install.upgradePackagesFromConfig( + ctx, + package_names, + curl_client.client(), + verify_store, + force_sync, + profile_name, + dry_run, + ) catch |err| return try command.errorResult(ctx, err, null); + + return .{ .success = true }; +} + +pub fn createCommand(allocator: std.mem.Allocator) !*command.Command { + const cmd = try allocator.create(command.Command); + cmd.* = command.Command.init(allocator, upgrade_meta, handleUpgrade); + return cmd; +} diff --git a/src/cli/main.zig b/src/cli/main.zig index c5c2d83..bbfcc14 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -7,6 +7,7 @@ const MereError = mere.errors.MereError; // Import command implementations const install = @import("commands/install.zig"); +const upgrade = @import("commands/upgrade.zig"); const uninstall = @import("commands/uninstall.zig"); const dev = @import("commands/dev.zig"); const release_cmd = @import("commands/release.zig"); @@ -139,6 +140,11 @@ fn registerCommands(allocator: std.mem.Allocator, cli_system: *cli.CLI, root_com try cli_system.registerCommand(install_command); try root_command.addSubcommand(install_command); + // Create upgrade command + const upgrade_command = try upgrade.createCommand(allocator); + try cli_system.registerCommand(upgrade_command); + try root_command.addSubcommand(upgrade_command); + // Create uninstall command const uninstall_command = try uninstall.createCommand(allocator); try cli_system.registerCommand(uninstall_command); diff --git a/src/generation.zig b/src/generation.zig index 49be281..998195b 100644 --- a/src/generation.zig +++ b/src/generation.zig @@ -31,6 +31,8 @@ pub const PackageEntry = struct { arch: []const u8, store_path: []const u8, content_hash: []const u8, // 64 hex chars + requested: bool = true, + constraint_expr: ?[]const u8 = null, pub fn deinit(self: *PackageEntry, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -38,6 +40,7 @@ pub const PackageEntry = struct { allocator.free(self.arch); allocator.free(self.store_path); allocator.free(self.content_hash); + if (self.constraint_expr) |constraint| allocator.free(constraint); } }; @@ -48,11 +51,14 @@ pub const PackageSpec = struct { version: ?[]const u8 = null, release: ?u32 = null, content_hash: ?[]const u8 = null, + requested: bool = true, + constraint_expr: ?[]const u8 = null, pub fn deinit(self: *PackageSpec, allocator: std.mem.Allocator) void { allocator.free(self.name); if (self.version) |v| allocator.free(v); if (self.content_hash) |h| allocator.free(h); + if (self.constraint_expr) |constraint| allocator.free(constraint); } }; @@ -258,6 +264,20 @@ pub const GenerationManifest = struct { arch: []const u8, store_path: []const u8, content_hash: []const u8, + ) GenerationError!void { + return self.addPackageWithIntent(name, version, release, arch, store_path, content_hash, true, null); + } + + pub fn addPackageWithIntent( + self: *GenerationManifest, + name: []const u8, + version: []const u8, + release: u32, + arch: []const u8, + store_path: []const u8, + content_hash: []const u8, + requested: bool, + constraint_expr: ?[]const u8, ) GenerationError!void { const entry = PackageEntry{ .name = self.allocator.dupe(u8, name) catch return GenerationError.OutOfMemory, @@ -266,6 +286,11 @@ pub const GenerationManifest = struct { .arch = self.allocator.dupe(u8, arch) catch return GenerationError.OutOfMemory, .store_path = self.allocator.dupe(u8, store_path) catch return GenerationError.OutOfMemory, .content_hash = self.allocator.dupe(u8, content_hash) catch return GenerationError.OutOfMemory, + .requested = requested, + .constraint_expr = if (constraint_expr) |constraint| + self.allocator.dupe(u8, constraint) catch return GenerationError.OutOfMemory + else + null, }; self.packages.append(self.allocator, entry) catch return GenerationError.OutOfMemory; @@ -337,11 +362,19 @@ pub const GenerationManifest = struct { try append(&buffer, a, pkg.name); try append(&buffer, a, "\""); - const props = std.fmt.allocPrint(a, " version=\"{s}\" release={d} content-hash=\"{s}\"", .{ - pkg.version, pkg.release, pkg.content_hash, + const props = std.fmt.allocPrint(a, " version=\"{s}\" release={d} content-hash=\"{s}\" requested=#{s}", .{ + pkg.version, + pkg.release, + pkg.content_hash, + if (pkg.requested) "true" else "false", }) catch return GenerationError.OutOfMemory; defer a.free(props); try append(&buffer, a, props); + if (pkg.constraint_expr) |constraint| { + try append(&buffer, a, " constraint=\""); + try append(&buffer, a, constraint); + try append(&buffer, a, "\""); + } try append(&buffer, a, "\n"); } @@ -434,6 +467,12 @@ pub const GenerationManifest = struct { break :blk @intCast(v); }; const content_hash = child.getStringProperty("content-hash") orelse return GenerationError.InvalidManifest; + const requested = if (child.getProperty("requested")) |value| + value.getBoolean() orelse return GenerationError.InvalidManifest + else + true; + const constraint_expr = child.getStringProperty("constraint"); + if (!requested and constraint_expr != null) return GenerationError.InvalidManifest; const store_dir_name = std.fmt.allocPrint(allocator, "{s}-{s}-{s}", .{ content_hash, name, version }) catch return GenerationError.OutOfMemory; defer allocator.free(store_dir_name); @@ -442,7 +481,7 @@ pub const GenerationManifest = struct { const host_arch = @tagName(builtin.cpu.arch); - try manifest.addPackage(name, version, release, host_arch, store_path, content_hash); + try manifest.addPackageWithIntent(name, version, release, host_arch, store_path, content_hash, requested, constraint_expr); } return manifest; @@ -514,6 +553,13 @@ pub fn parseProfilePackageSpecs(allocator: std.mem.Allocator, input: []const u8) if (child.getStringProperty("content-hash")) |h| { spec.content_hash = allocator.dupe(u8, h) catch return GenerationError.OutOfMemory; } + if (child.getProperty("requested")) |value| { + spec.requested = value.getBoolean() orelse return GenerationError.InvalidManifest; + } + if (child.getStringProperty("constraint")) |constraint| { + if (!spec.requested) return GenerationError.InvalidManifest; + spec.constraint_expr = allocator.dupe(u8, constraint) catch return GenerationError.OutOfMemory; + } specs.append(allocator, spec) catch return GenerationError.OutOfMemory; } @@ -1067,22 +1113,26 @@ test "GenerationManifest encode and parse roundtrip" { manifest.notes = try allocator.dupe(u8, "test generation"); manifest.tool_version = try allocator.dupe(u8, "test-version"); - try manifest.addPackage( + try manifest.addPackageWithIntent( "nginx", "1.24.0", 1, "x86_64", "/mere/store/abc123-nginx-1.24.0/", "abc123def456789012345678901234567890123456789012345678901234", + true, + ">=1.24 <2", ); - try manifest.addPackage( + try manifest.addPackageWithIntent( "musl", "1.2.4", 1, "x86_64", "/mere/store/def456-musl-1.2.4/", "def456abc123789012345678901234567890123456789012345678901234", + false, + null, ); const encoded = try manifest.encode(allocator); @@ -1102,6 +1152,8 @@ test "GenerationManifest encode and parse roundtrip" { try std.testing.expectEqualStrings("musl", parsed.packages.items[0].name); try std.testing.expectEqualStrings("1.2.4", parsed.packages.items[0].version); try std.testing.expectEqual(@as(u32, 1), parsed.packages.items[0].release); + try std.testing.expect(!parsed.packages.items[0].requested); + try std.testing.expect(parsed.packages.items[0].constraint_expr == null); // Verify store path is derived from content-hash + name + version try std.testing.expectEqualStrings( "/mere/store/def456abc123789012345678901234567890123456789012345678901234-musl-1.2.4", @@ -1109,6 +1161,48 @@ test "GenerationManifest encode and parse roundtrip" { ); try std.testing.expectEqualStrings("nginx", parsed.packages.items[1].name); try std.testing.expectEqualStrings("1.24.0", parsed.packages.items[1].version); + try std.testing.expect(parsed.packages.items[1].requested); + try std.testing.expectEqualStrings(">=1.24 <2", parsed.packages.items[1].constraint_expr.?); +} + +test "legacy profile packages default to requested roots" { + const allocator = std.testing.allocator; + const input = + \\profile { + \\ schema-version 2 + \\ created-at 1234567890 + \\ package "demo" version="1.0.0" release=1 content-hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + \\} + ; + + var parsed = try GenerationManifest.parse(allocator, "/mere/store", input); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 1), parsed.packages.items.len); + try std.testing.expect(parsed.packages.items[0].requested); + try std.testing.expect(parsed.packages.items[0].constraint_expr == null); +} + +test "profile package specs preserve intent" { + const allocator = std.testing.allocator; + const input = + \\profile { + \\ package "app" version="2.0.0" release=3 content-hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" requested=#true constraint=">=2 <3" + \\ package "lib" version="1.0.0" release=1 content-hash="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" requested=#false + \\} + ; + + const specs = try parseProfilePackageSpecs(allocator, input); + defer { + for (specs) |*spec| spec.deinit(allocator); + allocator.free(specs); + } + + try std.testing.expectEqual(@as(usize, 2), specs.len); + try std.testing.expect(specs[0].requested); + try std.testing.expectEqualStrings(">=2 <3", specs[0].constraint_expr.?); + try std.testing.expect(!specs[1].requested); + try std.testing.expect(specs[1].constraint_expr == null); } test "RealizationData encode and decode roundtrip" { diff --git a/src/install.zig b/src/install.zig index 2f31b40..74a3757 100644 --- a/src/install.zig +++ b/src/install.zig @@ -35,11 +35,14 @@ const InstallRootRequirement = struct { name: []const u8, constraint_expr: ?[]const u8 = null, content_hash: ?[]const u8 = null, + requested: bool = true, + intent_constraint: ?[]const u8 = null, fn deinit(self: *InstallRootRequirement, allocator: std.mem.Allocator) void { allocator.free(self.name); if (self.constraint_expr) |expr| allocator.free(expr); if (self.content_hash) |h| allocator.free(h); + if (self.intent_constraint) |expr| allocator.free(expr); } }; @@ -158,19 +161,26 @@ fn profileMatchesResolution(ctx: *Context, profile_name: []const u8, sorted: []c if (current.packages.items.len != sorted.len) return false; - var current_hashes = std.StringHashMap(void).init(ctx.allocator); - defer current_hashes.deinit(); + var current_by_hash = std.StringHashMap(generation.PackageEntry).init(ctx.allocator); + defer current_by_hash.deinit(); for (current.packages.items) |pkg| { - current_hashes.put(pkg.content_hash, {}) catch return false; + current_by_hash.put(pkg.content_hash, pkg) catch return false; } for (sorted) |resolved| { - if (!current_hashes.contains(resolved.pkg.content_hash)) return false; + const current_pkg = current_by_hash.get(resolved.pkg.content_hash) orelse return false; + if (current_pkg.requested != resolved.requested) return false; + if (!optionalStringEqual(current_pkg.constraint_expr, resolved.constraint_expr)) return false; } return true; } +fn optionalStringEqual(left: ?[]const u8, right: ?[]const u8) bool { + if (left == null or right == null) return left == null and right == null; + return std.mem.eql(u8, left.?, right.?); +} + fn emitResolutionDiff( ctx: *Context, profile_name: []const u8, @@ -351,6 +361,28 @@ pub fn installPackagesFromConfig( verify_store: bool, force_sync: bool, profile_name: ?[]const u8, +) !InstallCommandOutcome { + return installPackagesFromConfigWithPreview( + ctx, + pkg_names, + client, + reinstall, + verify_store, + force_sync, + profile_name, + false, + ); +} + +pub fn installPackagesFromConfigWithPreview( + ctx: *Context, + pkg_names: []const []const u8, + client: download.TransferClient, + reinstall: bool, + verify_store: bool, + force_sync: bool, + profile_name: ?[]const u8, + dry_run: bool, ) !InstallCommandOutcome { if (pkg_names.len == 0) { return ctx.fail(error.InvalidInput, "package", "no package names provided"); @@ -401,6 +433,7 @@ pub fn installPackagesFromConfig( ); resolver_requirements = owned_resolver_requirements.?; preferred_selections_state = try loadCurrentGenerationPreferences(ctx, profile_name.?); + preferred_selections_state.?.setInstallTargets(input_requirements.items); preferred_selections = preferred_selections_state.?.selections; } else { owned_resolver_requirements = try installRequirementsToResolverRequirements(ctx.allocator, input_requirements.items); @@ -410,6 +443,14 @@ pub fn installPackagesFromConfig( // Resolve var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, force_sync, true); defer resolution.deinit(); + if (requested_state) |*state| resolution.setRequestedIntent(state.packages.items); + + if (dry_run) { + emitResolutionDiff(ctx, profile_name.?, resolution.plan.sorted, .install); + emit.logLineSeverity(ctx, .install, .info, "dry run: no changes made"); + emit.phaseEnd(ctx, .install, true); + return .completed; + } // Realize const result_behavior = try realizeProfile(ctx, &resolution, client, reinstall, verify_store, profile_name, null, .install); @@ -490,6 +531,7 @@ pub fn installPackageSpecsFromConfig( // Resolve var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, force_sync, true); defer resolution.deinit(); + resolution.setRequirementIntent(input_requirements.items); // Realize const result_behavior = try realizeProfile(ctx, &resolution, client, reinstall, verify_store, profile_name, null, .install); @@ -501,6 +543,92 @@ pub fn installPackageSpecsFromConfig( }; } +pub fn upgradePackagesFromConfig( + ctx: *Context, + pkg_names: []const []const u8, + client: download.TransferClient, + verify_store: bool, + force_sync: bool, + profile_name: []const u8, + dry_run: bool, +) !InstallCommandOutcome { + const config = ctx.configuration orelse { + return ctx.fail(error.InvalidConfig, "configuration", "no configuration loaded"); + }; + config.validate() catch { + return ctx.fail(error.InvalidConfig, "configuration", "invalid repository configuration"); + }; + + const phase_name: []const u8 = if (pkg_names.len == 0) profile_name else if (pkg_names.len == 1) pkg_names[0] else "multiple"; + emit.phaseStart(ctx, .install, .{ .name = phase_name }); + errdefer emit.phaseEnd(ctx, .install, false); + + var repocaches = try repo_sources.createCaches(ctx, &config); + defer { + for (repocaches.items) |rc| { + rc.deinit(); + ctx.allocator.destroy(rc); + } + repocaches.deinit(ctx.allocator); + } + + var requested_state = try loadRequestedRootsState(ctx, profile_name); + defer requested_state.deinit(ctx.allocator); + if (requested_state.packages.items.len == 0) { + return ctx.fail(error.InvalidInput, profile_name, "profile has no requested packages to upgrade"); + } + + for (pkg_names) |name| { + var found = false; + for (requested_state.packages.items) |root| { + if (std.mem.eql(u8, root.name, name)) { + found = true; + break; + } + } + if (!found) return ctx.fail(error.InvalidInput, name, "package is not an explicitly requested root"); + } + + const resolver_requirements = try ctx.allocator.alloc(resolver.Requirement, requested_state.packages.items.len); + defer ctx.allocator.free(resolver_requirements); + for (requested_state.packages.items, 0..) |pkg, index| { + resolver_requirements[index] = .{ + .name = pkg.name, + .constraint_expr = pkg.constraint_expr, + }; + } + + var preferred_selections_state = try loadCurrentGenerationPreferences(ctx, profile_name); + defer preferred_selections_state.deinit(); + preferred_selections_state.setUpgradeTargets(requested_state.packages.items, pkg_names); + + var resolution = try resolveProfile( + ctx, + repocaches.items, + resolver_requirements, + preferred_selections_state.selections, + client, + force_sync, + true, + ); + defer resolution.deinit(); + resolution.setRequestedIntent(requested_state.packages.items); + + if (dry_run) { + emitResolutionDiff(ctx, profile_name, resolution.plan.sorted, .install); + emit.logLineSeverity(ctx, .install, .info, "dry run: no changes made"); + emit.phaseEnd(ctx, .install, true); + return .completed; + } + + const behavior = try realizeProfile(ctx, &resolution, client, false, verify_store, profile_name, null, .install); + emit.phaseEnd(ctx, .install, true); + return switch (behavior) { + .store_only_system_deferred => .store_only_system_activation_deferred, + .store_only_requested, .activate_profile => .completed, + }; +} + pub fn uninstallPackagesFromConfig( ctx: *Context, pkg_names: []const []const u8, @@ -532,7 +660,7 @@ pub fn uninstallPackagesFromConfig( var requested_state = try buildRequestedRootsAfterRemove(ctx, profile_name, pkg_names); defer requested_state.deinit(ctx.allocator); - if (requested_state.removed_count == 0) { + if (requested_state.removed_count == 0 and requested_state.packages.items.len == 0) { return "No requested packages matched"; } @@ -543,17 +671,29 @@ pub fn uninstallPackagesFromConfig( for (requested_state.packages.items, 0..) |pkg, i| { resolver_requirements[i] = .{ .name = pkg.name, - .constraint_expr = pkg.version, + .constraint_expr = pkg.constraint_expr, }; } var preferred_selections_state = try loadCurrentGenerationPreferences(ctx, profile_name); defer preferred_selections_state.deinit(); + preferred_selections_state.holdAll(); // Resolve var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections_state.selections, client, force_sync, false); defer resolution.deinit(); + if (requested_state.removed_count == 0) { + var matched_dependency = false; + for (pkg_names) |name| { + if (resolution.containsPackage(name)) { + matched_dependency = true; + break; + } + } + if (!matched_dependency) return "No requested packages matched"; + } + // Check if removed packages are still in the resolved set as transitive // deps. Loop to a fixed point: each cascade round only accounts for the // one removed_name it processed, so another requested removal that's @@ -624,9 +764,10 @@ pub fn uninstallPackagesFromConfig( const new_reqs = try ctx.allocator.alloc(resolver.Requirement, requested_state.packages.items.len); defer ctx.allocator.free(new_reqs); for (requested_state.packages.items, 0..) |pkg, ri| { - new_reqs[ri] = .{ .name = pkg.name, .constraint_expr = pkg.version }; + new_reqs[ri] = .{ .name = pkg.name, .constraint_expr = pkg.constraint_expr }; } resolution = try resolveProfile(ctx, repocaches.items, new_reqs, preferred_selections_state.selections, client, force_sync, false); + resolution.setRequestedIntent(requested_state.packages.items); } else { // All roots removed. Do NOT deinit `resolution` here - the // caller's `defer resolution.deinit()` (set up right after @@ -648,6 +789,7 @@ pub fn uninstallPackagesFromConfig( } // Realize + resolution.setRequestedIntent(requested_state.packages.items); if (dry_run) { emitResolutionDiff(ctx, profile_name, resolution.plan.sorted, .uninstall); emit.logLineSeverity(ctx, .uninstall, .info, "dry run: no changes made"); @@ -708,6 +850,7 @@ pub fn installPackagesToProfile( ); resolver_requirements = owned_resolver_requirements.?; preferred_selections_state = try loadCurrentGenerationPreferences(ctx, profile_name.?); + preferred_selections_state.?.setInstallTargets(input_requirements.items); preferred_selections = preferred_selections_state.?.selections; } else { owned_resolver_requirements = try installRequirementsToResolverRequirements(ctx.allocator, input_requirements.items); @@ -717,6 +860,7 @@ pub fn installPackagesToProfile( // Resolve var resolution = try resolveProfile(ctx, repocaches, resolver_requirements, preferred_selections, client, force_sync, true); defer resolution.deinit(); + if (requested_state) |*state| resolution.setRequestedIntent(state.packages.items); // Realize _ = try realizeProfile(ctx, &resolution, client, reinstall, verify_store, profile_name, target_profile_path, .install); @@ -735,17 +879,17 @@ const RequestedRootsState = struct { const RequestedPackage = struct { name: []const u8, - version: ?[]const u8, + constraint_expr: ?[]const u8, fn deinit(self: *RequestedPackage, allocator: std.mem.Allocator) void { allocator.free(self.name); - if (self.version) |v| allocator.free(v); + if (self.constraint_expr) |constraint| allocator.free(constraint); } }; const PreferredSelectionsState = struct { selections: []resolver.PreferredSelection, - manifest: ?generation.GenerationManifest, + manifest: ?generation.GenerationManifest = null, allocator: std.mem.Allocator, fn initEmpty(allocator: std.mem.Allocator) PreferredSelectionsState { @@ -756,6 +900,47 @@ const PreferredSelectionsState = struct { }; } + fn setUpgradeTargets( + self: *PreferredSelectionsState, + roots: []const RequestedPackage, + names: []const []const u8, + ) void { + for (self.selections) |*selection| { + selection.allow_upgrade = false; + if (names.len == 0) { + for (roots) |root| { + if (std.mem.eql(u8, selection.name, root.name)) { + selection.allow_upgrade = true; + break; + } + } + } else { + for (names) |name| { + if (std.mem.eql(u8, selection.name, name)) { + selection.allow_upgrade = true; + break; + } + } + } + } + } + + fn holdAll(self: *PreferredSelectionsState) void { + for (self.selections) |*selection| selection.allow_upgrade = false; + } + + fn setInstallTargets(self: *PreferredSelectionsState, requirements: []const InstallRootRequirement) void { + self.holdAll(); + for (self.selections) |*selection| { + for (requirements) |requirement| { + if (std.mem.eql(u8, selection.name, requirement.name)) { + selection.allow_upgrade = true; + break; + } + } + } + } + fn deinit(self: *PreferredSelectionsState) void { if (self.selections.len > 0) self.allocator.free(self.selections); if (self.manifest) |*manifest_data| manifest_data.deinit(); @@ -793,9 +978,18 @@ fn loadRequestedRootsState(ctx: *Context, profile_name: []const u8) !RequestedRo defer current.deinit(); for (current.packages.items) |pkg| { + if (!pkg.requested) continue; const name_copy = ctx.allocator.dupe(u8, pkg.name) catch return error.OutOfMemory; errdefer ctx.allocator.free(name_copy); - packages.append(ctx.allocator, .{ .name = name_copy, .version = null }) catch return error.OutOfMemory; + const constraint_copy = if (pkg.constraint_expr) |constraint| + ctx.allocator.dupe(u8, constraint) catch return error.OutOfMemory + else + null; + errdefer if (constraint_copy) |constraint| ctx.allocator.free(constraint); + packages.append(ctx.allocator, .{ + .name = name_copy, + .constraint_expr = constraint_copy, + }) catch return error.OutOfMemory; } } @@ -843,36 +1037,30 @@ fn buildRequestedRootsAfterAdd( } for (requirements) |req| { + if (!req.requested) continue; if (package_index.get(req.name)) |idx| { const existing = &state.packages.items[idx]; - if (existing.version) |old_version| { - if (req.constraint_expr) |new_version| { - if (!std.mem.eql(u8, old_version, new_version)) { - ctx.allocator.free(old_version); - existing.version = try ctx.allocator.dupe(u8, new_version); - state.changed = true; - } - } else { - ctx.allocator.free(old_version); - existing.version = null; + // Repeating an unconstrained install preserves an existing + // constraint. Supplying a new constraint deliberately replaces it. + if (req.intent_constraint) |new_constraint| { + if (existing.constraint_expr == null or !std.mem.eql(u8, existing.constraint_expr.?, new_constraint)) { + if (existing.constraint_expr) |old_constraint| ctx.allocator.free(old_constraint); + existing.constraint_expr = try ctx.allocator.dupe(u8, new_constraint); state.changed = true; } - } else if (req.constraint_expr) |new_version| { - existing.version = try ctx.allocator.dupe(u8, new_version); - state.changed = true; } continue; } const name_copy = try ctx.allocator.dupe(u8, req.name); - const version_copy = if (req.constraint_expr) |expr| + const constraint_copy = if (req.intent_constraint) |expr| try ctx.allocator.dupe(u8, expr) else null; - errdefer if (version_copy) |v| ctx.allocator.free(v); + errdefer if (constraint_copy) |constraint| ctx.allocator.free(constraint); try state.packages.append(ctx.allocator, .{ .name = name_copy, - .version = version_copy, + .constraint_expr = constraint_copy, }); try package_index.put(state.packages.items[state.packages.items.len - 1].name, state.packages.items.len - 1); state.changed = true; @@ -1025,6 +1213,10 @@ fn parseInstallRootRequirements( try requirements.append(allocator, .{ .name = name_copy, .constraint_expr = canonical_constraint, + .intent_constraint = if (canonical_constraint) |constraint| + try allocator.dupe(u8, constraint) + else + null, }); try requirement_index.put(requirements.items[requirements.items.len - 1].name, requirements.items.len - 1); } @@ -1072,6 +1264,13 @@ fn specsToInstallRequirements( .name = name_copy, .constraint_expr = constraint, .content_hash = content_hash_copy, + .requested = spec.requested, + .intent_constraint = if (spec.constraint_expr) |intent| + try allocator.dupe(u8, intent) + else if (spec.requested) + if (constraint) |resolved_constraint| try allocator.dupe(u8, resolved_constraint) else null + else + null, }); } @@ -1098,15 +1297,34 @@ fn buildProfileResolverRequirements( explicit_requirements: []const InstallRootRequirement, packages: []const RequestedPackage, ) ![]resolver.Requirement { - const out = try allocator.alloc(resolver.Requirement, packages.len); var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); + for (explicit_requirements) |requirement| try seen.put(requirement.name, {}); + + var count = explicit_requirements.len; + for (packages) |pkg| { + if (!seen.contains(pkg.name)) count += 1; + } + seen.clearRetainingCapacity(); + const out = try allocator.alloc(resolver.Requirement, count); var idx: usize = 0; for (explicit_requirements) |req| { + var constraint = req.constraint_expr; + // An unconstrained repeated install keeps the persisted root + // constraint. Exact profile specs continue to select their recorded + // content hash regardless of future intent. + if (req.requested and req.content_hash == null and req.intent_constraint == null) { + for (packages) |pkg| { + if (std.mem.eql(u8, pkg.name, req.name)) { + constraint = pkg.constraint_expr; + break; + } + } + } out[idx] = .{ .name = req.name, - .constraint_expr = req.constraint_expr, + .constraint_expr = constraint, .content_hash = req.content_hash, }; idx += 1; @@ -1117,12 +1335,12 @@ fn buildProfileResolverRequirements( if (seen.contains(pkg.name)) continue; out[idx] = .{ .name = pkg.name, - .constraint_expr = pkg.version, + .constraint_expr = pkg.constraint_expr, }; idx += 1; } - std.debug.assert(idx == packages.len); + std.debug.assert(idx == count); return out; } @@ -1149,6 +1367,19 @@ pub const ProfileResolution = struct { self.arena.deinit(); } + /// Mark the durable roots whose intent should be written into the next + /// profile generation. Constraints are borrowed until realization copies + /// them into owned PackageEntry values. + fn setRequestedIntent(self: *ProfileResolution, roots: []const RequestedPackage) void { + for (self.plan.resolution.packages) |*resolved| setResolvedIntent(resolved, roots); + for (self.plan.sorted) |*resolved| setResolvedIntent(resolved, roots); + } + + fn setRequirementIntent(self: *ProfileResolution, requirements: []const InstallRootRequirement) void { + for (self.plan.resolution.packages) |*resolved| setResolvedRequirementIntent(resolved, requirements); + for (self.plan.sorted) |*resolved| setResolvedRequirementIntent(resolved, requirements); + } + /// Check whether a package name appears in the resolved set. pub fn containsPackage(self: *const ProfileResolution, name: []const u8) bool { for (self.plan.sorted) |resolved| { @@ -1160,6 +1391,32 @@ pub const ProfileResolution = struct { } }; +fn setResolvedIntent(resolved: *resolver.ResolvedPackage, roots: []const RequestedPackage) void { + resolved.requested = false; + resolved.constraint_expr = null; + const name = resolved.pkg.name orelse return; + for (roots) |root| { + if (std.mem.eql(u8, root.name, name)) { + resolved.requested = true; + resolved.constraint_expr = root.constraint_expr; + return; + } + } +} + +fn setResolvedRequirementIntent(resolved: *resolver.ResolvedPackage, requirements: []const InstallRootRequirement) void { + resolved.requested = false; + resolved.constraint_expr = null; + const name = resolved.pkg.name orelse return; + for (requirements) |requirement| { + if (requirement.requested and std.mem.eql(u8, requirement.name, name)) { + resolved.requested = true; + resolved.constraint_expr = requirement.intent_constraint; + return; + } + } +} + /// Resolve a list of package-name tokens (each optionally carrying a /// version constraint, e.g. "openssl>=3.0" — the same syntax /// installPackagesToProfile accepts) into a ProfileResolution, without @@ -1415,11 +1672,16 @@ fn installResolvedPackages( .arch = try ctx.allocator.dupe(u8, resolved.pkg.arch.?), .store_path = store_path, .content_hash = try ctx.allocator.dupe(u8, resolved.pkg.content_hash), + .requested = resolved.requested, + .constraint_expr = if (resolved.constraint_expr) |constraint| + try ctx.allocator.dupe(u8, constraint) + else + null, }); continue; } - const pkg_info = try installSinglePackageToStore( + var pkg_info = try installSinglePackageToStore( ctx, &resolved.pkg, resolved.repocache, @@ -1427,6 +1689,11 @@ fn installResolvedPackages( reinstall, loaded_keys, ); + pkg_info.requested = resolved.requested; + pkg_info.constraint_expr = if (resolved.constraint_expr) |constraint| + try ctx.allocator.dupe(u8, constraint) + else + null; try installed_packages.append(ctx.allocator, pkg_info); } @@ -2746,11 +3013,11 @@ test "buildProfileResolverRequirements orders explicit roots before existing req } try requested_packages.append(allocator, .{ .name = try allocator.dupe(u8, "A"), - .version = null, + .constraint_expr = null, }); try requested_packages.append(allocator, .{ .name = try allocator.dupe(u8, "X"), - .version = null, + .constraint_expr = null, }); const requirements = try buildProfileResolverRequirements( @@ -2765,6 +3032,82 @@ test "buildProfileResolverRequirements orders explicit roots before existing req try std.testing.expectEqualStrings("A", requirements[1].name); } +test "upgrade targeting holds unrelated requested roots" { + var selections = [_]resolver.PreferredSelection{ + .{ .name = "app", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "a" }, + .{ .name = "tool", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "b" }, + .{ .name = "dependency", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "c" }, + }; + var state = PreferredSelectionsState{ + .allocator = std.testing.allocator, + .selections = selections[0..], + }; + const roots = [_]RequestedPackage{ + .{ .name = "app", .constraint_expr = ">=1" }, + .{ .name = "tool", .constraint_expr = null }, + }; + const targets = [_][]const u8{"app"}; + + state.setUpgradeTargets(&roots, &targets); + + try std.testing.expect(selections[0].allow_upgrade); + try std.testing.expect(!selections[1].allow_upgrade); + try std.testing.expect(!selections[2].allow_upgrade); +} + +test "unconstrained repeat install preserves the persisted root constraint" { + const allocator = std.testing.allocator; + const install_requirements = [_]InstallRootRequirement{ + .{ .name = "tool", .constraint_expr = null, .intent_constraint = null }, + }; + const requested_packages = [_]RequestedPackage{ + .{ .name = "tool", .constraint_expr = ">=2,<3" }, + }; + + const requirements = try buildProfileResolverRequirements(allocator, &install_requirements, &requested_packages); + defer allocator.free(requirements); + + try std.testing.expectEqual(@as(usize, 1), requirements.len); + try std.testing.expectEqualStrings(">=2,<3", requirements[0].constraint_expr.?); +} + +test "profile requirements keep exact dependency specs without promoting them" { + const allocator = std.testing.allocator; + const install_requirements = [_]InstallRootRequirement{ + .{ .name = "app", .content_hash = "app-hash", .requested = true, .intent_constraint = ">=1" }, + .{ .name = "lib", .content_hash = "lib-hash", .requested = false }, + }; + const requested_packages = [_]RequestedPackage{ + .{ .name = "app", .constraint_expr = ">=1" }, + }; + + const requirements = try buildProfileResolverRequirements(allocator, &install_requirements, &requested_packages); + defer allocator.free(requirements); + + try std.testing.expectEqual(@as(usize, 2), requirements.len); + try std.testing.expectEqualStrings("app-hash", requirements[0].content_hash.?); + try std.testing.expectEqualStrings("lib-hash", requirements[1].content_hash.?); +} + +test "install targeting holds unrelated current selections" { + var selections = [_]resolver.PreferredSelection{ + .{ .name = "app", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "a" }, + .{ .name = "tool", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "b" }, + .{ .name = "lib", .version = "1", .release = 1, .arch = "x86_64", .content_hash = "c" }, + }; + var state = PreferredSelectionsState{ + .allocator = std.testing.allocator, + .selections = selections[0..], + }; + const requirements = [_]InstallRootRequirement{.{ .name = "app" }}; + + state.setInstallTargets(&requirements); + + try std.testing.expect(selections[0].allow_upgrade); + try std.testing.expect(!selections[1].allow_upgrade); + try std.testing.expect(!selections[2].allow_upgrade); +} + /// Assert a profile entry exists and is backed by a real store object. /// /// Profile symlinks target the *logical* store path (/mere/store/...), which diff --git a/src/profile.zig b/src/profile.zig index 166b7b6..34c2c6b 100644 --- a/src/profile.zig +++ b/src/profile.zig @@ -865,13 +865,15 @@ fn buildProfileManifest( manifest.selected_profile = allocator.dupe(u8, selected_profile) catch return ProfileError.OutOfMemory; for (packages) |pkg| { - manifest.addPackage( + manifest.addPackageWithIntent( pkg.name, pkg.version, pkg.release, pkg.arch, pkg.store_path, pkg.content_hash, + pkg.requested, + pkg.constraint_expr, ) catch return ProfileError.OutOfMemory; } diff --git a/src/resolver.zig b/src/resolver.zig index bf57567..566f105 100644 --- a/src/resolver.zig +++ b/src/resolver.zig @@ -24,6 +24,8 @@ pub const ResolvedPackage = struct { scc_id: usize, from_pin: bool = false, pinned_store_path: ?[]const u8 = null, + requested: bool = false, + constraint_expr: ?[]const u8 = null, /// Package names this package directly depends on (extracted from the dependency graph). dependency_names: []const []const u8 = &.{}, }; @@ -60,8 +62,16 @@ pub const PreferredSelection = struct { release: u32, arch: []const u8, content_hash: []const u8, + allow_upgrade: bool = true, }; +fn preferredAllowsUpgrade(name: []const u8, selections: []const PreferredSelection) bool { + for (selections) |selection| { + if (std.mem.eql(u8, selection.name, name)) return selection.allow_upgrade; + } + return true; +} + const GraphNode = struct { pkg_key: []const u8, pkg: ?package.Package, @@ -1300,10 +1310,10 @@ fn resolveRequirement( const candidate_label = try formatCandidateLabel(allocator, candidate.*); defer allocator.free(candidate_label); - // Skip preferred candidate if a newer version exists in the repo. - // Preferred selections stabilize dependency resolution but should - // not hold back upgrades when a newer version is available. - skip_preferred: { + // A caller can hold an existing selection while adding or + // removing unrelated roots. Explicit upgrade operations opt the + // selected roots back into normal newest-candidate ranking. + if (preferredAllowsUpgrade(candidate.pkg_name, preferred_selections)) skip_preferred: { var all_candidates = collectAndRankCandidates( ctx, candidate.pkg_name,