aboutsummaryrefslogtreecommitdiff
path: root/src/port.zig
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2025-01-01 14:58:28 +0100
committertsne <tsne.dev@outlook.com>2025-10-30 08:32:49 +0100
commit44e5ad763794a438ecfd50c8b7f6ea760ea82da5 (patch)
tree575a1fc72ceb1d7f052cf582abc1e038e27f69a3 /src/port.zig
downloadporteur-44e5ad763794a438ecfd50c8b7f6ea760ea82da5.tar.gz
initial commitHEADmain
Diffstat (limited to 'src/port.zig')
-rw-r--r--src/port.zig846
1 files changed, 846 insertions, 0 deletions
diff --git a/src/port.zig b/src/port.zig
new file mode 100644
index 0000000..81aa034
--- /dev/null
+++ b/src/port.zig
@@ -0,0 +1,846 @@
+//! 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: <wrk>/<tree>/ports/<port>/*
+ // * dist file: <wrk>/<tree>/dists/<distname>
+ //
+ 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));
+}