From e5128686890ef4389c9ea56a7cc7a68a5aada179 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sun, 23 Aug 2026 10:51:44 -0400 Subject: [PATCH 1/2] Make repository refresh policy explicit Repository-dependent commands now share one visible refresh contract instead of resolving against metadata according to command-specific behavior. Automatic interval refresh remains the rolling-release default, while forced and suppressed refresh are explicit and standalone sync is strict. Verified cached metadata remains available when ordinary refresh attempts fail, search can preserve partial results, and per-repository publication prevents concurrent refreshes from colliding. The sync-ttl spelling remains readable for compatibility, but sync-interval is the durable name because elapsed time triggers refresh rather than invalidating a cache. --- docs/design/roadmap.md | 5 +- docs/design/specification-details.md | 15 +++- src/cli/commands/install.zig | 16 ++-- src/cli/commands/search.zig | 16 +++- src/cli/commands/sync.zig | 117 ++++++++++++++++++++++++++ src/cli/commands/uninstall.zig | 16 ++-- src/cli/commands/upgrade.zig | 12 ++- src/cli/main.zig | 6 ++ src/config.zig | 109 ++++++++++++++++++------- src/install.zig | 23 +++--- src/kdl_schema.zig | 2 + src/mere.zig | 1 + src/repocache.zig | 118 ++++++++++++++++++++++----- src/search.zig | 43 ++++++---- 14 files changed, 405 insertions(+), 94 deletions(-) create mode 100644 src/cli/commands/sync.zig diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 10b0bf9..c8c2d8b 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -49,7 +49,7 @@ Format changes (detailed below): Capability removals: -- **Remove ambient state.** Working-directory profile discovery and TTL-based metadata staleness. +- **Remove ambient state.** Working-directory profile discovery still makes one command select a profile for reasons not visible in the command. Repository refresh is no longer part of this gap: repository-dependent operations use an explicit automatic refresh policy described below. - **Stop treating every installed package as a resolution root.** `mere install` moves every installed package, because nothing records which ones were asked for. Narrowing install is the removal; the additive half — recording intent, and a `mere upgrade` that moves the world deliberately — lands first. Detailed below. Closing the remaining conformance gaps: @@ -102,7 +102,8 @@ Existing generations have no `requested`, so everything in them reads as request ### Interface and automation - **Interface contract and self-description.** Done: the contract section, and `mere describe`. Shipped early so the document's shape gets contact with real consumers while `schema_version: 1` is still free to revise. -- **Remove ambient state.** Working-directory profile discovery and TTL-based metadata staleness both make one command mean different things on different runs for reasons not visible in the command. Where the convenience is worth keeping for interactive use, it should at least be resolvable to an explicit form. +- **Repository refresh policy.** Repository-dependent operations automatically refresh signed metadata when a configured interval has elapsed, so rolling-release users do not need a separate update ritual. `--sync` forces an attempt while retaining verified-cache fallback for ordinary operations; `--no-sync` suppresses metadata refresh without suppressing package archive downloads. `mere sync [repositories...]` is the strict explicit form: it refreshes all enabled or named repositories regardless of interval, preserves the previous verified cache on failure, and reports failure rather than treating the stale cache as a successful refresh. Search uses the same policy and may return partial results from usable repositories. The canonical setting is `sync-interval`; `sync-ttl` remains a read-only compatibility alias because expiry triggers an attempt rather than invalidating cached metadata. Refresh publication is serialized per repository, and future last-sync timestamps never make a cache indefinitely fresh. +- **Remove ambient profile selection.** Working-directory profile discovery makes one command mean different things on different runs for a reason not visible in the command. Where the convenience is worth keeping for interactive use, it should at least be resolvable to an explicit form. - **Attestation.** A signed, append-only record of what was done — who, which plan, which generation before and after. - **Plans as artifacts.** Separate deciding from doing: resolve an operation into a content-addressed plan, then apply the plan by its hash. Gives idempotent retry, a reviewable diff before anything mutates, and a natural identity for crash recovery. - **Concurrency.** Dropping the per-root lock for store admission waits for concurrent-mutator tests. Conditional activation is not scheduled: without a concrete split between observing state and applying a previously derived decision, an expected-generation argument merely asks callers to repeat state Mere can already read. Reconsider it if a real split-phase consumer or observed lost update establishes the need. diff --git a/docs/design/specification-details.md b/docs/design/specification-details.md index d3b194f..ef9b0f0 100644 --- a/docs/design/specification-details.md +++ b/docs/design/specification-details.md @@ -34,6 +34,19 @@ Mere is driven by people at a terminal, by scripts, by CI, and by automated call Because a consumer may not remember its own past and may not be the only one running, an invocation SHOULD depend only on its arguments and on state it can observe. Behaviour that varies with ambient context — the working directory, elapsed time since some earlier action, what a previous invocation happened to leave behind — makes the same command mean different things on different runs for reasons not visible in the command. Where such conveniences exist for interactive use, they SHOULD be resolvable to an explicit form. +### Repository metadata refresh + +Repository-dependent operations use an availability-first automatic refresh policy suitable for a rolling distribution: + +- By default, a remote repository is refreshed when its last successful refresh is older than `sync-interval`. A cache inside the interval is reused quietly. +- `--sync` forces a refresh attempt regardless of the interval. On ordinary operations, a failed attempt MAY fall back to the prior verified cache and MUST warn with its age. +- `--no-sync` performs no remote metadata refresh. It MUST still verify cached metadata before use and MUST fail or skip that repository when no verified cache exists. It does not prohibit downloading a selected package archive. +- `--sync` and `--no-sync` are mutually exclusive invocation errors. +- `mere sync [repositories...]` is strict: it refreshes all enabled repositories, or only the named enabled repositories, regardless of interval. Local repositories are signature-verified. Any requested repository that cannot be refreshed or found makes the command fail, while the previous verified cache remains intact. +- Search follows the same policy. It MAY return partial results from repositories with usable verified metadata, MUST warn about unavailable repositories, and MUST fail when none can be searched. + +`sync-interval` is expressed in seconds and MAY be configured globally or per repository. `sync-ttl` remains accepted as a read-only compatibility alias; Mere writes only `sync-interval`, because reaching the interval triggers an attempt rather than invalidating cached metadata. A last-refresh timestamp in the future MUST NOT suppress refresh. Refresh check, download, verification, and publication MUST serialize per repository so unrelated repositories do not block one another. + ### Self-description `mere describe` writes a machine-readable description of the command surface to stdout, so a consumer can learn the interface at runtime instead of being told about it out of band. It reports the program name, the running version, global flags, and the command tree with each command's group, positional arguments, and flags — including each flag's type, short form, value name, default, and whether it is required. @@ -72,7 +85,7 @@ Requirements: This contract is a design commitment, and Mere does not yet meet all of it. Known gaps, recorded here so they are not mistaken for intent: -- **Ambient state.** `mere shell` resolves a profile from the working directory (§15.12), and repository sync is skipped on a TTL, so `mere install` may resolve against different metadata depending only on elapsed time. Neither is expressible as an explicit argument. +- **Ambient profile selection.** `mere shell` still resolves a profile from the working directory (§15.12), so the selected profile is not always visible in the invocation. Repository refresh is explicit: repository-dependent commands automatically refresh on a configured interval and expose `--sync` and `--no-sync`; `mere sync` is the strict standalone operation. - **Concurrency granularity.** All mutating operations serialize on one exclusive lock per root. This is correct but coarse: unrelated work blocks, and there is no way to express "apply only if the profile is still at generation N". - **Self-description.** The command surface is machine-readable via `mere describe`. The schemas of on-disk artifacts are not, and the gap is uneven. A generation's `profile.kdl` (§6) is KDL, so a consumer can read which packages a generation selected and at which content hashes without being told anything. A package manifest (§17) and a realization manifest are hand-rolled binary encodings behind `MEREMFST` and `MERERLZ1` headers, and neither can be read without being told its shape out of band. - **No record of action.** Mere keeps no account of what it did, so a consumer cannot reconstruct its own prior operations, and an operator cannot audit another consumer's. diff --git a/src/cli/commands/install.zig b/src/cli/commands/install.zig index da7d0d7..9d4f782 100644 --- a/src/cli/commands/install.zig +++ b/src/cli/commands/install.zig @@ -3,6 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); +const sync_command = @import("sync.zig"); const MereError = types.MereError; /// Install command metadata @@ -32,7 +33,12 @@ const install_meta = command.CommandMeta{ }, .{ .name = "sync", - .description = "Force repository sync even if cache is fresh", + .description = "Refresh repository metadata now, retaining verified-cache fallback", + .flag_type = .bool, + }, + .{ + .name = "no-sync", + .description = "Use verified cached repository metadata without refreshing it", .flag_type = .bool, }, .{ @@ -52,7 +58,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty 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 sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; const dry_run = args.getBool("dry-run"); // Set initial diagnostic context - the subject is the package being installed @@ -64,7 +70,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, dry_run) catch |err| { + const success_message = performInstallation(ctx, package_names, profile_name, verify_store, sync_policy, dry_run) catch |err| { return try command.errorResult(ctx, err, null); }; @@ -82,7 +88,7 @@ fn performInstallation( package_names: []const []const u8, profile_name: []const u8, verify_store: bool, - force_sync: bool, + sync_policy: mere.repocache.SyncPolicy, dry_run: bool, ) !?[]const u8 { // Ensure configuration is loaded (no logging - errors propagate) @@ -94,7 +100,7 @@ fn performInstallation( const client = curl_client.client(); // Perform installation (no logging - errors propagate) - const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, force_sync, profile_name, dry_run); + const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, sync_policy, profile_name, dry_run); return switch (outcome) { .completed => null, .store_only_system_activation_deferred => null, diff --git a/src/cli/commands/search.zig b/src/cli/commands/search.zig index a27de62..89bec32 100644 --- a/src/cli/commands/search.zig +++ b/src/cli/commands/search.zig @@ -2,6 +2,7 @@ const std = @import("std"); const mere = @import("mere"); const types = @import("../types.zig"); const command = @import("../command.zig"); +const sync_command = @import("sync.zig"); const MereError = mere.errors.MereError; const ui = mere.ui; const emit = ui.emit; @@ -18,6 +19,18 @@ const search_meta = command.CommandMeta{ .required = true, }, }, + .flags = &[_]types.Flag{ + .{ + .name = "sync", + .description = "Refresh repository metadata now, retaining verified-cache fallback", + .flag_type = .bool, + }, + .{ + .name = "no-sync", + .description = "Use verified cached repository metadata without refreshing it", + .flag_type = .bool, + }, + }, }; fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { @@ -26,12 +39,13 @@ fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ } const term = args.positional[0]; + const sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; var curl_client = try mere.download.CurlTransferClient.init(ctx, command.user_agent); defer mere.download.CurlTransferClient.cleanupFn(ctx, curl_client); const client = curl_client.client(); - var results = mere.search.searchPackages(ctx, term, client) catch |err| { + var results = mere.search.searchPackagesWithPolicy(ctx, term, client, sync_policy) catch |err| { const user_message = mere.errors.getUserFriendlyMessage(err); return types.CommandResult{ .success = false, diff --git a/src/cli/commands/sync.zig b/src/cli/commands/sync.zig new file mode 100644 index 0000000..7eb855b --- /dev/null +++ b/src/cli/commands/sync.zig @@ -0,0 +1,117 @@ +const std = @import("std"); +const mere = @import("mere"); +const types = @import("../types.zig"); +const command = @import("../command.zig"); +const MereError = types.MereError; + +const sync_meta = command.CommandMeta{ + .group = "Package Management", + .order = 21, + .name = "sync", + .description = "Refresh and verify repository metadata", + .args = &[_]types.Arg{ + .{ + .name = "repository", + .description = "Enabled repository name(s); omit to refresh all", + .required = false, + }, + }, +}; + +fn selected(names: []const []const u8, candidate: []const u8) bool { + if (names.len == 0) return true; + for (names) |name| { + if (std.mem.eql(u8, name, candidate)) return true; + } + return false; +} + +pub fn repositorySyncPolicy(args: *const types.ParsedArgs) MereError!mere.repocache.SyncPolicy { + return repositorySyncPolicyFromFlags(args.getBool("sync"), args.getBool("no-sync")); +} + +fn repositorySyncPolicyFromFlags(force: bool, disabled: bool) MereError!mere.repocache.SyncPolicy { + if (force and disabled) return MereError.InvalidInput; + if (force) return .force; + if (disabled) return .no_sync; + return .automatic; +} + +test "repository sync flags are explicit and mutually exclusive" { + try std.testing.expectEqual(mere.repocache.SyncPolicy.automatic, try repositorySyncPolicyFromFlags(false, false)); + try std.testing.expectEqual(mere.repocache.SyncPolicy.force, try repositorySyncPolicyFromFlags(true, false)); + try std.testing.expectEqual(mere.repocache.SyncPolicy.no_sync, try repositorySyncPolicyFromFlags(false, true)); + try std.testing.expectError(MereError.InvalidInput, repositorySyncPolicyFromFlags(true, true)); +} + +fn handleSync(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { + const config = ctx.getConfig() catch |err| return try command.errorResult(ctx, err, null); + var caches = mere.repo_sources.createCaches(ctx, config) catch |err| return try command.errorResult(ctx, err, null); + defer { + for (caches.items) |cache| { + cache.deinit(); + ctx.allocator.destroy(cache); + } + caches.deinit(ctx.allocator); + } + + var loaded_keys = mere.sign.loadAllKeys(ctx) catch |err| return try command.errorResult(ctx, err, null); + defer { + for (loaded_keys.items) |*key| key.deinit(ctx.allocator); + loaded_keys.deinit(ctx.allocator); + } + + var curl_client = mere.download.CurlTransferClient.init(ctx, command.user_agent) catch |err| { + return try command.errorResult(ctx, err, null); + }; + defer mere.download.CurlTransferClient.cleanupFn(ctx, curl_client); + + var matched = try ctx.allocator.alloc(bool, args.positional.len); + defer ctx.allocator.free(matched); + @memset(matched, false); + + var failures: usize = 0; + var attempted: usize = 0; + for (caches.items) |cache| { + if (!selected(args.positional, cache.name)) continue; + attempted += 1; + for (args.positional, 0..) |name, index| { + if (std.mem.eql(u8, name, cache.name)) matched[index] = true; + } + + cache.sync(curl_client.client(), .{ + .force = true, + .interval_seconds = cache.sync_interval_seconds, + .timeout_seconds = cache.sync_timeout_seconds, + .allow_stale_fallback = false, + }, loaded_keys.items) catch |err| { + failures += 1; + mere.ui.emit.logFmtSeverity(ctx, null, .err, "failed to refresh repository {s}: {s}", .{ cache.name, @errorName(err) }); + }; + } + + for (args.positional, 0..) |name, index| { + if (!matched[index]) { + failures += 1; + mere.ui.emit.logFmtSeverity(ctx, null, .err, "enabled repository not found: {s}", .{name}); + } + } + + if (attempted == 0 and args.positional.len == 0) { + return .{ .success = true, .message = "no enabled repositories configured" }; + } + if (failures > 0) { + return .{ + .success = false, + .exit_code = 1, + .message = try std.fmt.allocPrint(ctx.allocator, "repository synchronization failed for {d} requested source(s)", .{failures}), + }; + } + return .{ .success = true }; +} + +pub fn createCommand(allocator: std.mem.Allocator) !*command.Command { + const cmd = try allocator.create(command.Command); + cmd.* = command.Command.init(allocator, sync_meta, handleSync); + return cmd; +} diff --git a/src/cli/commands/uninstall.zig b/src/cli/commands/uninstall.zig index 1e32e81..7c9d8e7 100644 --- a/src/cli/commands/uninstall.zig +++ b/src/cli/commands/uninstall.zig @@ -3,6 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); +const sync_command = @import("sync.zig"); const MereError = types.MereError; const uninstall_meta = command.CommandMeta{ @@ -41,7 +42,12 @@ const uninstall_meta = command.CommandMeta{ }, .{ .name = "sync", - .description = "Force repository sync even if cache is fresh", + .description = "Refresh repository metadata now, retaining verified-cache fallback", + .flag_type = .bool, + }, + .{ + .name = "no-sync", + .description = "Use verified cached repository metadata without refreshing it", .flag_type = .bool, }, }, @@ -55,7 +61,7 @@ fn handleUninstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError! 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 sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; const cascade = args.getBool("cascade"); const dry_run = args.getBool("dry-run"); @@ -66,7 +72,7 @@ fn handleUninstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError! if (try command.acquireStoreLockOrResult(ctx)) |result| return result; defer ctx.releaseStoreLock(); - const result = performUninstall(ctx, package_names, profile_name, verify_store, force_sync, cascade, dry_run) catch |err| { + const result = performUninstall(ctx, package_names, profile_name, verify_store, sync_policy, cascade, dry_run) catch |err| { return try command.errorResult(ctx, err, null); }; @@ -89,7 +95,7 @@ fn performUninstall( package_names: []const []const u8, profile_name: []const u8, verify_store: bool, - force_sync: bool, + sync_policy: mere.repocache.SyncPolicy, cascade: bool, dry_run: bool, ) !?[]const u8 { @@ -104,7 +110,7 @@ fn performUninstall( package_names, client, verify_store, - force_sync, + sync_policy, profile_name, cascade, dry_run, diff --git a/src/cli/commands/upgrade.zig b/src/cli/commands/upgrade.zig index 42840f4..9f3658a 100644 --- a/src/cli/commands/upgrade.zig +++ b/src/cli/commands/upgrade.zig @@ -3,6 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); +const sync_command = @import("sync.zig"); const MereError = types.MereError; const upgrade_meta = command.CommandMeta{ @@ -31,7 +32,12 @@ const upgrade_meta = command.CommandMeta{ }, .{ .name = "sync", - .description = "Force repository sync even if cache is fresh", + .description = "Refresh repository metadata now, retaining verified-cache fallback", + .flag_type = .bool, + }, + .{ + .name = "no-sync", + .description = "Use verified cached repository metadata without refreshing it", .flag_type = .bool, }, .{ @@ -46,7 +52,7 @@ fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty 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 sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; const dry_run = args.getBool("dry-run"); ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject( @@ -67,7 +73,7 @@ fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty package_names, curl_client.client(), verify_store, - force_sync, + sync_policy, profile_name, dry_run, ) catch |err| return try command.errorResult(ctx, err, null); diff --git a/src/cli/main.zig b/src/cli/main.zig index bbfcc14..571bb76 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -15,6 +15,7 @@ const etc_cmd = @import("commands/etc.zig"); const shell_cmd = @import("commands/shell.zig"); const profile_cmd = @import("commands/profile.zig"); const search_cmd = @import("commands/search.zig"); +const sync_cmd = @import("commands/sync.zig"); const store_cmd = @import("commands/store.zig"); const service_cmd = @import("commands/service.zig"); const describe = @import("describe.zig"); @@ -180,6 +181,11 @@ fn registerCommands(allocator: std.mem.Allocator, cli_system: *cli.CLI, root_com try cli_system.registerCommand(profile_command); try root_command.addSubcommand(profile_command); + // Create repository sync command + const sync_command = try sync_cmd.createCommand(allocator); + try cli_system.registerCommand(sync_command); + try root_command.addSubcommand(sync_command); + // Create search command const search_command = try search_cmd.createCommand(allocator); try cli_system.registerCommand(search_command); diff --git a/src/config.zig b/src/config.zig index 52ae84a..c69c22b 100644 --- a/src/config.zig +++ b/src/config.zig @@ -6,7 +6,7 @@ const download = @import("download.zig"); const kdl = @import("kdl.zig"); const kdl_schema = @import("kdl_schema.zig"); -const default_sync_ttl_seconds: u64 = 15 * 60; +const default_sync_interval_seconds: u64 = 15 * 60; const default_sync_timeout_seconds: u32 = 30; pub const InitProvider = enum { @@ -37,8 +37,8 @@ pub const RepoConfig = struct { priority: u8 = 100, /// Whether this repository is enabled (disabled repos are skipped during install/sync) enabled: bool = true, - /// Sync TTL in seconds (remote repos only; local repos ignore) - sync_ttl_seconds: u64 = default_sync_ttl_seconds, + /// Automatic refresh interval in seconds (remote repos only; local repos ignore) + sync_interval_seconds: u64 = default_sync_interval_seconds, /// Sync timeout in seconds (remote repos only; local repos ignore) sync_timeout_seconds: u32 = default_sync_timeout_seconds, @@ -77,7 +77,7 @@ pub const RepoConfig = struct { .url = url_copy, .priority = self.priority, .enabled = self.enabled, - .sync_ttl_seconds = self.sync_ttl_seconds, + .sync_interval_seconds = self.sync_interval_seconds, .sync_timeout_seconds = self.sync_timeout_seconds, .trusted_fingerprints = fingerprints_copy, }; @@ -89,7 +89,7 @@ pub const RepoConfig = struct { ctx: *Context, node: *const kdl.Node, allocator: std.mem.Allocator, - default_ttl_seconds: u64, + default_interval_seconds: u64, default_timeout_seconds: u32, ) !RepoConfig { ctx.debug("parsing repository from kdl", .{}); @@ -121,15 +121,21 @@ pub const RepoConfig = struct { // Get optional enabled flag (default true) const enabled = node.getChildBool("enabled") orelse true; - // Get optional sync ttl (seconds) - const sync_ttl_seconds: u64 = blk: { - if (node.getChildInt("sync-ttl")) |ttl| { - if (ttl < 0) { - return ctx.fail(error.InvalidConfig, name, "sync-ttl must be non-negative"); + // `sync-ttl` remains a read-only compatibility alias. Both names at + // once are ambiguous and therefore rejected. + const interval_value = node.getChildInt("sync-interval"); + const ttl_alias_value = node.getChildInt("sync-ttl"); + if (interval_value != null and ttl_alias_value != null) { + return ctx.fail(error.InvalidConfig, name, "sync-interval and sync-ttl cannot both be set"); + } + const sync_interval_seconds: u64 = blk: { + if (interval_value orelse ttl_alias_value) |interval| { + if (interval < 0) { + return ctx.fail(error.InvalidConfig, name, "sync-interval must be non-negative"); } - break :blk @intCast(ttl); + break :blk @intCast(interval); } - break :blk default_ttl_seconds; + break :blk default_interval_seconds; }; // Get optional sync timeout (seconds) @@ -195,7 +201,7 @@ pub const RepoConfig = struct { .url = url_copy, .priority = priority, .enabled = enabled, - .sync_ttl_seconds = sync_ttl_seconds, + .sync_interval_seconds = sync_interval_seconds, .sync_timeout_seconds = sync_timeout_seconds, .trusted_fingerprints = fingerprints, }; @@ -222,8 +228,8 @@ pub const Config = struct { ctx: *Context, alloc: std.mem.Allocator, color: ?bool = null, - /// Default sync TTL for remote repos (seconds) - sync_ttl_seconds: u64 = default_sync_ttl_seconds, + /// Default automatic refresh interval for remote repos (seconds) + sync_interval_seconds: u64 = default_sync_interval_seconds, /// Default sync timeout for remote repos (seconds) sync_timeout_seconds: u32 = default_sync_timeout_seconds, /// Init/service provider. Null means use the default provider. @@ -238,7 +244,7 @@ pub const Config = struct { .ctx = ctx, .alloc = alloc, .color = null, - .sync_ttl_seconds = default_sync_ttl_seconds, + .sync_interval_seconds = default_sync_interval_seconds, .sync_timeout_seconds = default_sync_timeout_seconds, .init_provider = null, }; @@ -308,11 +314,16 @@ pub const Config = struct { if (node.getChildBool("color")) |color| { config.color = color; } - if (node.getChildInt("sync-ttl")) |ttl| { - if (ttl < 0) { - return ctx.fail(error.InvalidConfig, "settings", "sync-ttl must be non-negative"); + const interval_value = node.getChildInt("sync-interval"); + const ttl_alias_value = node.getChildInt("sync-ttl"); + if (interval_value != null and ttl_alias_value != null) { + return ctx.fail(error.InvalidConfig, "settings", "sync-interval and sync-ttl cannot both be set"); + } + if (interval_value orelse ttl_alias_value) |interval| { + if (interval < 0) { + return ctx.fail(error.InvalidConfig, "settings", "sync-interval must be non-negative"); } - config.sync_ttl_seconds = @intCast(ttl); + config.sync_interval_seconds = @intCast(interval); } if (node.getChildInt("sync-timeout")) |timeout| { if (timeout < 0 or timeout > std.math.maxInt(u32)) { @@ -335,7 +346,7 @@ pub const Config = struct { ctx, node, allocator, - config.sync_ttl_seconds, + config.sync_interval_seconds, config.sync_timeout_seconds, ); try config.repos.append(allocator, repo); @@ -355,7 +366,7 @@ pub const Config = struct { out.writeAll("// Mere Linux configuration\n\n") catch return error.OutOfMemory; - if (self.color != null or self.sync_ttl_seconds != default_sync_ttl_seconds or self.sync_timeout_seconds != default_sync_timeout_seconds or self.init_provider != null) { + if (self.color != null or self.sync_interval_seconds != default_sync_interval_seconds or self.sync_timeout_seconds != default_sync_timeout_seconds or self.init_provider != null) { out.writeAll("settings {\n") catch return error.OutOfMemory; if (self.color) |color| { out.print(" color {}\n", .{color}) catch return error.OutOfMemory; @@ -363,8 +374,8 @@ pub const Config = struct { if (self.init_provider) |provider| { out.print(" init-provider \"{s}\"\n", .{provider.label()}) catch return error.OutOfMemory; } - if (self.sync_ttl_seconds != default_sync_ttl_seconds) { - out.print(" sync-ttl {d}\n", .{self.sync_ttl_seconds}) catch return error.OutOfMemory; + if (self.sync_interval_seconds != default_sync_interval_seconds) { + out.print(" sync-interval {d}\n", .{self.sync_interval_seconds}) catch return error.OutOfMemory; } if (self.sync_timeout_seconds != default_sync_timeout_seconds) { out.print(" sync-timeout {d}\n", .{self.sync_timeout_seconds}) catch return error.OutOfMemory; @@ -384,8 +395,8 @@ pub const Config = struct { out.writeAll("\n") catch return error.OutOfMemory; } out.print(" priority {d}\n", .{repo.priority}) catch return error.OutOfMemory; - if (repo.sync_ttl_seconds != self.sync_ttl_seconds) { - out.print(" sync-ttl {d}\n", .{repo.sync_ttl_seconds}) catch return error.OutOfMemory; + if (repo.sync_interval_seconds != self.sync_interval_seconds) { + out.print(" sync-interval {d}\n", .{repo.sync_interval_seconds}) catch return error.OutOfMemory; } if (repo.sync_timeout_seconds != self.sync_timeout_seconds) { out.print(" sync-timeout {d}\n", .{repo.sync_timeout_seconds}) catch return error.OutOfMemory; @@ -453,7 +464,7 @@ pub const Config = struct { .url = url_copy, .priority = src.priority, .enabled = src.enabled, - .sync_ttl_seconds = src.sync_ttl_seconds, + .sync_interval_seconds = src.sync_interval_seconds, .sync_timeout_seconds = src.sync_timeout_seconds, .trusted_fingerprints = fingerprints_copy, }; @@ -485,7 +496,7 @@ pub const Config = struct { pub fn deepCopy(self: *const Config, allocator: std.mem.Allocator) !Config { var new_config = Config.init(self.ctx, allocator); new_config.color = self.color; - new_config.sync_ttl_seconds = self.sync_ttl_seconds; + new_config.sync_interval_seconds = self.sync_interval_seconds; new_config.sync_timeout_seconds = self.sync_timeout_seconds; new_config.init_provider = self.init_provider; errdefer new_config.deinit(); @@ -1382,3 +1393,45 @@ test "KDL config normalizes trusted-fingerprints to lowercase" { config.repos.items[0].trusted_fingerprints.items[0], ); } + +test "sync-ttl remains a read alias while config writes sync-interval" { + var test_env = try @import("test_helpers.zig").createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + var config = try Config.fromKdl(&test_env.ctx, + \\settings { + \\ sync-ttl 42 + \\} + \\repo "core" { + \\ url "https://example.com/core" + \\ sync-ttl 7 + \\} + , test_env.ctx.allocator); + defer config.deinit(); + + try std.testing.expectEqual(@as(u64, 42), config.sync_interval_seconds); + try std.testing.expectEqual(@as(u64, 7), config.repos.items[0].sync_interval_seconds); + const encoded = try config.toKdl(); + defer config.alloc.free(encoded); + try std.testing.expect(std.mem.indexOf(u8, encoded, "sync-interval 42") != null); + try std.testing.expect(std.mem.indexOf(u8, encoded, "sync-interval 7") != null); + try std.testing.expect(std.mem.indexOf(u8, encoded, "sync-ttl") == null); +} + +test "config rejects sync interval and ttl alias together" { + var test_env = try @import("test_helpers.zig").createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + + try std.testing.expectError(error.InvalidConfig, Config.fromKdl(&test_env.ctx, + \\settings { + \\ sync-interval 60 + \\ sync-ttl 60 + \\} + , test_env.ctx.allocator)); +} diff --git a/src/install.zig b/src/install.zig index 74a3757..2458ce7 100644 --- a/src/install.zig +++ b/src/install.zig @@ -11,6 +11,7 @@ const repodb = @import("repodb.zig"); const c = repodb.c; const sign = @import("sign.zig"); const repocache_mod = @import("repocache.zig"); +const SyncPolicy = repocache_mod.SyncPolicy; const RepoCache = repocache_mod.RepoCache; const config_mod = @import("config.zig"); const repo_sources = @import("repo_sources.zig"); @@ -368,7 +369,7 @@ pub fn installPackagesFromConfig( client, reinstall, verify_store, - force_sync, + if (force_sync) SyncPolicy.force else .automatic, profile_name, false, ); @@ -380,7 +381,7 @@ pub fn installPackagesFromConfigWithPreview( client: download.TransferClient, reinstall: bool, verify_store: bool, - force_sync: bool, + sync_policy: SyncPolicy, profile_name: ?[]const u8, dry_run: bool, ) !InstallCommandOutcome { @@ -441,7 +442,7 @@ pub fn installPackagesFromConfigWithPreview( } // Resolve - var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, force_sync, true); + var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, sync_policy == .force, sync_policy != .no_sync); defer resolution.deinit(); if (requested_state) |*state| resolution.setRequestedIntent(state.packages.items); @@ -548,7 +549,7 @@ pub fn upgradePackagesFromConfig( pkg_names: []const []const u8, client: download.TransferClient, verify_store: bool, - force_sync: bool, + sync_policy: SyncPolicy, profile_name: []const u8, dry_run: bool, ) !InstallCommandOutcome { @@ -608,8 +609,8 @@ pub fn upgradePackagesFromConfig( resolver_requirements, preferred_selections_state.selections, client, - force_sync, - true, + sync_policy == .force, + sync_policy != .no_sync, ); defer resolution.deinit(); resolution.setRequestedIntent(requested_state.packages.items); @@ -634,7 +635,7 @@ pub fn uninstallPackagesFromConfig( pkg_names: []const []const u8, client: download.TransferClient, verify_store: bool, - force_sync: bool, + sync_policy: SyncPolicy, profile_name: []const u8, cascade: bool, dry_run: bool, @@ -680,7 +681,7 @@ pub fn uninstallPackagesFromConfig( preferred_selections_state.holdAll(); // Resolve - var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections_state.selections, client, force_sync, false); + var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections_state.selections, client, sync_policy == .force, sync_policy != .no_sync); defer resolution.deinit(); if (requested_state.removed_count == 0) { @@ -766,7 +767,7 @@ pub fn uninstallPackagesFromConfig( for (requested_state.packages.items, 0..) |pkg, ri| { 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 = try resolveProfile(ctx, repocaches.items, new_reqs, preferred_selections_state.selections, client, sync_policy == .force, sync_policy != .no_sync); resolution.setRequestedIntent(requested_state.packages.items); } else { // All roots removed. Do NOT deinit `resolution` here - the @@ -1563,7 +1564,7 @@ fn syncRepoCaches( for (repocaches) |repo_cache| { try repo_cache.sync(client, .{ .force = force_sync, - .ttl_seconds = repo_cache.sync_ttl_seconds, + .interval_seconds = repo_cache.sync_interval_seconds, .timeout_seconds = repo_cache.sync_timeout_seconds, }, loaded_keys); try repo_cache.ensureRepository(loaded_keys); @@ -5199,7 +5200,7 @@ test "uninstallPackagesFromConfig cascade handles multiple independently-require // Uninstall A and C with cascade. Both E (depends on A) and F (depends // on C) must be cascaded away too - not just whichever is checked first. - const result = try uninstallPackagesFromConfig(ctx, &.{ "A", "C" }, client, false, false, "testprofile", true, false); + const result = try uninstallPackagesFromConfig(ctx, &.{ "A", "C" }, client, false, .automatic, "testprofile", true, false); defer if (result) |msg| allocator.free(msg); try std.testing.expect(result == null); diff --git a/src/kdl_schema.zig b/src/kdl_schema.zig index 87cf7eb..b26b9e6 100644 --- a/src/kdl_schema.zig +++ b/src/kdl_schema.zig @@ -430,6 +430,7 @@ fn formatRootPath( const config_settings_children = [_]NodeSpec{ .{ .name = "color", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } }, .{ .name = "init-provider", .args = .{ .kind = .string, .min = 1, .max = 1, .label = "value" } }, + .{ .name = "sync-interval", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, .{ .name = "sync-ttl", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, .{ .name = "sync-timeout", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, }; @@ -439,6 +440,7 @@ const config_repo_children = [_]NodeSpec{ .{ .name = "trusted-fingerprints", .args = .{ .kind = .string, .min = 1, .label = "value" } }, .{ .name = "priority", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, .{ .name = "enabled", .args = .{ .kind = .boolean, .min = 1, .max = 1, .label = "value" } }, + .{ .name = "sync-interval", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, .{ .name = "sync-ttl", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, .{ .name = "sync-timeout", .args = .{ .kind = .integer, .min = 1, .max = 1, .label = "value" } }, }; diff --git a/src/mere.zig b/src/mere.zig index 3c1f6bd..32a6969 100644 --- a/src/mere.zig +++ b/src/mere.zig @@ -21,6 +21,7 @@ pub const scratch = @import("scratch.zig"); pub const package = @import("package.zig"); pub const packaging = @import("packaging.zig"); pub const pin = @import("pin.zig"); +pub const repocache = @import("repocache.zig"); pub const repodb = @import("repodb.zig"); pub const profile = @import("profile.zig"); pub const recipe = @import("recipe.zig"); diff --git a/src/repocache.zig b/src/repocache.zig index dd037ca..ca2a3ab 100644 --- a/src/repocache.zig +++ b/src/repocache.zig @@ -22,15 +22,24 @@ const hash = @import("hash.zig"); const Std = errors.StandardErrors; pub const RepoCacheError = Std.OutOfMemory || Std.FileSystem || Std.Network || Std.PermissionDenied || Std.SignatureInvalid || Std.CorruptData; +pub const SyncPolicy = enum { + automatic, + force, + no_sync, +}; + pub const SyncOptions = struct { force: bool = false, - ttl_seconds: u64 = default_sync_ttl_seconds, + interval_seconds: u64 = default_sync_interval_seconds, timeout_seconds: u32 = default_sync_timeout_seconds, + /// Ordinary operations retain availability by falling back to a verified + /// cache. The explicit `mere sync` operation disables this fallback. + allow_stale_fallback: bool = true, /// Override current time (unix seconds) for tests. now: ?u64 = null, }; -const default_sync_ttl_seconds: u64 = 15 * 60; +const default_sync_interval_seconds: u64 = 15 * 60; const default_sync_timeout_seconds: u32 = 30; /// Compute the cache identity hash for a repo's trust context. @@ -96,8 +105,8 @@ pub const RepoCache = struct { priority: u8 = 100, /// Whether this is a local (file://) repository is_local: bool = false, - /// Sync TTL in seconds (remote repos only; local repos ignore) - sync_ttl_seconds: u64 = default_sync_ttl_seconds, + /// Automatic refresh interval in seconds (remote repos only; local repos ignore) + sync_interval_seconds: u64 = default_sync_interval_seconds, /// Sync timeout in seconds (remote repos only; local repos ignore) sync_timeout_seconds: u32 = default_sync_timeout_seconds, @@ -150,7 +159,7 @@ pub const RepoCache = struct { .repository = null, .priority = priority, .is_local = local, - .sync_ttl_seconds = default_sync_ttl_seconds, + .sync_interval_seconds = default_sync_interval_seconds, .sync_timeout_seconds = default_sync_timeout_seconds, }; } @@ -166,7 +175,7 @@ pub const RepoCache = struct { config.trusted_fingerprints.items, config.priority, ); - cache.sync_ttl_seconds = config.sync_ttl_seconds; + cache.sync_interval_seconds = config.sync_interval_seconds; cache.sync_timeout_seconds = config.sync_timeout_seconds; return cache; } @@ -268,6 +277,7 @@ pub const RepoCache = struct { // No download needed — just verify the signature in-place. if (self.is_local) { try self.syncLocal(loaded_keys); + ui.emit.logFmtSeverity(self.ctx, null, .info, "verified local repository metadata for {s}", .{self.name}); return; } @@ -275,14 +285,10 @@ pub const RepoCache = struct { const cache_dir = self.cache_dir; const now: u64 = if (options.now) |forced| forced else @intCast(std.Io.Clock.real.now(path.currentIo()).toSeconds()); - if (!options.force) { - if (try shouldSkipSync(self, now, options.ttl_seconds)) { - return; - } - } - - // Ensure cache directory exists with world-writable sticky bit (matches - // parent /mere/cache/repos/ so any user can sync). + // Synchronize the check/download/publish sequence per repository. The + // lock is deliberately narrower than Mere's root-wide mutation lock, + // so searches and operations using unrelated repositories do not + // serialize each other. var cache_dir_handle = path.makePathAndOpenDirMode(cache_dir, std.Io.File.Permissions.fromMode(0o1777)) catch |err| { return switch (err) { error.FileNotFound => RepoCacheError.FileSystem, @@ -291,6 +297,16 @@ pub const RepoCache = struct { }; }; cache_dir_handle.close(path.currentIo()); + const sync_lock_fd = try acquireSyncLock(self); + defer releaseSyncLock(sync_lock_fd); + + // Re-check freshness after acquiring the lock: another process may + // have completed the refresh while this caller was waiting. + if (!options.force) { + if (try shouldSkipSync(self, now, options.interval_seconds)) { + return; + } + } // Build remote URLs (null-terminated for curl client). // Remote repo URLs are directory roots that contain repo.db, repo.db.sig, and packages/. @@ -344,14 +360,20 @@ pub const RepoCache = struct { self.ctx.debug("failed to write last_sync.kdl for repo {s}: {s}", .{ self.name, @errorName(err) }); }; + ui.emit.logFmtSeverity(self.ctx, null, .info, "refreshed repository metadata for {s}", .{self.name}); return; } else |err| { - if (try cacheUsable(self)) { + if (options.allow_stale_fallback and try cacheUsable(self)) { const hint = if (err == RepoCacheError.PermissionDenied) " (permission denied — check ownership of cache directory)" else ""; - ui.emit.logFmtSeverity(self.ctx, null, .warn, "sync failed for repo {s}{s}; using cached metadata", .{ self.name, hint }); + const age = try cacheAgeSeconds(self, now); + if (age) |seconds| { + ui.emit.logFmtSeverity(self.ctx, null, .warn, "sync failed for repo {s}{s}; using verified cached metadata ({d}s old)", .{ self.name, hint, seconds }); + } else { + ui.emit.logFmtSeverity(self.ctx, null, .warn, "sync failed for repo {s}{s}; using verified cached metadata", .{ self.name, hint }); + } return; } return err; @@ -600,15 +622,40 @@ fn cacheFilesExist(self: *RepoCache) RepoCacheError!bool { return true; } -fn shouldSkipSync(self: *RepoCache, now: u64, ttl_seconds: u64) RepoCacheError!bool { +fn cacheAgeSeconds(self: *RepoCache, now: u64) RepoCacheError!?u64 { + const last_sync = try readLastSync(self, self.ctx.allocator); + if (last_sync == null or now < last_sync.?) return null; + return now - last_sync.?; +} + +fn acquireSyncLock(self: *RepoCache) RepoCacheError!std.posix.fd_t { + const lock_path = std.fs.path.join(self.ctx.allocator, &.{ self.cache_dir, ".sync.lock" }) catch return RepoCacheError.OutOfMemory; + defer self.ctx.allocator.free(lock_path); + const lock_file = std.Io.Dir.createFileAbsolute(path.currentIo(), lock_path, .{ .truncate = false }) catch |err| return mapFsAccessError(err); + const fd = lock_file.handle; + if (std.posix.errno(std.c.flock(fd, std.c.LOCK.EX)) != .SUCCESS) { + _ = std.c.close(fd); + return RepoCacheError.FileSystem; + } + return fd; +} + +fn releaseSyncLock(fd: std.posix.fd_t) void { + _ = std.c.flock(fd, std.c.LOCK.UN); + _ = std.c.close(fd); +} + +fn shouldSkipSync(self: *RepoCache, now: u64, interval_seconds: u64) RepoCacheError!bool { const last_sync = try readLastSync(self, self.ctx.allocator); if (last_sync == null) return false; - if (ttl_seconds == 0) return false; + if (interval_seconds == 0) return false; if (!try cacheFilesExist(self)) return false; - if (now <= last_sync.?) return true; - if (now - last_sync.? <= ttl_seconds) { + // Equal timestamps are common for immediate repeat operations. A future + // timestamp, however, is not evidence that metadata remains fresh. + if (now < last_sync.?) return false; + if (now - last_sync.? <= interval_seconds) { return true; } return false; @@ -776,6 +823,37 @@ test "remote repo urls resolve to repo.db and repo.db.sig under repo directory" try std.testing.expectEqualStrings("https://repo.example.com/core/new/repo.db.sig", sig_url); } +test "future last-sync timestamps do not suppress refresh" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + const ctx = &test_env.ctx; + var cache = try RepoCache.init(ctx, "future", "https://repo.example.com/future", &.{}, 100); + defer cache.deinit(); + try path.ensureDirExists(cache.cache_dir); + + const db_path = try std.fs.path.join(ctx.allocator, &.{ cache.cache_dir, repo_history.REPO_DB_FILENAME }); + defer ctx.allocator.free(db_path); + const sig_path = try std.fs.path.join(ctx.allocator, &.{ cache.cache_dir, repo_history.REPO_SIG_FILENAME }); + defer ctx.allocator.free(sig_path); + { + const file = try std.Io.Dir.createFileAbsolute(path.currentIo(), db_path, .{}); + file.close(path.currentIo()); + } + { + const file = try std.Io.Dir.createFileAbsolute(path.currentIo(), sig_path, .{}); + file.close(path.currentIo()); + } + try writeLastSync(&cache, 200); + + try std.testing.expect(!try shouldSkipSync(&cache, 100, 900)); + try std.testing.expect(try shouldSkipSync(&cache, 200, 900)); + try std.testing.expect(try shouldSkipSync(&cache, 250, 900)); +} + test "RepoCache.sync downloads and verifies DB and signature" { const th = @import("test_helpers.zig"); var test_env = try th.createTestEnv(); diff --git a/src/search.zig b/src/search.zig index 5f90955..5cea063 100644 --- a/src/search.zig +++ b/src/search.zig @@ -11,6 +11,7 @@ const testing = std.testing; const th = @import("test_helpers.zig"); const config_mod = @import("config.zig"); const path = @import("path.zig"); +const ui = @import("ui/mod.zig"); const Repository = @import("repository.zig").Repository; const Std = errors.StandardErrors; @@ -41,6 +42,15 @@ fn matchesArch(arch: []const u8) bool { } pub fn searchPackages(ctx: *mere.Context, term: []const u8, client: download.TransferClient) SearchError!std.ArrayList(SearchResult) { + return searchPackagesWithPolicy(ctx, term, client, .automatic); +} + +pub fn searchPackagesWithPolicy( + ctx: *mere.Context, + term: []const u8, + client: download.TransferClient, + sync_policy: repocache.SyncPolicy, +) SearchError!std.ArrayList(SearchResult) { var results: std.ArrayList(SearchResult) = .empty; errdefer { for (results.items) |*r| r.deinit(ctx.allocator); @@ -70,36 +80,29 @@ pub fn searchPackages(ctx: *mere.Context, term: []const u8, client: download.Tra loaded_keys.deinit(ctx.allocator); } - // Check if any remote repo needs an initial sync - var needs_sync = false; + var searchable_repositories: usize = 0; for (repocaches.items) |rc| { - if (!rc.is_local) { - rc.ensureRepository(loaded_keys.items) catch { - needs_sync = true; - break; + if (rc.is_local) { + rc.sync(client, .{}, loaded_keys.items) catch { + ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} is unavailable; skipping it", .{rc.name}); + continue; }; - } - } - - if (needs_sync) { - for (repocaches.items) |rc| { - if (rc.is_local) continue; + } else if (sync_policy != .no_sync) { rc.sync(client, .{ - .force = false, - .ttl_seconds = rc.sync_ttl_seconds, + .force = sync_policy == .force, + .interval_seconds = rc.sync_interval_seconds, .timeout_seconds = rc.sync_timeout_seconds, }, loaded_keys.items) catch { - ctx.debug("skipping repo {s}: sync failed", .{rc.name}); + ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} is unavailable; skipping it", .{rc.name}); continue; }; } - } - for (repocaches.items) |rc| { rc.ensureRepository(loaded_keys.items) catch { - ctx.debug("skipping repo {s}: failed to open", .{rc.name}); + ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} has no usable verified metadata; skipping it", .{rc.name}); continue; }; + searchable_repositories += 1; const repo = &(rc.repository.?); var matches = repo.db.searchByName(ctx.allocator, term) catch { @@ -127,6 +130,10 @@ pub fn searchPackages(ctx: *mere.Context, term: []const u8, client: download.Tra } } + if (searchable_repositories == 0) { + return ctx.fail(SearchError.FileSystem, term, "no repository has usable verified metadata"); + } + return results; } From 4109d3c4d5633824efd71702e3e6f8e49ae42ae7 Mon Sep 17 00:00:00 2001 From: Jeremy Huntwork Date: Sun, 23 Aug 2026 11:09:58 -0400 Subject: [PATCH 2/2] Keep repository synchronization policy in one core operation Repository selection, refresh policy, verification, and outcomes now have one owner shared by package resolution, search, and standalone sync. This prevents command-specific semantics from drifting while preserving availability-first cached fallback and strict explicit synchronization. CLI commands now parse common flags, establish transfer adapters, and render structured core outcomes rather than owning repository policy. --- docs/design/specification-details.md | 2 +- src/all_tests.zig | 1 + src/cli/commands/install.zig | 6 +- src/cli/commands/search.zig | 4 +- src/cli/commands/sync.zig | 67 ++-------- src/cli/commands/uninstall.zig | 6 +- src/cli/commands/upgrade.zig | 4 +- src/cli/sync_options.zig | 22 ++++ src/install.zig | 52 +++----- src/mere.zig | 1 + src/repo_sync.zig | 180 +++++++++++++++++++++++++++ src/repocache.zig | 6 - src/search.zig | 35 ++---- 13 files changed, 256 insertions(+), 130 deletions(-) create mode 100644 src/cli/sync_options.zig create mode 100644 src/repo_sync.zig diff --git a/docs/design/specification-details.md b/docs/design/specification-details.md index ef9b0f0..0fa8b77 100644 --- a/docs/design/specification-details.md +++ b/docs/design/specification-details.md @@ -45,7 +45,7 @@ Repository-dependent operations use an availability-first automatic refresh poli - `mere sync [repositories...]` is strict: it refreshes all enabled repositories, or only the named enabled repositories, regardless of interval. Local repositories are signature-verified. Any requested repository that cannot be refreshed or found makes the command fail, while the previous verified cache remains intact. - Search follows the same policy. It MAY return partial results from repositories with usable verified metadata, MUST warn about unavailable repositories, and MUST fail when none can be searched. -`sync-interval` is expressed in seconds and MAY be configured globally or per repository. `sync-ttl` remains accepted as a read-only compatibility alias; Mere writes only `sync-interval`, because reaching the interval triggers an attempt rather than invalidating cached metadata. A last-refresh timestamp in the future MUST NOT suppress refresh. Refresh check, download, verification, and publication MUST serialize per repository so unrelated repositories do not block one another. +`sync-interval` is expressed in seconds and MAY be configured globally or per repository. `sync-ttl` remains accepted as a read-only compatibility alias; Mere writes only `sync-interval`, because reaching the interval triggers an attempt rather than invalidating cached metadata. A last-refresh timestamp in the future MUST NOT suppress refresh. Repository selection, policy application, verification, and per-repository outcomes MUST be implemented by one core repository-set operation shared by package resolution, search, and standalone sync; command handlers only select policy and render those outcomes. Refresh check, download, verification, and publication MUST serialize per repository so unrelated repositories do not block one another. ### Self-description diff --git a/src/all_tests.zig b/src/all_tests.zig index 56a30fe..3697aec 100644 --- a/src/all_tests.zig +++ b/src/all_tests.zig @@ -41,6 +41,7 @@ test { _ = @import("recipe.zig"); _ = @import("repodb.zig"); _ = @import("repo_history.zig"); + _ = @import("repo_sync.zig"); _ = @import("repo_sources.zig"); _ = @import("repocache.zig"); _ = @import("repository.zig"); diff --git a/src/cli/commands/install.zig b/src/cli/commands/install.zig index 9d4f782..b43d6f9 100644 --- a/src/cli/commands/install.zig +++ b/src/cli/commands/install.zig @@ -3,7 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); -const sync_command = @import("sync.zig"); +const sync_options = @import("../sync_options.zig"); const MereError = types.MereError; /// Install command metadata @@ -58,7 +58,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty const package_names = args.positional; const profile_name = args.getString("profile") orelse "system"; const verify_store = args.getBool("verify-store"); - const sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; + const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput; const dry_run = args.getBool("dry-run"); // Set initial diagnostic context - the subject is the package being installed @@ -88,7 +88,7 @@ fn performInstallation( package_names: []const []const u8, profile_name: []const u8, verify_store: bool, - sync_policy: mere.repocache.SyncPolicy, + sync_policy: mere.repo_sync.SyncPolicy, dry_run: bool, ) !?[]const u8 { // Ensure configuration is loaded (no logging - errors propagate) diff --git a/src/cli/commands/search.zig b/src/cli/commands/search.zig index 89bec32..a09290b 100644 --- a/src/cli/commands/search.zig +++ b/src/cli/commands/search.zig @@ -2,7 +2,7 @@ const std = @import("std"); const mere = @import("mere"); const types = @import("../types.zig"); const command = @import("../command.zig"); -const sync_command = @import("sync.zig"); +const sync_options = @import("../sync_options.zig"); const MereError = mere.errors.MereError; const ui = mere.ui; const emit = ui.emit; @@ -39,7 +39,7 @@ fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ } const term = args.positional[0]; - const sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; + const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput; var curl_client = try mere.download.CurlTransferClient.init(ctx, command.user_agent); defer mere.download.CurlTransferClient.cleanupFn(ctx, curl_client); diff --git a/src/cli/commands/sync.zig b/src/cli/commands/sync.zig index 7eb855b..d217fe7 100644 --- a/src/cli/commands/sync.zig +++ b/src/cli/commands/sync.zig @@ -18,32 +18,6 @@ const sync_meta = command.CommandMeta{ }, }; -fn selected(names: []const []const u8, candidate: []const u8) bool { - if (names.len == 0) return true; - for (names) |name| { - if (std.mem.eql(u8, name, candidate)) return true; - } - return false; -} - -pub fn repositorySyncPolicy(args: *const types.ParsedArgs) MereError!mere.repocache.SyncPolicy { - return repositorySyncPolicyFromFlags(args.getBool("sync"), args.getBool("no-sync")); -} - -fn repositorySyncPolicyFromFlags(force: bool, disabled: bool) MereError!mere.repocache.SyncPolicy { - if (force and disabled) return MereError.InvalidInput; - if (force) return .force; - if (disabled) return .no_sync; - return .automatic; -} - -test "repository sync flags are explicit and mutually exclusive" { - try std.testing.expectEqual(mere.repocache.SyncPolicy.automatic, try repositorySyncPolicyFromFlags(false, false)); - try std.testing.expectEqual(mere.repocache.SyncPolicy.force, try repositorySyncPolicyFromFlags(true, false)); - try std.testing.expectEqual(mere.repocache.SyncPolicy.no_sync, try repositorySyncPolicyFromFlags(false, true)); - try std.testing.expectError(MereError.InvalidInput, repositorySyncPolicyFromFlags(true, true)); -} - fn handleSync(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult { const config = ctx.getConfig() catch |err| return try command.errorResult(ctx, err, null); var caches = mere.repo_sources.createCaches(ctx, config) catch |err| return try command.errorResult(ctx, err, null); @@ -66,40 +40,25 @@ fn handleSync(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types }; defer mere.download.CurlTransferClient.cleanupFn(ctx, curl_client); - var matched = try ctx.allocator.alloc(bool, args.positional.len); - defer ctx.allocator.free(matched); - @memset(matched, false); - - var failures: usize = 0; - var attempted: usize = 0; - for (caches.items) |cache| { - if (!selected(args.positional, cache.name)) continue; - attempted += 1; - for (args.positional, 0..) |name, index| { - if (std.mem.eql(u8, name, cache.name)) matched[index] = true; - } - - cache.sync(curl_client.client(), .{ - .force = true, - .interval_seconds = cache.sync_interval_seconds, - .timeout_seconds = cache.sync_timeout_seconds, - .allow_stale_fallback = false, - }, loaded_keys.items) catch |err| { - failures += 1; - mere.ui.emit.logFmtSeverity(ctx, null, .err, "failed to refresh repository {s}: {s}", .{ cache.name, @errorName(err) }); - }; - } + var sync_result = mere.repo_sync.synchronize(ctx, caches.items, curl_client.client(), .{ + .policy = .force, + .repositories = args.positional, + .strict = true, + }, loaded_keys.items) catch |err| return try command.errorResult(ctx, err, null); + defer sync_result.deinit(ctx.allocator); - for (args.positional, 0..) |name, index| { - if (!matched[index]) { - failures += 1; - mere.ui.emit.logFmtSeverity(ctx, null, .err, "enabled repository not found: {s}", .{name}); + for (sync_result.outcomes.items) |outcome| { + switch (outcome.status) { + .ready => {}, + .failed => mere.ui.emit.logFmtSeverity(ctx, null, .err, "failed to refresh repository {s}: {s}", .{ outcome.name, @errorName(outcome.failure.?) }), + .not_found => mere.ui.emit.logFmtSeverity(ctx, null, .err, "enabled repository not found: {s}", .{outcome.name}), } } - if (attempted == 0 and args.positional.len == 0) { + if (sync_result.selected_count == 0 and args.positional.len == 0) { return .{ .success = true, .message = "no enabled repositories configured" }; } + const failures = sync_result.failureCount(); if (failures > 0) { return .{ .success = false, diff --git a/src/cli/commands/uninstall.zig b/src/cli/commands/uninstall.zig index 7c9d8e7..0c71b34 100644 --- a/src/cli/commands/uninstall.zig +++ b/src/cli/commands/uninstall.zig @@ -3,7 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); -const sync_command = @import("sync.zig"); +const sync_options = @import("../sync_options.zig"); const MereError = types.MereError; const uninstall_meta = command.CommandMeta{ @@ -61,7 +61,7 @@ fn handleUninstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError! const package_names = args.positional; const profile_name = args.getString("profile") orelse "system"; const verify_store = args.getBool("verify-store"); - const sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; + const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput; const cascade = args.getBool("cascade"); const dry_run = args.getBool("dry-run"); @@ -95,7 +95,7 @@ fn performUninstall( package_names: []const []const u8, profile_name: []const u8, verify_store: bool, - sync_policy: mere.repocache.SyncPolicy, + sync_policy: mere.repo_sync.SyncPolicy, cascade: bool, dry_run: bool, ) !?[]const u8 { diff --git a/src/cli/commands/upgrade.zig b/src/cli/commands/upgrade.zig index 9f3658a..ed8346b 100644 --- a/src/cli/commands/upgrade.zig +++ b/src/cli/commands/upgrade.zig @@ -3,7 +3,7 @@ const mere = @import("mere"); const download = mere.download; const types = @import("../types.zig"); const command = @import("../command.zig"); -const sync_command = @import("sync.zig"); +const sync_options = @import("../sync_options.zig"); const MereError = types.MereError; const upgrade_meta = command.CommandMeta{ @@ -52,7 +52,7 @@ fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty const package_names = args.positional; const profile_name = args.getString("profile") orelse "system"; const verify_store = args.getBool("verify-store"); - const sync_policy = sync_command.repositorySyncPolicy(args) catch return MereError.InvalidInput; + const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput; const dry_run = args.getBool("dry-run"); ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject( diff --git a/src/cli/sync_options.zig b/src/cli/sync_options.zig new file mode 100644 index 0000000..ef1c829 --- /dev/null +++ b/src/cli/sync_options.zig @@ -0,0 +1,22 @@ +const std = @import("std"); +const mere = @import("mere"); +const types = @import("types.zig"); +const MereError = types.MereError; + +pub fn repositorySyncPolicy(args: *const types.ParsedArgs) MereError!mere.repo_sync.SyncPolicy { + return fromFlags(args.getBool("sync"), args.getBool("no-sync")); +} + +fn fromFlags(force: bool, disabled: bool) MereError!mere.repo_sync.SyncPolicy { + if (force and disabled) return MereError.InvalidInput; + if (force) return .force; + if (disabled) return .no_sync; + return .automatic; +} + +test "repository sync flags are explicit and mutually exclusive" { + try std.testing.expectEqual(mere.repo_sync.SyncPolicy.automatic, try fromFlags(false, false)); + try std.testing.expectEqual(mere.repo_sync.SyncPolicy.force, try fromFlags(true, false)); + try std.testing.expectEqual(mere.repo_sync.SyncPolicy.no_sync, try fromFlags(false, true)); + try std.testing.expectError(MereError.InvalidInput, fromFlags(true, true)); +} diff --git a/src/install.zig b/src/install.zig index 2458ce7..05be5df 100644 --- a/src/install.zig +++ b/src/install.zig @@ -11,7 +11,8 @@ const repodb = @import("repodb.zig"); const c = repodb.c; const sign = @import("sign.zig"); const repocache_mod = @import("repocache.zig"); -const SyncPolicy = repocache_mod.SyncPolicy; +const repo_sync = @import("repo_sync.zig"); +const SyncPolicy = repo_sync.SyncPolicy; const RepoCache = repocache_mod.RepoCache; const config_mod = @import("config.zig"); const repo_sources = @import("repo_sources.zig"); @@ -442,7 +443,7 @@ pub fn installPackagesFromConfigWithPreview( } // Resolve - var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, sync_policy == .force, sync_policy != .no_sync); + var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, sync_policy); defer resolution.deinit(); if (requested_state) |*state| resolution.setRequestedIntent(state.packages.items); @@ -530,7 +531,7 @@ pub fn installPackageSpecsFromConfig( } // Resolve - var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, force_sync, true); + var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections, client, if (force_sync) .force else .automatic); defer resolution.deinit(); resolution.setRequirementIntent(input_requirements.items); @@ -609,8 +610,7 @@ pub fn upgradePackagesFromConfig( resolver_requirements, preferred_selections_state.selections, client, - sync_policy == .force, - sync_policy != .no_sync, + sync_policy, ); defer resolution.deinit(); resolution.setRequestedIntent(requested_state.packages.items); @@ -681,7 +681,7 @@ pub fn uninstallPackagesFromConfig( preferred_selections_state.holdAll(); // Resolve - var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections_state.selections, client, sync_policy == .force, sync_policy != .no_sync); + var resolution = try resolveProfile(ctx, repocaches.items, resolver_requirements, preferred_selections_state.selections, client, sync_policy); defer resolution.deinit(); if (requested_state.removed_count == 0) { @@ -767,7 +767,7 @@ pub fn uninstallPackagesFromConfig( for (requested_state.packages.items, 0..) |pkg, ri| { new_reqs[ri] = .{ .name = pkg.name, .constraint_expr = pkg.constraint_expr }; } - resolution = try resolveProfile(ctx, repocaches.items, new_reqs, preferred_selections_state.selections, client, sync_policy == .force, sync_policy != .no_sync); + resolution = try resolveProfile(ctx, repocaches.items, new_reqs, preferred_selections_state.selections, client, sync_policy); resolution.setRequestedIntent(requested_state.packages.items); } else { // All roots removed. Do NOT deinit `resolution` here - the @@ -859,7 +859,7 @@ pub fn installPackagesToProfile( } // Resolve - var resolution = try resolveProfile(ctx, repocaches, resolver_requirements, preferred_selections, client, force_sync, true); + var resolution = try resolveProfile(ctx, repocaches, resolver_requirements, preferred_selections, client, if (force_sync) .force else .automatic); defer resolution.deinit(); if (requested_state) |*state| resolution.setRequestedIntent(state.packages.items); @@ -1441,7 +1441,7 @@ pub fn resolveDependencyTokens( const resolver_requirements = try installRequirementsToResolverRequirements(ctx.allocator, input_requirements.items); defer ctx.allocator.free(resolver_requirements); - return resolveProfile(ctx, repocaches, resolver_requirements, &.{}, client, force_sync, true); + return resolveProfile(ctx, repocaches, resolver_requirements, &.{}, client, if (force_sync) .force else .automatic); } /// Resolve a profile: sync repos, resolve dependencies, return the resolved package set. @@ -1452,8 +1452,7 @@ pub fn resolveProfile( requirements: []const resolver.Requirement, preferred_selections: []const resolver.PreferredSelection, client: download.TransferClient, - force_sync: bool, - sync: bool, + sync_policy: SyncPolicy, ) !ProfileResolution { var loaded_keys = try sign.loadAllKeys(ctx); errdefer { @@ -1461,13 +1460,11 @@ pub fn resolveProfile( loaded_keys.deinit(ctx.allocator); } - if (sync) { - try syncRepoCaches(ctx, repocaches, client, force_sync, loaded_keys.items); - } else { - for (repocaches) |repo_cache| { - try repo_cache.ensureRepository(loaded_keys.items); - } - } + var sync_result = try repo_sync.synchronize(ctx, repocaches, client, .{ + .policy = sync_policy, + }, loaded_keys.items); + defer sync_result.deinit(ctx.allocator); + if (sync_result.firstFailure()) |err| return err; var arena = std.heap.ArenaAllocator.init(ctx.allocator); errdefer arena.deinit(); @@ -1553,25 +1550,6 @@ pub fn realizeProfile( return target_behavior; } -fn syncRepoCaches( - ctx: *Context, - repocaches: []*RepoCache, - client: download.TransferClient, - force_sync: bool, - loaded_keys: []const sign.LoadedKey, -) !void { - // 0. Sync all RepoCaches before install - for (repocaches) |repo_cache| { - try repo_cache.sync(client, .{ - .force = force_sync, - .interval_seconds = repo_cache.sync_interval_seconds, - .timeout_seconds = repo_cache.sync_timeout_seconds, - }, loaded_keys); - try repo_cache.ensureRepository(loaded_keys); - } - ctx.debug("repo sync complete for {d} repositories", .{repocaches.len}); -} - fn resolveInstallPlan( ctx: *Context, repocaches: []*RepoCache, diff --git a/src/mere.zig b/src/mere.zig index 32a6969..4c81bb5 100644 --- a/src/mere.zig +++ b/src/mere.zig @@ -11,6 +11,7 @@ pub const gc = @import("gc.zig"); pub const gcroots = @import("gcroots.zig"); pub const generation = @import("generation.zig"); pub const repo_history = @import("repo_history.zig"); +pub const repo_sync = @import("repo_sync.zig"); pub const hash = @import("hash.zig"); pub const import = @import("import.zig"); pub const init = @import("init.zig"); diff --git a/src/repo_sync.zig b/src/repo_sync.zig new file mode 100644 index 0000000..dc7b4e1 --- /dev/null +++ b/src/repo_sync.zig @@ -0,0 +1,180 @@ +const std = @import("std"); +const mere = @import("mere.zig"); +const download = @import("download.zig"); +const repocache = @import("repocache.zig"); +const sign = @import("sign.zig"); + +pub const SyncPolicy = enum { + automatic, + force, + no_sync, +}; + +pub const Request = struct { + policy: SyncPolicy = .automatic, + repositories: []const []const u8 = &.{}, + strict: bool = false, +}; + +pub const OutcomeStatus = enum { + ready, + failed, + not_found, +}; + +pub const Outcome = struct { + name: []const u8, + cache: ?*repocache.RepoCache, + status: OutcomeStatus, + failure: ?anyerror = null, +}; + +pub const Result = struct { + outcomes: std.ArrayList(Outcome) = .empty, + ready_count: usize = 0, + selected_count: usize = 0, + + pub fn deinit(self: *Result, allocator: std.mem.Allocator) void { + self.outcomes.deinit(allocator); + } + + pub fn failureCount(self: *const Result) usize { + var count: usize = 0; + for (self.outcomes.items) |outcome| { + if (outcome.status != .ready) count += 1; + } + return count; + } + + pub fn firstFailure(self: *const Result) ?anyerror { + for (self.outcomes.items) |outcome| { + switch (outcome.status) { + .ready => {}, + .failed => return outcome.failure orelse error.RepositoryUnavailable, + .not_found => return error.RepositoryNotFound, + } + } + return null; + } +}; + +fn isSelected(names: []const []const u8, candidate: []const u8) bool { + if (names.len == 0) return true; + for (names) |name| { + if (std.mem.eql(u8, name, candidate)) return true; + } + return false; +} + +fn synchronizeOne( + cache: *repocache.RepoCache, + client: download.TransferClient, + request: Request, + loaded_keys: []const sign.LoadedKey, +) !void { + if (request.strict) { + try cache.sync(client, .{ + .force = true, + .interval_seconds = cache.sync_interval_seconds, + .timeout_seconds = cache.sync_timeout_seconds, + .allow_stale_fallback = false, + }, loaded_keys); + } else switch (request.policy) { + .automatic, .force => try cache.sync(client, .{ + .force = request.policy == .force, + .interval_seconds = cache.sync_interval_seconds, + .timeout_seconds = cache.sync_timeout_seconds, + }, loaded_keys), + .no_sync => {}, + } + + try cache.ensureRepository(loaded_keys); +} + +/// Apply one repository synchronization contract to a set of initialized +/// caches. Operational repository failures are returned as per-repository +/// outcomes so strict sync can report all failures and search can retain +/// partial results. Resource exhaustion still aborts the operation. +pub fn synchronize( + ctx: *mere.Context, + caches: []*repocache.RepoCache, + client: download.TransferClient, + request: Request, + loaded_keys: []const sign.LoadedKey, +) !Result { + var result = Result{}; + errdefer result.deinit(ctx.allocator); + + const matched = try ctx.allocator.alloc(bool, request.repositories.len); + defer ctx.allocator.free(matched); + @memset(matched, false); + + for (caches) |cache| { + if (!isSelected(request.repositories, cache.name)) continue; + result.selected_count += 1; + for (request.repositories, 0..) |name, index| { + if (std.mem.eql(u8, name, cache.name)) matched[index] = true; + } + + synchronizeOne(cache, client, request, loaded_keys) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + try result.outcomes.append(ctx.allocator, .{ + .name = cache.name, + .cache = cache, + .status = .failed, + .failure = err, + }); + continue; + }; + + result.ready_count += 1; + try result.outcomes.append(ctx.allocator, .{ + .name = cache.name, + .cache = cache, + .status = .ready, + }); + } + + for (request.repositories, 0..) |name, index| { + if (matched[index]) continue; + try result.outcomes.append(ctx.allocator, .{ + .name = name, + .cache = null, + .status = .not_found, + .failure = error.RepositoryNotFound, + }); + } + + return result; +} + +test "repository selection reports unknown names without hiding ready repositories" { + const th = @import("test_helpers.zig"); + var test_env = try th.createTestEnv(); + defer { + test_env.cleanup(); + std.testing.allocator.destroy(test_env); + } + const ctx = &test_env.ctx; + + var cache = try repocache.RepoCache.init(ctx, "known", "https://repo.example.com/known", &.{}, 100); + defer cache.deinit(); + var caches = [_]*repocache.RepoCache{&cache}; + + var dummy = th.DummyClient.init(ctx.allocator); + defer dummy.deinit(); + var vtable = download.TransferClient.VTable{ .download_file = th.dummy_download_file }; + const client = download.TransferClient{ .ptr = @ptrCast(&dummy), .vtable = &vtable }; + + const names = [_][]const u8{ "known", "missing" }; + var result = try synchronize(ctx, caches[0..], client, .{ + .policy = .no_sync, + .repositories = &names, + }, &.{}); + defer result.deinit(ctx.allocator); + + try std.testing.expectEqual(@as(usize, 1), result.selected_count); + try std.testing.expectEqual(@as(usize, 2), result.failureCount()); + try std.testing.expectEqual(OutcomeStatus.failed, result.outcomes.items[0].status); + try std.testing.expectEqual(OutcomeStatus.not_found, result.outcomes.items[1].status); +} diff --git a/src/repocache.zig b/src/repocache.zig index ca2a3ab..1b6aca7 100644 --- a/src/repocache.zig +++ b/src/repocache.zig @@ -22,12 +22,6 @@ const hash = @import("hash.zig"); const Std = errors.StandardErrors; pub const RepoCacheError = Std.OutOfMemory || Std.FileSystem || Std.Network || Std.PermissionDenied || Std.SignatureInvalid || Std.CorruptData; -pub const SyncPolicy = enum { - automatic, - force, - no_sync, -}; - pub const SyncOptions = struct { force: bool = false, interval_seconds: u64 = default_sync_interval_seconds, diff --git a/src/search.zig b/src/search.zig index 5cea063..b6ea69f 100644 --- a/src/search.zig +++ b/src/search.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const mere = @import("mere.zig"); const repo_sources = @import("repo_sources.zig"); -const repocache = @import("repocache.zig"); +const repo_sync = @import("repo_sync.zig"); const package = @import("package.zig"); const errors = @import("errors.zig"); const download = @import("download.zig"); @@ -49,7 +49,7 @@ pub fn searchPackagesWithPolicy( ctx: *mere.Context, term: []const u8, client: download.TransferClient, - sync_policy: repocache.SyncPolicy, + sync_policy: repo_sync.SyncPolicy, ) SearchError!std.ArrayList(SearchResult) { var results: std.ArrayList(SearchResult) = .empty; errdefer { @@ -81,27 +81,18 @@ pub fn searchPackagesWithPolicy( } var searchable_repositories: usize = 0; - for (repocaches.items) |rc| { - if (rc.is_local) { - rc.sync(client, .{}, loaded_keys.items) catch { - ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} is unavailable; skipping it", .{rc.name}); - continue; - }; - } else if (sync_policy != .no_sync) { - rc.sync(client, .{ - .force = sync_policy == .force, - .interval_seconds = rc.sync_interval_seconds, - .timeout_seconds = rc.sync_timeout_seconds, - }, loaded_keys.items) catch { - ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} is unavailable; skipping it", .{rc.name}); - continue; - }; - } - - rc.ensureRepository(loaded_keys.items) catch { - ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} has no usable verified metadata; skipping it", .{rc.name}); + var sync_result = repo_sync.synchronize(ctx, repocaches.items, client, .{ + .policy = sync_policy, + }, loaded_keys.items) catch return SearchError.OutOfMemory; + defer sync_result.deinit(ctx.allocator); + + for (sync_result.outcomes.items) |outcome| { + if (outcome.status != .ready) { + const detail = if (outcome.failure) |err| @errorName(err) else "unavailable"; + ui.emit.logFmtSeverity(ctx, .search, .warn, "repository {s} is unavailable ({s}); skipping it", .{ outcome.name, detail }); continue; - }; + } + const rc = outcome.cache.?; searchable_repositories += 1; const repo = &(rc.repository.?);