From a7abc5f5d7e35452e5dcaccf5b306de84e1170c6 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 21 Apr 2026 11:53:00 -0700 Subject: [PATCH] add version command --- src/commands/version.zig | 213 +++++++++++++++++++++++++++++++++++++++ src/main.zig | 15 ++- src/version.zig | 39 +++++++ 3 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/commands/version.zig create mode 100644 src/version.zig diff --git a/src/commands/version.zig b/src/commands/version.zig new file mode 100644 index 0000000..2765830 --- /dev/null +++ b/src/commands/version.zig @@ -0,0 +1,213 @@ +//! `zfin version` — print version and build info. +//! +//! Default: single line, e.g. +//! zfin v0.3.1-4-g1a2b3c4 (built 2026-04-21) +//! +//! `--verbose`: adds zig version, build mode, resolved user file dir +//! (ZFIN_HOME), and the cache directory. Intended for bug reports and +//! "what am I running?" diagnostics. + +const std = @import("std"); +const builtin = @import("builtin"); +const zfin = @import("../root.zig"); +const version = @import("../version.zig"); +const cli = @import("common.zig"); +const Date = @import("../models/date.zig").Date; + +/// Run the version command. +/// +/// `args` is the slice after `zfin version` (expects `--verbose`/`-v` or +/// nothing). Unknown args produce an error on stderr. +pub fn run( + config: zfin.Config, + args: []const []const u8, + out: *std.Io.Writer, +) !void { + var verbose = false; + for (args) |a| { + if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) { + verbose = true; + } else { + try cli.stderrPrint("Error: unknown argument to 'version': "); + try cli.stderrPrint(a); + try cli.stderrPrint("\n"); + return error.UnexpectedArg; + } + } + + try writeVersion(out, config, verbose); +} + +/// Render version info into `out`. Separated from `run` so tests can +/// exercise the formatting without going through flag parsing and the +/// stderr side-effect on unknown args. +/// +/// The rendered output is fully determined by `config` (for ZFIN_HOME and +/// cache_dir when verbose) plus the build-time `version` module. Build-time +/// values (version string, build timestamp, zig version, target) are +/// constants from the perspective of a single test run, so tests assert on +/// their presence/shape rather than exact values. +pub fn writeVersion( + out: *std.Io.Writer, + config: zfin.Config, + verbose: bool, +) !void { + const build_date_buf = blk: { + var buf: [10]u8 = undefined; + const d = Date.fromEpoch(version.build_timestamp); + const s = d.format(&buf); + break :blk .{ .buf = buf, .len = s.len }; + }; + const build_date = build_date_buf.buf[0..build_date_buf.len]; + + try out.print("zfin {s} (built {s})\n", .{ version.version_string, build_date }); + + if (!verbose) return; + + try out.print(" zig version: {s}\n", .{builtin.zig_version_string}); + try out.print(" build mode: {s}\n", .{@tagName(builtin.mode)}); + try out.print(" target: {s}-{s}\n", .{ + @tagName(builtin.cpu.arch), + @tagName(builtin.os.tag), + }); + if (config.zfin_home) |home| { + try out.print(" ZFIN_HOME: {s}\n", .{home}); + } else { + try out.print(" ZFIN_HOME: (unset)\n", .{}); + } + try out.print(" cache dir: {s}\n", .{config.cache_dir}); + if (version.isDirty()) { + try out.print(" note: built from a dirty worktree\n", .{}); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +/// Build a bare-minimum `Config` for testing. Does not allocate and does +/// not touch the environment; the caller specifies exactly which fields +/// matter for the behavior under test. The returned Config has no +/// allocator set so `deinit` is a no-op. +fn stubConfig(zfin_home: ?[]const u8, cache_dir: []const u8) zfin.Config { + return .{ + .cache_dir = cache_dir, + .zfin_home = zfin_home, + }; +} + +test "run: no args prints single-line banner" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const cfg = stubConfig(null, "/tmp/test-cache"); + try run(cfg, &.{}, &w); + const out = w.buffered(); + + // Banner shape: starts with "zfin ", contains " (built ", ends with newline. + try std.testing.expect(std.mem.startsWith(u8, out, "zfin ")); + try std.testing.expect(std.mem.indexOf(u8, out, " (built ") != null); + try std.testing.expect(std.mem.endsWith(u8, out, ")\n")); + + // Non-verbose: should NOT include any of the indented fields. + try std.testing.expect(std.mem.indexOf(u8, out, "zig version:") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "cache dir:") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME:") == null); + + // Single line of output. + const newline_count = std.mem.count(u8, out, "\n"); + try std.testing.expectEqual(@as(usize, 1), newline_count); +} + +test "run: --verbose includes all diagnostic fields" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const cfg = stubConfig("/some/zfin/home", "/tmp/expected-cache-dir"); + const args = [_][]const u8{"--verbose"}; + try run(cfg, &args, &w); + const out = w.buffered(); + + // Banner still first line + try std.testing.expect(std.mem.startsWith(u8, out, "zfin ")); + + // Every verbose field present + try std.testing.expect(std.mem.indexOf(u8, out, "zig version:") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "build mode:") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "target:") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME:") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "cache dir:") != null); + + // Config values appear in the rendered output (proof the config is + // actually consulted, not ignored). + try std.testing.expect(std.mem.indexOf(u8, out, "/some/zfin/home") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "/tmp/expected-cache-dir") != null); + + // The exact zig version string from builtin should appear. + try std.testing.expect(std.mem.indexOf(u8, out, builtin.zig_version_string) != null); +} + +test "run: -v short form equivalent to --verbose" { + var buf_long: [2048]u8 = undefined; + var buf_short: [2048]u8 = undefined; + var w_long: std.Io.Writer = .fixed(&buf_long); + var w_short: std.Io.Writer = .fixed(&buf_short); + const cfg = stubConfig("/zhome", "/cache"); + + try run(cfg, &[_][]const u8{"--verbose"}, &w_long); + try run(cfg, &[_][]const u8{"-v"}, &w_short); + + try std.testing.expectEqualStrings(w_long.buffered(), w_short.buffered()); +} + +test "run: unknown flag returns UnexpectedArg and writes nothing to out" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const cfg = stubConfig(null, "/cache"); + const args = [_][]const u8{"--bogus"}; + + try std.testing.expectError(error.UnexpectedArg, run(cfg, &args, &w)); + + // The error path returns before any writing to `out`. Stderr output + // (the "unknown argument" line) goes through cli.stderrPrint directly + // and is not observable via the fixed writer. + try std.testing.expectEqual(@as(usize, 0), w.buffered().len); +} + +test "writeVersion: ZFIN_HOME=null renders '(unset)'" { + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const cfg = stubConfig(null, "/some/cache"); + try writeVersion(&w, cfg, true); + const out = w.buffered(); + try std.testing.expect(std.mem.indexOf(u8, out, "ZFIN_HOME: (unset)") != null); + // And the bogus path should NOT appear. + try std.testing.expect(std.mem.indexOf(u8, out, "(some/home)") == null); +} + +test "writeVersion: build date parses as a valid YYYY-MM-DD" { + var buf: [512]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const cfg = stubConfig(null, "/cache"); + try writeVersion(&w, cfg, false); + const out = w.buffered(); + + // Extract the text between "(built " and ")". + const prefix = "(built "; + const start = (std.mem.indexOf(u8, out, prefix) orelse return error.MissingPrefix) + prefix.len; + const end = std.mem.indexOfScalarPos(u8, out, start, ')') orelse return error.MissingCloseParen; + const date_str = out[start..end]; + + // Format: exactly 10 chars, positions 4 and 7 are '-'. + try std.testing.expectEqual(@as(usize, 10), date_str.len); + try std.testing.expectEqual(@as(u8, '-'), date_str[4]); + try std.testing.expectEqual(@as(u8, '-'), date_str[7]); + // Year is plausibly recent-ish (>= 2020, < 2100). + const year = try std.fmt.parseInt(u16, date_str[0..4], 10); + try std.testing.expect(year >= 2020 and year < 2100); +} + +test "Date.fromEpoch round-trip for build timestamp" { + // Sanity-check the Date conversion used for build_date formatting. + const ts: i64 = 1_745_222_400; // 2025-04-21 00:00 UTC + const d = Date.fromEpoch(ts); + var buf: [10]u8 = undefined; + const s = d.format(&buf); + try std.testing.expectEqualStrings("2025-04-21", s); +} diff --git a/src/main.zig b/src/main.zig index f16e9ac..b53866f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,6 +24,7 @@ const usage = \\ audit [opts] Reconcile portfolio against brokerage export \\ cache stats Show cache statistics \\ cache clear Clear all cached data + \\ version [-v] Show zfin version and build info \\ \\Global options (must appear before the subcommand): \\ --no-color Disable colored output @@ -205,6 +206,16 @@ pub fn main() !u8 { return 0; } + // Version: doesn't need DataService; uses build_info + Config paths. + if (std.mem.eql(u8, command, "version")) { + commands.version.run(config, cmd_args, out) catch |err| switch (err) { + error.UnexpectedArg => return 1, + else => return err, + }; + try out.flush(); + return 0; + } + var svc = zfin.DataService.init(allocator, config); defer svc.deinit(); @@ -217,7 +228,8 @@ pub fn main() !u8 { !std.mem.eql(u8, command, "audit") and !std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "contributions") and - !std.mem.eql(u8, command, "portfolio"); + !std.mem.eql(u8, command, "portfolio") and + !std.mem.eql(u8, command, "version"); if (symbol_cmd and cmd_args.len >= 1) { for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); } @@ -406,6 +418,7 @@ const commands = struct { const audit = @import("commands/audit.zig"); const enrich = @import("commands/enrich.zig"); const contributions = @import("commands/contributions.zig"); + const version = @import("commands/version.zig"); }; // ── Tests ──────────────────────────────────────────────────── diff --git a/src/version.zig b/src/version.zig new file mode 100644 index 0000000..0b1eeef --- /dev/null +++ b/src/version.zig @@ -0,0 +1,39 @@ +//! Build-time version info exposed via the `build_info` module. +//! +//! `version_string` is `git describe --tags --always --dirty` output (or the +//! `.version` field from `build.zig.zon` if git is unavailable at build time). +//! `build_timestamp` is the Unix epoch seconds at the moment the build +//! executed. Together they identify the binary precisely. +//! +//! Consumers (the `zfin version` command, snapshot metadata writers, bug +//! reports) should route through this module rather than importing +//! `build_info` directly, so the interface stays stable if we reshape the +//! build options later. + +const std = @import("std"); +const build_info = @import("build_info"); + +/// Version string, e.g. "v0.3.1", "v0.3.1-4-g1a2b3c4", "v0.3.1-dirty", +/// "1a2b3c4" (no tags yet), or a fallback from build.zig.zon. +pub const version_string: []const u8 = build_info.version; + +/// Unix epoch seconds at build time. Rendered as ISO date by the +/// `zfin version --verbose` command. +pub const build_timestamp: i64 = build_info.build_timestamp; + +/// True when `version_string` ends in `-dirty`, indicating the build was +/// produced from a worktree with uncommitted changes. +pub fn isDirty() bool { + return std.mem.endsWith(u8, version_string, "-dirty"); +} + +test "version_string is non-empty" { + try std.testing.expect(version_string.len > 0); +} + +test "build_timestamp is positive" { + // A legitimate build always runs after 2020 (roughly 1577836800). + // If this fires, either the host clock is badly wrong, or the build + // options didn't wire through. + try std.testing.expect(build_timestamp > 1_577_836_800); +}