//! `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); }