add version command

This commit is contained in:
Emil Lerch 2026-04-21 11:53:00 -07:00
parent 41b8008c03
commit f610b81474
3 changed files with 266 additions and 1 deletions

213
src/commands/version.zig Normal file
View file

@ -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);
}

View file

@ -24,6 +24,7 @@ const usage =
\\ audit [opts] Reconcile portfolio against brokerage export \\ audit [opts] Reconcile portfolio against brokerage export
\\ cache stats Show cache statistics \\ cache stats Show cache statistics
\\ cache clear Clear all cached data \\ cache clear Clear all cached data
\\ version [-v] Show zfin version and build info
\\ \\
\\Global options (must appear before the subcommand): \\Global options (must appear before the subcommand):
\\ --no-color Disable colored output \\ --no-color Disable colored output
@ -205,6 +206,16 @@ pub fn main() !u8 {
return 0; 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); var svc = zfin.DataService.init(allocator, config);
defer svc.deinit(); defer svc.deinit();
@ -217,7 +228,8 @@ pub fn main() !u8 {
!std.mem.eql(u8, command, "audit") and !std.mem.eql(u8, command, "audit") and
!std.mem.eql(u8, command, "analysis") and !std.mem.eql(u8, command, "analysis") and
!std.mem.eql(u8, command, "contributions") 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) { if (symbol_cmd and cmd_args.len >= 1) {
for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*); for (cmd_args[0]) |*c| c.* = std.ascii.toUpper(c.*);
} }
@ -406,6 +418,7 @@ const commands = struct {
const audit = @import("commands/audit.zig"); const audit = @import("commands/audit.zig");
const enrich = @import("commands/enrich.zig"); const enrich = @import("commands/enrich.zig");
const contributions = @import("commands/contributions.zig"); const contributions = @import("commands/contributions.zig");
const version = @import("commands/version.zig");
}; };
// ── Tests ──────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────

39
src/version.zig Normal file
View file

@ -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);
}