//! A port's configuration is stored file-based with the following //! directory structure: //! //! * info - category, template //! * src - repo, branch, commit hash, commit timestamp //! * version - version, commit hash, commit timestamp //! * vars - variables used by the port const std = @import("std"); const assert = std.debug.assert; const cli = @import("cli.zig"); const fs = @import("fs.zig"); const conf = @import("conf.zig"); const git = @import("git.zig"); const tree = @import("tree.zig"); const tmpl = @import("template.zig"); const Env = @import("env.zig"); const time = @import("time.zig"); const shell = @import("shell.zig"); const Time = time.Time; const fatal = cli.fatal; const distname_ext = ".tar.gz"; pub const Ctx = struct { env: Env, tree: tree.Tree, }; pub const Info = struct { root: fs.Path, info: conf.Config, name: []const u8, category: []const u8, template: []const u8, /// Create a new port info with the given values. Infos created with this function /// must not call `deinit`. pub fn init(t: tree.Tree, name: []const u8, category: []const u8, template: []const u8) Info { assert(name.len > 0); assert(category.len > 0); assert(template.len > 0); return .{ .root = t.port_path(name), .info = undefined, .name = name, .category = category, .template = template, }; } pub fn deinit(self: Info) void { self.info.deinit(); } fn from_file(ctx: Ctx, port_name: []const u8) !?Info { const port_root = ctx.tree.port_path(port_name); const info_file: fs.Path = .join(.{ port_root.name(), "info" }); const info_res = conf.Config.from_file(ctx.env.allocator, info_file.name()) catch { port_corrupted(port_name, "invalid info file"); }; const info_conf = switch (info_res) { .conf => |c| c, .err => port_corrupted(port_name, "invalid info file"), }; var port_cat: ?[]const u8 = null; var port_tmpl: ?[]const u8 = null; var info_conf_iter = info_conf.iterate(); while (info_conf_iter.next()) |v| { if (std.mem.eql(u8, v.key, "category")) { port_cat = v.val; } else if (std.mem.eql(u8, v.key, "template")) { port_tmpl = v.val; } } return .{ .root = port_root, .info = info_conf, .name = port_name, .category = port_cat orelse port_corrupted(port_name, "missing category"), .template = port_tmpl orelse port_corrupted(port_name, "missing template"), }; } fn to_file(self: Info) !void { const path: fs.Path = .join(.{ self.root.name(), "info" }); const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); defer file.close(); var write_buf: [256]u8 = undefined; var writer = file.writer(&write_buf); inline for (&[_]conf.Var{ .{ .key = "category", .val = self.category }, .{ .key = "template", .val = self.template }, }) |v| { try v.format(&writer.interface); try writer.interface.writeByte('\n'); } try writer.interface.flush(); } fn version_file(self: Info) fs.Path { return .join(.{ self.root.name(), "version" }); } fn src_file(self: Info) fs.Path { return .join(.{ self.root.name(), "src" }); } fn vars_file(self: Info) fs.Path { return .join(.{ self.root.name(), "vars" }); } }; pub const Version_String = struct { buf: [64]u8, len: usize, pub fn slice(self: *const Version_String) []const u8 { return self.buf[0..self.len]; } }; pub const Version = packed struct { revision: u32 = 0, date: u32 = 0, // yyyymmdd or 0 patch: u32, minor: u16, major: u16, /// Parse a version in the semver format "x.y.z", "x.y", or "x". pub fn parse_semver(text: []const u8) !Version { var major: []const u8 = text; var minor: []const u8 = ""; var patch: []const u8 = ""; if (std.mem.indexOfScalar(u8, major, '.')) |dot1| { minor = major[dot1 + 1 ..]; major = major[0..dot1]; if (std.mem.indexOfScalar(u8, minor, '.')) |dot2| { patch = minor[dot2 + 1 ..]; minor = minor[0..dot2]; } } return .{ .major = parse_int(u16, major) orelse return error.invalid_version, .minor = parse_int(u16, minor) orelse return error.invalid_version, .patch = parse_int(u32, patch) orelse return error.invalid_version, }; } /// Parse the version as it is written to a file: 0.0.0[.d00000000[.r0]] pub fn parse(text: []const u8) !Version { const dot1 = std.mem.indexOfScalar(u8, text, '.') orelse return error.invalid_version; const dot2 = std.mem.indexOfScalarPos(u8, text, dot1 + 1, '.') orelse return error.invalid_version; const dot3 = std.mem.indexOfScalarPos(u8, text, dot2 + 1, '.') orelse text.len; const major = text[0..dot1]; const minor = text[dot1 + 1 .. dot2]; const patch = text[dot2 + 1 .. dot3]; var date: ?[]const u8 = null; var revision: ?[]const u8 = null; if (dot3 < text.len) { if (dot3 + 1 == text.len or text[dot3 + 1] != 'd') return error.invalid_version; date = text[dot3 + 2 ..]; if (std.mem.indexOfScalarPos(u8, text, dot3 + 2, '.')) |dot4| { if (dot4 + 1 == text.len or text[dot4 + 1] != 'r') return error.invalid_version; date = text[dot3 + 2 .. dot4]; revision = text[dot4 + 2 ..]; } } return .{ .major = parse_int(u16, major) orelse return error.invalid_version, .minor = parse_int(u16, minor) orelse return error.invalid_version, .patch = parse_int(u32, patch) orelse return error.invalid_version, .date = if (date) |d| parse_int(u32, d) orelse return error.invalid_version else 0, .revision = if (revision) |r| parse_int(u32, r) orelse return error.invalid_version else 0, }; } fn from_file(ctx: Ctx, info: Info) !Version { const text = try fs.read_file(ctx.env.allocator, info.version_file()) orelse return error.missing_version; defer ctx.env.dealloc(text); const s = std.mem.trim(u8, text, " \t\n"); var major: []const u8 = s; var minor: []const u8 = ""; var patch: []const u8 = ""; if (std.mem.indexOfScalar(u8, major, '.')) |dot1| { minor = major[dot1 + 1 ..]; major = major[0..dot1]; if (std.mem.indexOfScalar(u8, minor, '.')) |dot2| { patch = minor[dot2 + 1 ..]; minor = minor[0..dot2]; } } return try parse(std.mem.trim(u8, text, " \t\n")); } fn to_file(self: Version, path: fs.Path) !void { const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); defer file.close(); var write_buf: [64]u8 = undefined; var writer = file.writer(&write_buf); try self.format(&writer.interface); try writer.interface.writeByte('\n'); try writer.interface.flush(); } pub fn to_string(self: Version) Version_String { var str: Version_String = undefined; const printed = std.fmt.bufPrint(&str.buf, "{f}", .{self}) catch unreachable; str.len = printed.len; return str; } pub fn format(self: Version, w: *std.io.Writer) std.io.Writer.Error!void { try w.print("{}.{}.{}", .{ self.major, self.minor, self.patch }); if (self.date != 0) { try w.print(".d{}", .{self.date}); if (self.revision != 0) { try w.print(".r{}", .{self.revision}); } } } fn less(left: Version, right: Version) bool { return @as(u128, @bitCast(left)) < @as(u128, @bitCast(right)); } fn date_from_timestamp(timestamp: u64) u32 { const tm = Time.from_timestamp(timestamp); const y: u32 = tm.year; const m: u32 = tm.month; const d: u32 = tm.day; return y * 10000 + m * 100 + d; } }; pub const Source = struct { raw: conf.Config, repo: git.Repo, commit: git.Commit, fn from_file(ctx: Ctx, info: Info) !Source { const src_file = info.src_file(); const raw_res = try conf.Config.from_file(ctx.env.allocator, src_file.name()); const raw = switch (raw_res) { .conf => |c| c, .err => port_corrupted(info.name, "invalid source file"), }; var repo_url: ?[]const u8 = null; var repo_branch: ?[]const u8 = null; var repo_commit: ?[]const u8 = null; var iter = raw.iterate(); while (iter.next()) |v| { if (std.mem.eql(u8, v.key, "repo")) { repo_url = v.val; } else if (std.mem.eql(u8, v.key, "branch")) { repo_branch = v.val; } else if (std.mem.eql(u8, v.key, "commit")) { repo_commit = v.val; } } const commit_str = repo_commit orelse port_corrupted(info.name, "missing commit"); const commit = git.Commit.parse(commit_str) catch port_corrupted(info.name, "invalid commit"); return .{ .raw = raw, .repo = .{ .path = ctx.env.ports_src_path(ctx.tree.name, .{info.name}), .url = repo_url orelse port_corrupted(info.name, "missing repo"), .branch = repo_branch orelse port_corrupted(info.name, "missing branch"), }, .commit = commit, }; } fn to_file(self: Source, path: fs.Path) !void { var commit_buf: [64]u8 = undefined; var commit_str: std.io.Writer = .fixed(&commit_buf); try commit_str.print("{f}", .{self.commit}); const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); defer file.close(); var write_buf: [256]u8 = undefined; var writer = file.writer(&write_buf); inline for (&[_]conf.Var{ .{ .key = "repo", .val = self.repo.url }, .{ .key = "branch", .val = self.repo.branch }, .{ .key = "commit", .val = commit_str.buffered() }, }) |v| { try v.format(&writer.interface); try writer.interface.writeByte('\n'); } try writer.interface.flush(); } pub fn deinit(self: Source) void { self.raw.deinit(); } }; pub const Iterator = struct { ctx: Ctx, dir: ?std.fs.Dir = null, iter: ?std.fs.Dir.Iterator = null, current: ?Info = null, pub fn deinit(self: *Iterator) void { if (self.dir) |*dir| dir.close(); if (self.current) |p| p.deinit(); } pub fn next(self: *Iterator) !?Info { if (self.current) |p| { p.deinit(); self.current = null; } if (self.iter) |*iter| { while (try iter.next()) |entry| { if (entry.kind == .directory) { self.current = try .from_file(self.ctx, entry.name); return self.current; } } } return null; } }; pub fn iterate(ctx: Ctx) !Iterator { const dir = std.fs.openDirAbsoluteZ(ctx.tree.root.name(), .{ .iterate = true }) catch |err| { if (err == error.FileNotFound) { return .{ .ctx = ctx }; } return err; }; return .{ .ctx = ctx, .dir = dir, .iter = dir.iterate(), }; } pub fn exists(ctx: Ctx, port_name: []const u8) !bool { const port_root = ctx.tree.port_path(port_name); const info = try fs.dir_info(port_root); return info.exists; } pub fn get(ctx: Ctx, port_name: []const u8) !?Info { return .from_file(ctx, port_name); } pub fn must_get(ctx: Ctx, port_name: []const u8) !Info { return try get(ctx, port_name) orelse { fatal("port not found: {s}", .{port_name}); }; } pub fn read_version(ctx: Ctx, info: Info) !Version { return .from_file(ctx, info); } pub fn read_source(ctx: Ctx, info: Info) !Source { return try .from_file(ctx, info); } pub fn read_vars(ctx: Ctx, info: Info) !conf.Config { const vars_file = info.vars_file(); const conf_res = try conf.Config.from_file(ctx.env.allocator, vars_file.name()); return switch (conf_res) { .conf => |c| c, .err => |e| { ctx.env.err("{s} (line {d})", .{ e.msg, e.line }); fatal("invalid variable file for port {s}", .{info.name}); }, }; } pub const Create_Options = struct { repo_url: []const u8, repo_branch: []const u8, initial_version: Version, }; pub fn create(ctx: Ctx, info: Info, opts: Create_Options) !void { const repo: git.Repo = .{ .path = ctx.env.ports_src_path(ctx.tree.name, .{info.name}), .url = opts.repo_url, .branch = opts.repo_branch, }; try repo.clone(ctx.env); errdefer fs.rm(repo.path) catch {}; const source: Source = .{ .raw = undefined, .repo = repo, .commit = .init_empty(), }; const port_root = ctx.tree.port_path(info.name); try fs.mkdir_all(port_root); try info.to_file(); try opts.initial_version.to_file(info.version_file()); try source.to_file(info.src_file()); } pub fn edit_vars(ctx: Ctx, port_name: []const u8) !void { const info = try must_get(ctx, port_name); defer info.deinit(); try shell.edit_config_file_noverify(ctx.env, info.vars_file()); } pub fn import_vars(ctx: Ctx, port_name: []const u8, new_vars_file: fs.Path) !void { const info = try must_get(ctx, port_name); defer info.deinit(); const parsed = try conf.Config.from_file(ctx.env.allocator, new_vars_file.name()); switch (parsed) { .conf => |c| c.deinit(), .err => |e| { ctx.env.err("{s} (line {d})", .{ e.msg, e.line }); }, } try fs.mv_file(new_vars_file, info.vars_file()); } pub const Update_Options = struct { force: bool, }; /// Returns null if the port was up-to-date. pub fn update_version(ctx: Ctx, port_name: []const u8, options: Update_Options) !?Version { const info = try must_get(ctx, port_name); defer info.deinit(); var source: Source = try .from_file(ctx, info); defer source.deinit(); const old_version: Version = try .from_file(ctx, info); const old_commit = source.commit; const new_commit = try source.repo.update(ctx.env); if (!options.force) { if (new_commit.timestamp < old_commit.timestamp or std.mem.eql(u8, old_commit.hash, new_commit.hash)) { return null; } } var new_version = old_version; new_version.date = Version.date_from_timestamp(time.now()); if (new_version.date == old_version.date) { new_version.revision += 1; } else { assert(old_version.date < new_version.date); new_version.revision = 0; } source.commit = new_commit; try source.to_file(info.src_file()); try new_version.to_file(info.version_file()); try render_port(ctx, .{ .info = info, .version = new_version, .source = source, }); return new_version; } pub const Bump_Options = struct { which: enum { major, minor, patch }, }; pub fn bump_version(ctx: Ctx, port_name: []const u8, options: Bump_Options) !Version { const info = try must_get(ctx, port_name); defer info.deinit(); const version: Version = try .from_file(ctx, info); const source: Source = try .from_file(ctx, info); defer source.deinit(); const new_version: Version = switch (options.which) { .major => .{ .major = version.major + 1, .minor = 0, .patch = 0 }, .minor => .{ .major = version.major, .minor = version.minor + 1, .patch = 0 }, .patch => .{ .major = version.major, .minor = version.minor, .patch = version.patch + 1 }, }; try new_version.to_file(info.version_file()); try render_port(ctx, .{ .info = info, .version = new_version, .source = source, }); return new_version; } pub const Distfile = struct { version: Version, path: fs.Path, }; pub fn list_distfiles(ctx: Ctx, port_name: []const u8) !std.ArrayList(Distfile) { const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, port_name); return try list_distfiles_sorted(ctx.env.allocator, port_name, dist_dir); } pub fn rollback(ctx: Ctx, port_name: []const u8, old_version: Version) !void { const info = try must_get(ctx, port_name); defer info.deinit(); var version_buf: [64]u8 = undefined; const version_str = std.fmt.bufPrint(&version_buf, "{f}", .{old_version}) catch unreachable; var distname_buf: [std.fs.max_name_bytes]u8 = undefined; const distname = assemble_distname(&distname_buf, info.name, version_str); var dist_file = ctx.env.ports_dist_dir(ctx.tree.name, info.name); dist_file.append(.{distname}); if (!try fs.file_exists(dist_file)) { fatal("distfile {s} cannot be found", .{distname}); } try render_port(ctx, .{ .info = info, .version = old_version, .source = null, }); } pub fn remove(ctx: Ctx, port_name: []const u8) !bool { const p = try get(ctx, port_name) orelse return false; try remove_existing_port(ctx, p); return true; } pub inline fn remove_all(ctx: Ctx) !void { var dir = std.fs.openDirAbsoluteZ(ctx.tree.root.name(), .{ .iterate = true }) catch |err| { return switch (err) { error.FileNotFound => return, error.BadPathName, error.InvalidUtf8 => error.BadPathName, error.NameTooLong => unreachable, else => err, }; }; defer dir.close(); var iter = dir.iterate(); while (try iter.next()) |entry| { if (entry.kind == .directory) { if (try get(ctx, entry.name)) |p| { try remove_existing_port(ctx, p); } } } } fn remove_existing_port(ctx: Ctx, info: Info) !void { const port_dir = ctx.env.ports_tree_path(ctx.tree.name, .{ info.category, info.name }); const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, info.name); const src_dir = ctx.env.ports_src_path(ctx.tree.name, .{info.name}); try fs.rm(port_dir); try fs.rm(dist_dir); try fs.rm(src_dir); try fs.rm(ctx.tree.port_path(info.name)); } const Render_Options = struct { info: Info, version: Version, source: ?Source, // null if it already exists }; fn render_port(ctx: Ctx, opts: Render_Options) !void { if (opts.source) |src| { if (src.commit.hash.len == 0) { ctx.env.info("port {s} does not reference a commit (skipping rendering)", .{opts.info.name}); return; } } var version_buf: [64]u8 = undefined; const version_str = std.fmt.bufPrint(&version_buf, "{f}", .{opts.version}) catch unreachable; var distname_buf: [std.fs.max_name_bytes]u8 = undefined; const distname = assemble_distname(&distname_buf, opts.info.name, version_str); // While generating the port files we write to the following working directories: // // * port files: //ports//* // * dist file: //dists/ // const port_dir = ctx.env.ports_tree_path(ctx.tree.name, .{ opts.info.category, opts.info.name }); const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, opts.info.name); const dist_file: fs.Path = .join(.{ dist_dir.name(), distname }); const wrk_port_dir = ctx.env.work_path(.{ ctx.tree.name, "ports", opts.info.name }); const wrk_dist_file = ctx.env.work_path(.{ ctx.tree.name, "dist", distname }); try fs.rm(wrk_port_dir); try fs.rm(wrk_dist_file.dir().?); try fs.mkdir_all(wrk_port_dir); try fs.mkdir_all(wrk_dist_file.dir().?); const template: tmpl.Template = .init(ctx.env, opts.info.template); const template_conf = try tmpl.read_config(ctx.env, template); defer template_conf.deinit(); const port_vars = try read_vars(ctx, opts.info); defer port_vars.deinit(); var predefined_vars: conf.Config = .init(ctx.env.allocator); defer predefined_vars.deinit(); try predefined_vars.add_var(".portname", opts.info.name); try predefined_vars.add_var(".portversion", version_str); try predefined_vars.add_var(".category", opts.info.category); try predefined_vars.add_var(".distdir", dist_dir.name()); try predefined_vars.add_var(".distname", distname); var make_portsdir_buf: [std.fs.max_path_bytes]u8 = undefined; var make_distdir_buf: [std.fs.max_path_bytes]u8 = undefined; const make_portsdir = concat(&make_portsdir_buf, &.{ "PORTSDIR=", ctx.env.ports_dir }); const make_distdir = concat(&make_distdir_buf, &.{ "DISTDIR=", dist_dir.name() }); ctx.env.info("===> {s}: writing port files", .{opts.info.name}); const vars = [_]conf.Config{ predefined_vars, port_vars }; try template.render(ctx.env, &vars, wrk_port_dir); try fs.mv_dir(ctx.env.allocator, wrk_port_dir, port_dir); if (opts.source) |src| { var prepared_dist = false; if (template_conf.prepare_dist_target) |prepare_dist_target| { ctx.env.info("===> {s}: prepare distribution", .{opts.info.name}); var make_target_buf: [std.fs.max_name_bytes]u8 = undefined; const res = shell.exec(.{ .env = ctx.env, .argv = &.{ "make", "-V", concat(&make_target_buf, &.{ "${.ALLTARGETS:M", prepare_dist_target, "}" }), make_portsdir }, .cwd = port_dir, }); const has_target = switch (res) { .success => |out| has: { defer ctx.env.dealloc(out); const target_found = std.mem.trim(u8, out, " \t\n"); break :has std.mem.eql(u8, target_found, prepare_dist_target); }, .failure => |out| { ctx.env.err("failed to check for makefile target `{s}`\n", .{prepare_dist_target}); fatal("{s}", .{out}); }, }; if (has_target) { var make_wrksrc_buf: [std.fs.max_path_bytes]u8 = undefined; const make_wrksrc = concat(&make_wrksrc_buf, &.{ "WRKSRC=", src.repo.path.name() }); shell.run(.{ .env = ctx.env, .argv = &.{ "make", make_portsdir, make_distdir, make_wrksrc, prepare_dist_target }, .cwd = port_dir, }); prepared_dist = true; } else { ctx.env.info("Target `{s}` not found (skipping)", .{prepare_dist_target}); } } ctx.env.info("===> {s}: creating distfiles", .{opts.info.name}); var tar_rename_buf: [std.fs.max_path_bytes]u8 = undefined; const tar_rename = concat(&tar_rename_buf, &.{ ",^,", distname[0 .. distname.len - distname_ext.len], "/," }); shell.run(.{ .env = ctx.env, .argv = &.{ "tar", "-czf", wrk_dist_file.name(), "-C", src.repo.path.name(), "--exclude-vcs", "-s", tar_rename, "." }, }); if (prepared_dist) src.repo.reset(ctx.env); try fs.mv_file(wrk_dist_file, dist_file); ctx.env.info("distfile: {s}", .{dist_file.name()}); } ctx.env.info("===> {s}: creating distinfo", .{opts.info.name}); shell.run(.{ .env = ctx.env, .argv = &.{ "make", make_portsdir, make_distdir, "makesum" }, .cwd = port_dir, }); if (opts.source != null) { ctx.env.info("===> {s}: cleaning up old distfiles", .{opts.info.name}); var history = list_distfiles_sorted(ctx.env.allocator, opts.info.name, dist_dir) catch |err| { ctx.env.warn("cannot remove old distfiles from {s} ({s})", .{ dist_dir.name(), @errorName(err) }); return; }; defer history.deinit(ctx.env.allocator); if (history.items.len > ctx.env.distfiles_history) { var to_delete: usize = history.items.len - ctx.env.distfiles_history; var i: usize = history.items.len; while (i > 0 and to_delete > 0) { i -= 1; const dist: *const Distfile = &history.items[i]; if (!std.mem.endsWith(u8, dist.path.name(), distname)) { fs.rm(dist.path) catch |err| { ctx.env.warn("cannot remove {s} ({s})", .{ dist.path.name(), @errorName(err) }); }; to_delete -= 1; } } } } } /// Get a list of all distfiles sorted from new to old. fn list_distfiles_sorted(allocator: std.mem.Allocator, port_name: []const u8, dist_dir: fs.Path) !std.ArrayList(Distfile) { var iter = try fs.iterate_files(dist_dir) orelse return .empty; defer iter.deinit(); var distfiles: std.ArrayList(Distfile) = .empty; while (iter.next()) |distfile| { if (std.mem.startsWith(u8, distfile, port_name) and std.mem.endsWith(u8, distfile, distname_ext)) { const version = Version.parse(distfile[port_name.len + 1 .. distfile.len - distname_ext.len]) catch continue; try distfiles.append(allocator, .{ .version = version, .path = .join(.{ dist_dir.name(), distfile }), }); } } std.mem.sortUnstable(Distfile, distfiles.items, {}, struct { pub fn gt(_: void, left: Distfile, right: Distfile) bool { return Version.less(right.version, left.version); } }.gt); return distfiles; } /// Build a distname that matches FreeBSD's default value for ${DISTNAME}. fn assemble_distname(buf: []u8, port_name: []const u8, version: []const u8) []const u8 { return concat(buf, &.{ port_name, "-", version, distname_ext }); } fn parse_int(comptime Int: type, text: []const u8) ?Int { var res: Int = 0; for (text) |c| { if (c < '0' or c > '9') return null; res *= 10; res += @intCast(c - '0'); } return res; } fn concat(buf: []u8, parts: []const []const u8) []const u8 { var off: usize = 0; for (parts) |part| { @memcpy(buf[off .. off + part.len], part); off += part.len; } return buf[0..off]; } fn port_corrupted(port_name: []const u8, comptime reason: []const u8) noreturn { fatal("port {s} corrupted (" ++ reason ++ ")", .{port_name}); } test "parse version" { var version: Version = undefined; version = try .parse("1.2.3"); try std.testing.expectEqual(@as(u16, 1), version.major); try std.testing.expectEqual(@as(u16, 2), version.minor); try std.testing.expectEqual(@as(u16, 3), version.patch); try std.testing.expectEqual(@as(u32, 0), version.date); try std.testing.expectEqual(@as(u32, 0), version.revision); version = try .parse("3.2.1.d20180709"); try std.testing.expectEqual(@as(u16, 3), version.major); try std.testing.expectEqual(@as(u16, 2), version.minor); try std.testing.expectEqual(@as(u16, 1), version.patch); try std.testing.expectEqual(@as(u32, 20180709), version.date); try std.testing.expectEqual(@as(u32, 0), version.revision); version = try .parse("2.1.3.d20210429.r7"); try std.testing.expectEqual(@as(u16, 2), version.major); try std.testing.expectEqual(@as(u16, 1), version.minor); try std.testing.expectEqual(@as(u16, 3), version.patch); try std.testing.expectEqual(@as(u32, 20210429), version.date); try std.testing.expectEqual(@as(u32, 7), version.revision); } test "compare version" { var lhs: Version = undefined; var rhs: Version = undefined; lhs = .{ .major = 1, .minor = 2, .patch = 3 }; rhs = .{ .major = 1, .minor = 2, .patch = 3 }; try std.testing.expect(!Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3 }; rhs = .{ .major = 1, .minor = 2, .patch = 4 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3 }; rhs = .{ .major = 1, .minor = 3, .patch = 3 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3 }; rhs = .{ .major = 2, .minor = 2, .patch = 3 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3 }; rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250429 }; rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926 }; rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20251222 }; try std.testing.expect(Version.less(lhs, rhs)); try std.testing.expect(!Version.less(rhs, lhs)); }