//! A port's configuration is stored file-based with the following //! files: //! //! * 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: *const 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_path: fs.Path = .join(.{ port_root.name(), "info" }); const info_res = conf.Config.from_file(ctx.env.allocator, &info_path) 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, path: *const fs.Path) !void { var write_buf: [256]u8 = undefined; var writer = try fs.file_writer(path, &write_buf, .{ .create = true }); defer writer.deinit(); 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 info_file(self: Info) fs.Path { return .join(.{ self.root.name(), "info" }); } 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.findScalar(u8, major, '.')) |dot1| { minor = major[dot1 + 1 ..]; major = major[0..dot1]; if (std.mem.findScalar(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[.00000000r0] pub fn parse(text: []const u8) !Version { const minor_pos = std.mem.findScalar(u8, text, '.') orelse return error.invalid_version; const patch_pos = std.mem.findScalarPos(u8, text, minor_pos + 1, '.') orelse return error.invalid_version; const date_pos = std.mem.findScalarPos(u8, text, patch_pos + 1, '.') orelse text.len; const major = parse_int(u16, text[0..minor_pos]) orelse return error.invalid_version; const minor = parse_int(u16, text[minor_pos + 1 .. patch_pos]) orelse return error.invalid_version; const patch = parse_int(u32, text[patch_pos + 1 .. date_pos]) orelse return error.invalid_version; var date: u32 = 0; var revision: u32 = 0; if (date_pos < text.len) { const rev_pos = std.mem.findScalarPos(u8, text, date_pos + 1, 'r') orelse return error.invalid_version; date = parse_int(u32, text[date_pos + 1 .. rev_pos]) orelse return error.invalid_version; revision = parse_int(u32, text[rev_pos + 1 ..]) orelse return error.invalid_version; } return .{ .major = major, .minor = minor, .patch = patch, .date = date, .revision = revision, }; } fn from_file(ctx: Ctx, info: Info) !Version { const version_file = info.version_file(); const text = fs.read_file(ctx.env.allocator, &version_file) catch |err| { return if (err == error.FileNotFound) error.missing_version else err; }; defer ctx.env.dealloc(text); return try parse(std.mem.trim(u8, text, " \t\n")); } fn to_file(self: Version, path: *const fs.Path) !void { var write_buf: [64]u8 = undefined; var writer = try fs.file_writer(path, &write_buf, .{ .create = true }); defer writer.deinit(); 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(".{}r{}", .{ self.date, self.revision }); } } fn eq(left: Version, right: Version) bool { return @as(u128, @bitCast(left)) == @as(u128, @bitCast(right)); } 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); 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: *const 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}); var write_buf: [256]u8 = undefined; var writer = try fs.file_writer(path, &write_buf, .{ .create = true }); defer writer.deinit(); 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, iter: ?fs.Iterator = null, current: ?Info = null, pub fn deinit(self: *Iterator) void { if (self.iter) |*it| it.deinit(); 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.type == .dir) { self.current = try .from_file(self.ctx, entry.name); return self.current; } } } return null; } }; pub fn iterate(ctx: Ctx) !Iterator { const iter = fs.iterate(&ctx.tree.root) catch |err| { if (err == error.FileNotFound) return .{ .ctx = ctx }; return err; }; return .{ .ctx = ctx, .iter = iter, }; } 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); 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, }; ctx.env.info("===> cloning repository: {s}", .{repo.path.name()}); 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); const info_file = info.info_file(); const version_file = info.version_file(); const src_file = info.src_file(); try fs.mkdir_all(&port_root); ctx.env.info("===> writing port data: {s}", .{info.root.name()}); try info.to_file(&info_file); try opts.initial_version.to_file(&version_file); try source.to_file(&src_file); } pub fn edit_vars(ctx: Ctx, port_name: []const u8) !void { const info = try must_get(ctx, port_name); defer info.deinit(); const vars_file = info.vars_file(); try shell.edit_config_file_noverify(ctx.env, &vars_file); } pub fn import_vars(ctx: Ctx, port_name: []const u8, new_vars_file: *const 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); switch (parsed) { .conf => |c| c.deinit(), .err => |e| { ctx.env.err("{s} (line {d})", .{ e.msg, e.line }); }, } const vars_file = info.vars_file(); try fs.mv_file(new_vars_file, &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(); ctx.env.info("===> {s}: updating repository", .{info.name}); 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; ctx.env.info("===> {s}: updating port data", .{info.name}); const src_file = info.src_file(); const version_file = info.version_file(); try source.to_file(&src_file); try new_version.to_file(&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 }, }; const version_file = info.version_file(); try new_version.to_file(&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 distdir = ctx.env.ports_dist_dir(ctx.tree.name, port_name); return try fetch_port_distfiles_sorted(ctx.env.allocator, port_name, &distdir); } pub fn rollback(ctx: Ctx, port_name: []const u8, old_version: Version) !void { const info = try must_get(ctx, port_name); defer info.deinit(); const distdir = ctx.env.ports_dist_dir(ctx.tree.name, port_name); var distfiles = try fetch_distfiles(ctx.env.allocator, &distdir, .{ .port_name = port_name, .version = old_version, }); defer distfiles.deinit(ctx.env.allocator); if (distfiles.items.len == 0) { fatal("no distfiles found for port {s}", .{port_name}); } 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 iter = fs.iterate(&ctx.tree.root) catch |err| { if (err == error.FileNotFound) return; return err; }; defer iter.deinit(); while (try iter.next()) |entry| { if (entry.type == .dir) { 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}); const port_path = ctx.tree.port_path(info.name); try fs.rm(&port_dir); try fs.rm(&dist_dir); try fs.rm(&src_dir); try fs.rm(&port_path); } const Render_Options = struct { info: Info, version: Version, source: ?*const 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; // While generating the port files we write to the following working directories: // // * port files: //ports//* // * dist files: //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 wrk_port_dir = ctx.env.work_path(.{ ctx.tree.name, "ports", opts.info.name }); const wrk_dist_dir = ctx.env.work_path(.{ ctx.tree.name, "dist" }); try fs.rm(&wrk_port_dir); try fs.rm(&wrk_dist_dir); try fs.mkdir_all(&wrk_port_dir); try fs.mkdir_all(&wrk_dist_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()); for (template_conf.archives[0..template_conf.archives_len]) |*archive| { var distname_buf: [std.fs.max_name_bytes]u8 = undefined; const distname = assemble_distname(&distname_buf, .{ .dist_id = archive.id, .port_name = opts.info.name, .version = version_str, }); if (archive.id.len > 0) { var key_buf: [512]u8 = undefined; try predefined_vars.add_var(concat(&key_buf, &.{ ".distname.", archive.id }), distname); } else { try predefined_vars.add_var(".distname", distname); } } var make_portsdir_buf: [fs.max_path_bytes]u8 = undefined; var make_distdir_buf: [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(&wrk_port_dir, &port_dir); var new_distfiles_created = false; if (opts.source) |src| { var prepared_dist = false; defer { if (prepared_dist) src.repo.reset(ctx.env); } if (template_conf.prepare_target) |prepare_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_target, "}" }), make_portsdir }, .cwd = &port_dir, }); const has_target = switch (res) { .success => |out| has: { const output = out orelse break :has false; defer ctx.env.dealloc(output); const target_found = std.mem.trim(u8, output, " \t\n"); break :has std.mem.eql(u8, target_found, prepare_target); }, .failure => |out| { ctx.env.err("failed to check for makefile target `{s}`\n", .{prepare_target}); fatal("{s}", .{out orelse ""}); }, }; if (has_target) { var make_wrksrc_buf: [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_target }, .cwd = &port_dir, }); prepared_dist = true; } else { ctx.env.info("Target `{s}` not found (skipping)", .{prepare_target}); } } for (template_conf.archives[0..template_conf.archives_len]) |*archive| { var distname_buf: [std.fs.max_name_bytes]u8 = undefined; const distname = assemble_distname(&distname_buf, .{ .dist_id = archive.id, .port_name = opts.info.name, .version = version_str, }); const wrk_dist_file: fs.Path = .join(.{ wrk_dist_dir.name(), distname }); const dist_file: fs.Path = .join(.{ dist_dir.name(), distname }); if (archive.id.len > 0) { ctx.env.info("===> {s}: creating distfile [{s}]", .{ opts.info.name, archive.id }); } else { ctx.env.info("===> {s}: creating distfile", .{opts.info.name}); } var tar_rename_buf: [fs.max_path_bytes]u8 = undefined; const tar_rename = concat(&tar_rename_buf, &.{ ",^,", distname[0 .. distname.len - distname_ext.len], "/," }); var root = src.repo.path; if (archive.root) |archive_root| root.append(.{archive_root}); var tar_cmd: [8 + archive.paths.len][]const u8 = undefined; var tar_cmd_len: usize = 8; tar_cmd[0] = "tar"; tar_cmd[1] = "-czf"; tar_cmd[2] = wrk_dist_file.name(); tar_cmd[3] = "-C"; tar_cmd[4] = root.name(); tar_cmd[5] = "-s"; tar_cmd[6] = tar_rename; tar_cmd[7] = "--exclude-vcs"; if (archive.paths_len == 0) { tar_cmd[tar_cmd_len] = "."; tar_cmd_len += 1; } else { var existings_paths: usize = 0; for (archive.paths[0..archive.paths_len]) |path| { const abs_path: fs.Path = .join(.{ root.name(), path }); if (fs.path_exists(&abs_path)) { tar_cmd[tar_cmd_len] = path; tar_cmd_len += 1; existings_paths += 1; } else { ctx.env.warn("cannot find path in archive root : {s}", .{path}); } } if (existings_paths == 0) { tar_cmd[tar_cmd_len] = "--files-from"; tar_cmd_len += 1; tar_cmd[tar_cmd_len] = "/dev/null"; tar_cmd_len += 1; } } shell.run(.{ .env = ctx.env, .argv = tar_cmd[0..tar_cmd_len], }); try fs.mv_file(&wrk_dist_file, &dist_file); ctx.env.info("distfile: {s}", .{dist_file.name()}); new_distfiles_created = true; } } ctx.env.info("===> {s}: creating port's distinfo", .{opts.info.name}); shell.run(.{ .env = ctx.env, .argv = &.{ "make", make_portsdir, make_distdir, "makesum" }, .cwd = &port_dir, }); if (new_distfiles_created) { ctx.env.info("===> {s}: cleaning up old distfiles", .{opts.info.name}); var history = fetch_port_distfiles_sorted(ctx.env.allocator, opts.info.name, &dist_dir) catch |err| { ctx.env.warn("cannot fetch distfiles from {s} ({s})", .{ dist_dir.name(), @errorName(err) }); return; }; defer history.deinit(ctx.env.allocator); if (history.items.len > 0) { var version = history.items[0].version; var visited: usize = 1; for (history.items[1..]) |*distfile| { if (!Version.eq(distfile.version, version)) { visited += 1; version = distfile.version; } if (visited > ctx.env.distfiles_history) { fs.rm(&distfile.path) catch |err| { ctx.env.warn("cannot remove {s} ({s})", .{ distfile.path.name(), @errorName(err) }); }; } } } } } /// Get a list of all distfiles of a single port sorted from new to old. fn fetch_port_distfiles_sorted(allocator: std.mem.Allocator, port_name: []const u8, dist_dir: *const fs.Path) !std.ArrayList(Distfile) { const distfiles = try fetch_distfiles(allocator, dist_dir, .{ .port_name = port_name }); std.mem.sortUnstable(Distfile, distfiles.items, {}, struct { pub fn gt(_: void, left: Distfile, right: Distfile) bool { return if (Version.eq(right.version, left.version)) std.mem.lessThan(u8, right.path.name(), left.path.name()) else Version.less(right.version, left.version); } }.gt); return distfiles; } const Distfile_Filter = struct { port_name: []const u8, version: ?Version = null, }; fn fetch_distfiles(allocator: std.mem.Allocator, dist_dir: *const fs.Path, filter: Distfile_Filter) !std.ArrayList(Distfile) { var iter = fs.iterate(dist_dir) catch |err| { if (err == error.FileNotFound) return .empty; return err; }; defer iter.deinit(); var distfiles: std.ArrayList(Distfile) = .empty; while (try iter.next()) |entry| { if (entry.type != .file) continue; const distfile = entry.name; if (std.mem.startsWith(u8, distfile, filter.port_name) and std.mem.endsWith(u8, distfile, distname_ext)) { const last_dash = std.mem.lastIndexOfScalar(u8, distfile, '-') orelse continue; const version = Version.parse(distfile[last_dash + 1 .. distfile.len - distname_ext.len]) catch continue; if (filter.version) |expected_version| { if (!Version.eq(expected_version, version)) { continue; } } try distfiles.append(allocator, .{ .version = version, .path = .join(.{ dist_dir.name(), distfile }), }); } } return distfiles; } const Distname_Parts = struct { dist_id: []const u8, port_name: []const u8, version: []const u8, }; /// Build a distname that matches FreeBSD's default value for ${DISTNAME}. fn assemble_distname(buf: []u8, parts: Distname_Parts) []const u8 { return if (parts.dist_id.len > 0) concat(buf, &.{ parts.port_name, "-", parts.dist_id, "-", parts.version, distname_ext }) else return concat(buf, &.{ parts.port_name, "-", parts.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.20250709r0"); 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, 20250709), version.date); try std.testing.expectEqual(@as(u32, 0), version.revision); version = try .parse("2.1.3.20250429r7"); 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, 20250429), version.date); try std.testing.expectEqual(@as(u32, 7), version.revision); try std.testing.expectError(error.invalid_version, Version.parse("3.1.2.20250511")); } 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)); }