From 44e5ad763794a438ecfd50c8b7f6ea760ea82da5 Mon Sep 17 00:00:00 2001 From: tsne Date: Wed, 1 Jan 2025 14:58:28 +0100 Subject: initial commit --- src/port.zig | 846 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 846 insertions(+) create mode 100644 src/port.zig (limited to 'src/port.zig') 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: //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)); +} -- cgit v1.2.3