add version command
This commit is contained in:
parent
90be7a7306
commit
a7abc5f5d7
3 changed files with 266 additions and 1 deletions
213
src/commands/version.zig
Normal file
213
src/commands/version.zig
Normal 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);
|
||||
}
|
||||
15
src/main.zig
15
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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
39
src/version.zig
Normal file
39
src/version.zig
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue