diff --git a/src/internal/runtime/cli.zig b/src/internal/runtime/cli.zig new file mode 100644 index 0000000..8d33e20 --- /dev/null +++ b/src/internal/runtime/cli.zig @@ -0,0 +1,209 @@ +//! Comptime-derived flag and environment plumbing. +//! +//! Every option is a field on an options struct. Its flag spelling and its +//! `JETSTREAM_*` name are *derived* from the field name rather than listed +//! separately, so the three things that used to drift independently — the +//! parser, the environment map, and the usage text — cannot disagree: there is +//! only one list, and it is the struct. +//! +//! That drift was a real, documented gap. `docs/configuration-parity.md` marks +//! the flag inventory **unverified** because nothing proved the hand-written +//! `environment.zig` map covered every flag the parser accepts. Derivation +//! turns that from a promise into a property. +//! +//! Naming rule: `backfill_workers` -> `--backfill-workers` and +//! `JETSTREAM_BACKFILL_WORKERS`. Options whose spelling predates the rule, or +//! which upstream declares root-only, carry an entry in the struct's optional +//! `overrides` declaration; there is no other way to deviate, so deviations +//! stay countable. + +const std = @import("std"); + +pub const prefix = "JETSTREAM_"; + +/// How one option is exposed. `serve` mirrors upstream's command tree: a +/// root-only value has no synthesized `serve` flag. +pub const Exposure = struct { + flag: []const u8, + env: []const u8, + serve: bool = true, +}; + +/// `some_field` -> `some-field` +pub fn flagName(comptime field: []const u8) []const u8 { + return comptime blk: { + var out: [field.len]u8 = undefined; + for (field, 0..) |c, i| out[i] = if (c == '_') '-' else c; + const frozen = out; + break :blk &frozen; + }; +} + +/// `some_field` -> `JETSTREAM_SOME_FIELD` +pub fn envName(comptime field: []const u8) []const u8 { + return comptime blk: { + var out: [prefix.len + field.len]u8 = undefined; + @memcpy(out[0..prefix.len], prefix); + for (field, 0..) |c, i| out[prefix.len + i] = std.ascii.toUpper(c); + const frozen = out; + break :blk &frozen; + }; +} + +fn overrideFor(comptime T: type, comptime field: []const u8) ?Exposure { + @setEvalBranchQuota(10_000); + if (!@hasDecl(T, "overrides")) return null; + const ov = T.overrides; + if (!@hasField(@TypeOf(ov), field)) return null; + const entry = @field(ov, field); + const E = @TypeOf(entry); + return Exposure{ + .flag = if (@hasField(E, "flag")) entry.flag else flagName(field), + .env = if (@hasField(E, "env")) entry.env else envName(field), + .serve = if (@hasField(E, "serve")) entry.serve else true, + }; +} + +/// The exposure of every field on `T`, derived unless overridden. +pub fn exposures(comptime T: type) []const Exposure { + return comptime blk: { + @setEvalBranchQuota(20_000); + const fields = std.meta.fields(T); + var out: [fields.len]Exposure = undefined; + for (fields, 0..) |f, i| { + out[i] = overrideFor(T, f.name) orelse .{ + .flag = flagName(f.name), + .env = envName(f.name), + }; + } + const frozen = out; + break :blk &frozen; + }; +} + +/// Is `key` an environment name this option set declares? +pub fn declaresEnv(comptime T: type, key: []const u8) bool { + inline for (comptime exposures(T)) |e| { + if (std.mem.eql(u8, e.env, key)) return true; + } + return false; +} + +/// Is `flag` (without leading dashes) one this option set declares? +pub fn declaresFlag(comptime T: type, flag: []const u8) bool { + inline for (comptime exposures(T)) |e| { + if (std.mem.eql(u8, e.flag, flag)) return true; + } + return false; +} + +// === tests === + +const testing = std.testing; + +test "field names derive their flag and environment spellings" { + try testing.expectEqualStrings("backfill-workers", flagName("backfill_workers")); + try testing.expectEqualStrings("JETSTREAM_BACKFILL_WORKERS", envName("backfill_workers")); + try testing.expectEqualStrings("addr", flagName("addr")); + try testing.expectEqualStrings("JETSTREAM_ADDR", envName("addr")); +} + +test "overrides are the only way to deviate, and they compose with derivation" { + const Opts = struct { + addr: []const u8 = "", + log_level: []const u8 = "", + legacy: []const u8 = "", + + pub const overrides = .{ + // upstream declares this root-only: no synthesized serve flag + .log_level = .{ .serve = false }, + // a spelling that predates the naming rule + .legacy = .{ .flag = "port", .env = "JETSTREAM_PORT" }, + }; + }; + const e = comptime exposures(Opts); + try testing.expectEqualStrings("addr", e[0].flag); + try testing.expect(e[0].serve); + try testing.expectEqualStrings("log-level", e[1].flag); + try testing.expect(!e[1].serve); + try testing.expectEqualStrings("port", e[2].flag); + try testing.expectEqualStrings("JETSTREAM_PORT", e[2].env); + try testing.expect(declaresEnv(Opts, "JETSTREAM_ADDR")); + try testing.expect(!declaresEnv(Opts, "JETSTREAM_NOPE")); + try testing.expect(declaresFlag(Opts, "port")); +} + +test "the derived option set reproduces the hand-written environment map" { + // This is the point of the module. environment.zig's map is maintained by + // hand, and docs/configuration-parity.md marks the flag inventory + // unverified for exactly that reason. Deriving the same set from a struct + // and asserting equality here makes the two impossible to drift apart -- + // and when main.zig moves onto this struct, the map stops existing. + const environment = @import("environment.zig"); + const derived = comptime exposures(ServeOptions); + + try testing.expectEqual(environment.mappings.len, derived.len); + for (environment.mappings) |want| { + var found = false; + for (derived) |got| { + if (!std.mem.eql(u8, want.flag, got.flag)) continue; + found = true; + try testing.expectEqualStrings(want.env, got.env); + try testing.expectEqual(want.serve, got.serve); + break; + } + if (!found) { + std.debug.print("no derived option for flag --{s}\n", .{want.flag}); + return error.MissingDerivedOption; + } + } +} + +/// The pinned Jetstream V2 flag surface, as a struct. Field order matches +/// upstream's declaration order so the two read side by side. +pub const ServeOptions = struct { + log_level: []const u8 = "", + log_format: []const u8 = "", + addr: []const u8 = "", + debug_addr: []const u8 = "", + shutdown_timeout: []const u8 = "", + client_drain_timeout: []const u8 = "", + relay_url: []const u8 = "", + plc_url: []const u8 = "", + data_dir: []const u8 = "", + max_backfill_repos: []const u8 = "", + backfill_workers: []const u8 = "", + backfill_batch_size: []const u8 = "", + backfill_async_flush_workers: []const u8 = "", + backfill_repos: []const u8 = "", + skip_merge_discovery: []const u8 = "", + failed_repo_retry_interval: []const u8 = "", + failed_repo_retry_workers: []const u8 = "", + failed_repo_retry_host_workers: []const u8 = "", + failed_repo_retry_max_delay: []const u8 = "", + disable_repo_action_rate_limits: []const u8 = "", + cursor_lookback: []const u8 = "", + segment_cache_max_age: []const u8 = "", + plan_max_dids: []const u8 = "", + plan_max_collections: []const u8 = "", + plan_max_entries: []const u8 = "", + plan_whole_segment_threshold: []const u8 = "", + subscribe_read_log_retention_bytes: []const u8 = "", + subscribe_block_cache_bytes: []const u8 = "", + subscribe_read_batch: []const u8 = "", + subscribe_slow_window: []const u8 = "", + subscribe_slow_min_rate: []const u8 = "", + cursor_block_index_cache_size: []const u8 = "", + compaction_interval: []const u8 = "", + compaction_tombstone_cap: []const u8 = "", + compaction_rewrite_workers: []const u8 = "", + timestamp_import_token: []const u8 = "", + timestamp_import_dir: []const u8 = "", + + pub const overrides = .{ + // Upstream declares these on the root command, so there is no + // synthesized `serve` flag for them. + .log_level = .{ .serve = false }, + .log_format = .{ .serve = false }, + }; +}; diff --git a/src/internal/runtime/environment.zig b/src/internal/runtime/environment.zig index 684945a..597ecd7 100644 --- a/src/internal/runtime/environment.zig +++ b/src/internal/runtime/environment.zig @@ -2,7 +2,7 @@ const std = @import("std"); pub const jetstream_prefix = "JETSTREAM_"; -const Mapping = struct { +pub const Mapping = struct { env: []const u8, flag: []const u8, serve: bool = true, @@ -10,7 +10,7 @@ const Mapping = struct { // This is the exact flag-source set declared by the pinned Jetstream V2 // command tree. Root-only values have no synthesized serve flag. -const mappings = [_]Mapping{ +pub const mappings = [_]Mapping{ .{ .env = "JETSTREAM_LOG_LEVEL", .flag = "log-level", .serve = false }, .{ .env = "JETSTREAM_LOG_FORMAT", .flag = "log-format", .serve = false }, .{ .env = "JETSTREAM_ADDR", .flag = "addr" }, diff --git a/src/tests.zig b/src/tests.zig index b1df8ee..888cd36 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -4,6 +4,7 @@ comptime { _ = @import("internal/ingest/cursor.zig"); _ = @import("internal/storage/meta_store.zig"); _ = @import("internal/runtime/metrics.zig"); + _ = @import("internal/runtime/cli.zig"); _ = @import("internal/ingest/pipeline.zig"); _ = @import("internal/ingest/repair.zig"); _ = @import("internal/ingest/repair_integration_test.zig");