aboutsummaryrefslogtreecommitdiff
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
downloadporteur-44e5ad763794a438ecfd50c8b7f6ea760ea82da5.tar.gz
initial commitHEADmain
-rw-r--r--.gitignore2
-rw-r--r--LICENSE22
-rw-r--r--Makefile14
-rw-r--r--README.md45
-rw-r--r--build.zig45
-rw-r--r--build.zig.zon13
-rw-r--r--porteur.conf.sample37
-rw-r--r--src/cli.zig1162
-rw-r--r--src/conf.zig382
-rw-r--r--src/env.zig200
-rw-r--r--src/fs.zig269
-rw-r--r--src/git.zig179
-rw-r--r--src/main.zig1195
-rw-r--r--src/port.zig846
-rw-r--r--src/shell.zig191
-rw-r--r--src/template.zig476
-rw-r--r--src/time.zig134
-rw-r--r--src/tree.zig67
-rw-r--r--template.conf.sample12
19 files changed, 5291 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3389c86
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.zig-cache/
+zig-out/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3feb138
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+Copyright 2025 Thomas Nitsche
+
+Redistribution and use in source and binary forms, with or without modification, are permitted
+provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice, this list of
+ conditions and the following disclaimer.
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list
+ of conditions and the following disclaimer in the documentation and/or other materials
+ provided with the distribution.
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used
+ to endorse or promote products derived from this software without specific prior written
+ permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
+OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ac7e938
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,14 @@
+.POSIX:
+
+PREFIX = zig-out
+
+all: install
+
+install:
+ zig build --prefix "$(PREFIX)" --release=small --verbose
+
+test:
+ zig build test --summary all
+
+freebsd:
+ zig build --prefix "$(PREFIX)" --release=small --verbose -Dpath-prefix=/usr/local -Dtarget=x86_64-freebsd
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..193bcfa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+# Porteur
+
+Porteur automates the creation of FreeBSD ports directly from Git repositories.
+It bridges the gap between source code and the FreeBSD ports framework with
+generating ready-to-build ports that work seamlessly with `poudriere` and
+native `make` tooling.
+
+
+## Concepts
+
+### Ports Trees
+A ports tree is a collection of ports similar to how FreeBSD defines it. It
+only contains the ports and can be used as an overlay in the ports tooling
+of FreeBSD. Porteur can manage multiple ports trees and defaults to the
+`default` tree.
+
+### Template
+A template defines all the files that the resulting FreeBSD port should contain.
+It is used to avoid repetitive port file definitions and uses variables to make
+these files usable by multiple ports.
+
+All templates must be defined in the directory `$PREFIX/etc/porteur/tmpl`.
+
+### Port
+A port in porteur is basically a reference to a Git repository and a template.
+From such a port porteur creates a FreeBSD port which is ready to use with
+`poudriere` or `make`.
+
+The Git repository is used to create the distfile that will be referenced in
+the Makefile. Porteur manages a local clone of this repository and bundles the
+source code of the branch that is configured.
+
+The template defines the contents of the resulting FreeBSD port. Each variable
+used in the template must be defined by the port in order to render correctly.
+
+
+## Quickstart
+
+1. Create a template in `$PREFIX/etc/porteur/tmpl`.
+2. Create a new port: `porteur port add my-app`
+3. Define the variables the referenced template needs: `porteur port vars --edit my-app`
+4. Trigger the first rendering of the port: `porteur port update my-app`
+5. Use the port with the FreeBSD ports tooling.
+
+For more information see `porteur --help`.
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..f93974d
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,45 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const path_prefix = b.option([]const u8, "path-prefix", "The prefix which is set for porteur's data directories.") orelse b.getInstallPath(.prefix, ".");
+
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const options = b.addOptions();
+ options.addOption([]const u8, "path_prefix", path_prefix);
+
+ const exe = b.addExecutable(.{
+ .name = "porteur",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ exe.root_module.addOptions("options", options);
+ b.installArtifact(exe);
+
+ const run_cmd = b.addRunArtifact(exe);
+ run_cmd.step.dependOn(b.getInstallStep());
+ if (b.args) |args| {
+ run_cmd.addArgs(args);
+ }
+
+ const tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ tests.root_module.addOptions("options", options);
+
+ const run_test = b.addRunArtifact(tests);
+ const test_step = b.step("test", "Run unit tests");
+ test_step.dependOn(&run_test.step);
+
+ const clean_step = b.step("clean", "Clean up");
+ clean_step.dependOn(&b.addRemoveDirTree(.{ .cwd_relative = b.install_path }).step);
+ clean_step.dependOn(&b.addRemoveDirTree(.{ .cwd_relative = b.pathFromRoot("zig-cache") }).step);
+}
diff --git a/build.zig.zon b/build.zig.zon
new file mode 100644
index 0000000..4894ce9
--- /dev/null
+++ b/build.zig.zon
@@ -0,0 +1,13 @@
+.{
+ .name = .porteur,
+ .version = "0.1.0",
+ .minimum_zig_version = "0.15.1",
+ .fingerprint = 0x4db6a2d6b8f31b3e,
+ .paths = .{
+ "build.zig",
+ "build.zig.zon",
+ "src",
+ "LICENSE",
+ "README.md",
+ },
+}
diff --git a/porteur.conf.sample b/porteur.conf.sample
new file mode 100644
index 0000000..77f8b12
--- /dev/null
+++ b/porteur.conf.sample
@@ -0,0 +1,37 @@
+# The directory where of the main ports collection.
+# Per default porteur tries to determine the ports directory from the
+# environment. It first reads the PORTSDIR environment variable. If no
+# such variable can be found `/usr/ports` will be used.
+portsdir = /usr/ports
+
+# The directory where porteur creates the distribution files for the ports
+# into. To make the path configuration more flexible, the two variables
+# `TREENAME` and `PORTNAME` can be used which resolve to the name of the
+# ports tree and the name of the port respectively. This value is set in
+# the predefined template variable `.dist.dir`.
+# If no directory is configured, `$PREFIX/porteur/distfiles/{{TREENAME}}`
+# will be used.
+distfiles-dir = /usr/ports/distfiles/{{TREENAME}}/{{PORTNAME}}
+
+# The number of distribution files created for a single port. This can be
+# used for rollbacks.
+# If no number is configured only the last distfile will be kept.
+distfiles-history = 3
+
+# The port's default category that will be used.
+# This will be used as the default value in the `porteur port add` command.
+# If none is configured, a category must be typed explicitly everytime a
+# port is created.
+category = lichterlabs
+
+# The default branch to use for a Git repository.
+# This will be used as the default value in the `porteur port add` command.
+# If none is configured, the `main` branch is used as default value.
+git-branch = dev
+
+# When editing configuration files or variables, porteur needs to know which
+# editor to use. Per default porteur tries to determine the editor from the
+# environment. It reads the VISUAL or the EDITOR environment variable (in
+# this particular order) and uses this editor. When nothing can be found
+# and nothing is configured it falls back to `vi`.
+editor = vim
diff --git a/src/cli.zig b/src/cli.zig
new file mode 100644
index 0000000..8c5e37a
--- /dev/null
+++ b/src/cli.zig
@@ -0,0 +1,1162 @@
+//! The CLI is defined by a set of commands with options and subcommands.
+//! A command is a type where with special declarations and fields. If a
+//! command has options, it needs to define an `option` field, that is a
+//! struct where all fields are valid options, i.e. created with `Option`.
+//! If a command has any subcommands a `Subcommands` type can be declared,
+//! which needs to be a tagged union that contains a command type for each
+//! supported subcommand. To provide sufficient help for each command a
+//! command type must define a `help` declaration of type `Command_Help`.
+//!
+//! The basic structure of a command type:
+//!
+//! struct {
+//! pub const help = Command_Help{ ... };
+//!
+//! pub const Subcommands = union(enum) {
+//! another: Another_Command,
+//! };
+//!
+//! options: struct{
+//! one: Option(u16, .{ ... }),
+//! two: Option([]const u8, .{ ... }),
+//! },
+//! }
+//!
+//! With all the setup above it is possible to "generate" a command help
+//! during compile time. There are two versions for a help text: a short
+//! and a long version. The short version displays the usage pattern of
+//! the command whereas the long version displays a detailed help text.
+
+const std = @import("std");
+const assert = std.debug.assert;
+
+var stdout_buf: [1024]u8 = undefined;
+var stdout_printer: Printer = .init(std.fs.File.stdout().writer(&stdout_buf));
+var stderr_printer: Printer = .init(std.fs.File.stderr().writer(&.{}));
+
+pub var stdout = &stdout_printer;
+pub var stderr = &stderr_printer;
+
+pub const Command_Help = struct {
+ pub const Section = struct {
+ /// The heading of the section. This must be a single line with
+ /// where each letter is uppercase.
+ heading: []const u8,
+ /// The text that should be displayed in the body of the section.
+ ///
+ /// Note: Single newlines will be replaced by spaces to format the
+ /// help text according to the terminal width. Multiple consecutive
+ /// newlines will be kept to keep paragraphs. If a line is indented
+ /// it will be printed as separate line. This can be used to display
+ /// lists or code, for example.
+ body: []const u8,
+ };
+
+ // The fully qualified command name, which includes the executable
+ // name and all the subcommands. All parts of the fully qualified
+ // name must be divided by a single space only.
+ //
+ // Note: The last part is used to determining the subcommand during
+ // argument parsing.
+ name: []const u8,
+ /// A synopsis of how to invoke the command. For each usage of the
+ /// command a separate invocation can be provided. It should give
+ /// a brief overview of how the command can be used.
+ /// An invocation pattern should be formatted in manpage style
+ /// (e.g. "program mycommand [-f|--foo]").
+ ///
+ /// Note: For better formatting a single pattern can be split into
+ /// multiple lines.
+ synopsis: []const []const u8,
+ /// A short description of what the command does. In the long help
+ /// it is displayed directly after the command name. In the short
+ /// help it is used to give a brief description of the command.
+ ///
+ /// Note: This must be a single line of text.
+ short_description: []const u8,
+ /// A detailed description of the functioning of the command. This
+ /// text is displayed in the long help. It should explain all the
+ /// details of the command.
+ ///
+ /// Note: Single newlines will be replaced by spaces to format the
+ /// help text according to the terminal width. Multiple consecutive
+ /// newlines will be kept to keep paragraphs. If a line is indented
+ /// it will be printed as separate line. This can be used to display
+ /// lists or code, for example.
+ long_description: []const u8,
+ /// Extra sections that should be displayed at the bottom of the
+ /// command help.
+ extra_sections: []const Section = &.{},
+};
+
+pub fn Option_Help(comptime T: type) type {
+ return struct {
+ /// The long name of an option without the leading double-dash.
+ ///
+ /// Note: Only alphanumeric characters or dashes are allowed, but
+ /// it must not start or end with a dash.
+ name: []const u8,
+ /// An option can have an optional short name which can be used as an
+ /// alternative to the long name. A short name is a single character.
+ ///
+ /// Note: A short name must be alphanumeric.
+ short_name: ?u8 = null,
+ /// Every non-boolean option takes an argument as its value. To improve
+ /// help message quality an argument must have a meaningful name that is
+ /// displayed in the help text of the corresponding command.
+ /// For boolean option on the other hand this field defaults to null and
+ /// will be ignored when printing a help text.
+ ///
+ /// Note: This must be a single word, i.e. only characters, numbers,
+ /// dashes, and underscores are allowed.
+ argument_name: ?[]const u8 = null,
+ /// A short description of what the option does. This text is displayed
+ /// in the short help of the corresponding command. It should give a brief
+ /// overview of what the option does.
+ ///
+ /// Note: This must be a single line of text.
+ short_description: []const u8,
+ /// A detailed description of the command option. This text is displayed
+ /// in the long help of the corresponding command. It should explain all
+ /// details of the option and can be as long as needed.
+ ///
+ /// Note: Single newlines will be replaced by spaces to format the
+ /// help text according to the terminal width. Multiple consecutive
+ /// newlines will be kept to keep paragraphs.
+ long_description: []const u8,
+ /// Default value that will be set if the user does not provide a specific
+ /// value. If this field is null the option value will also be null.
+ default_value: ?T = null,
+ };
+}
+
+pub fn Option(comptime T: type, comptime opt_help: Option_Help(T)) type {
+ comptime {
+ const has_argname = opt_help.argument_name != null and opt_help.argument_name.?.len > 0;
+ if (!has_argname and T != bool) {
+ @compileError("non-boolean options must have an argument name");
+ }
+
+ switch (@typeInfo(T)) {
+ .@"enum" => |info| {
+ for (info.fields) |field| {
+ for (field.name) |ch| {
+ switch (ch) {
+ 'a'...'z', 'A'...'Z', '0'...'9' => {},
+ else => @compileError("invalid enum '" ++ field.name ++ "' value in option " ++ opt_help.name),
+ }
+ }
+ }
+ },
+ else => {},
+ }
+ }
+
+ return struct {
+ pub const Type = T;
+ pub const help: Option_Help(T) = opt_help;
+
+ const Self = @This();
+
+ value: ?T,
+
+ fn init(self: *Self) void {
+ self.value = null;
+ }
+
+ pub fn value_or_default(self: Self) T {
+ if (opt_help.default_value == null) {
+ @compileError("option '" ++ opt_help.name ++ "' does not have default value");
+ }
+ return self.value orelse opt_help.default_value.?;
+ }
+
+ fn parse(self: *Self, name: []const u8, value: [:0]const u8) void {
+ if (self.value != null) {
+ fatal("duplicate option '{s}'", .{name});
+ }
+
+ self.value = switch (@typeInfo(T)) {
+ .bool => val: {
+ if (value.len != 0) {
+ fatal("boolean option '{s}' does not take a value", .{name});
+ }
+ break :val true;
+ },
+ .int => val: {
+ if (value.len == 0) {
+ fatal("missing value for option '{s}'", .{name});
+ }
+ break :val std.fmt.parseInt(T, value, 10) catch |err| switch (err) {
+ error.InvalidCharacter => fatal("option '{s}' requires a numeric value", .{name}),
+ error.Overflow => fatal("option '{s}' exceeds the maximum numeric value", .{name}),
+ };
+ },
+ .@"enum" => |info| val: {
+ if (info.fields.len == 0) {
+ @compileError("enum cli option " ++ @typeName(T) ++ " does not have any values");
+ }
+ if (value.len == 0) {
+ fatal("missing value for option '{s}'", .{name});
+ }
+ break :val std.meta.stringToEnum(T, value) orelse {
+ fatal("invalid value for option '{s}' (use " ++ join_enum_values(T) ++ ")", .{name});
+ };
+ },
+ .pointer => |info| val: {
+ if (info.child != u8 and info.size != .Slice) {
+ @compileError("pointer cli option " ++ @typeName(T) ++ " not supported");
+ }
+ break :val value;
+ },
+ else => @compileError("cli option type " ++ @typeName(T) ++ " not supported"),
+ };
+ }
+ };
+}
+
+pub const Args = struct {
+ /// The command we are currently in or null if we reached the end or positional arguments.
+ current_cmd: ?[]const u8,
+ iter: std.process.ArgIterator,
+ exe_name: []const u8,
+
+ pub fn init(allocator: std.mem.Allocator, comptime exe_name: []const u8, program: anytype) !Args {
+ comptime assert(exe_name.len > 0);
+
+ var iter = try std.process.ArgIterator.initWithAllocator(allocator);
+ errdefer iter.deinit();
+ _ = iter.skip(); // skip executable name
+ const cmd_info = Command_Info.from_command(switch (@typeInfo(@TypeOf(program))) {
+ .pointer => |info| info.child,
+ else => @compileError("expected pointer, got " ++ @typeName(@TypeOf(program))),
+ });
+ const current_cmd = parse_opts(&iter, cmd_info, program);
+ return Args{
+ .current_cmd = current_cmd,
+ .iter = iter,
+ .exe_name = exe_name,
+ };
+ }
+
+ pub fn deinit(self: *Args, allocator: std.mem.Allocator) void {
+ _ = allocator;
+ self.iter.deinit();
+ }
+
+ pub fn next_subcommand(self: *Args, comptime Command: type) ?Command.Subcommands {
+ const subcommands = comptime Command_Info.from_subcommand(Command);
+ comptime assert(subcommands.len > 0);
+
+ const arg = self.current_cmd orelse return null;
+ inline for (subcommands) |subcmd| {
+ if (std.mem.eql(u8, arg, subcmd.name)) {
+ var cmd: subcmd.type = undefined;
+ self.current_cmd = parse_opts(&self.iter, subcmd, &cmd);
+ return @unionInit(Command.Subcommands, subcmd.tag.?, cmd);
+ }
+ }
+ fatal_with_usage(Command, "unknown command '{s}' (see '{s} --help')", .{ arg, self.exe_name });
+ }
+
+ pub fn next_positional(self: *Args) ?[]const u8 {
+ if (self.current_cmd) |cmd| {
+ self.current_cmd = null;
+ return cmd;
+ }
+ self.current_cmd = null;
+ return self.iter.next();
+ }
+
+ /// Parse and fill all command options and return the current command.
+ fn parse_opts(iter: *std.process.ArgIterator, comptime cmd_info: Command_Info, cmd: *cmd_info.type) ?[]const u8 {
+ inline for (cmd_info.options) |opt| {
+ @field(cmd.options, opt.field).init();
+ }
+
+ args_loop: while (iter.next()) |arg| {
+ assert(arg.len > 0);
+ if (arg.len < 2 or arg[0] != '-') {
+ // We have either a single dash or a non-option here. Treat these as
+ // a positional or subcommand (however the executor chooses to use it).
+ return arg;
+ }
+
+ var opt_name: []const u8 = undefined;
+ if (arg[1] != '-') {
+ // We check for a short option name.
+ // If we have a boolean flag or an option name longer than a
+ // single character, we have both the name and the value in
+ // a single argument. Otherwise we fetch the next argument.
+ opt_name = arg[1..2];
+ if (opt_name[0] == 'h') {
+ if (arg.len > 2) {
+ fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{arg});
+ }
+ cmd_info.print_usage(stderr);
+ std.process.exit(0);
+ }
+
+ inline for (cmd_info.options) |opt| {
+ if (opt.short_name) |short_name| {
+ if (short_name == opt_name[0]) {
+ const value: [:0]const u8 = if (arg.len > 2 or opt.arg_name.len == 0) arg[2..] else next: {
+ const next_arg = iter.next();
+ if (next_arg == null or next_arg.?[0] == '-') {
+ fatal("missing value for option '{c}'", .{opt_name[0]});
+ }
+ break :next next_arg.?;
+ };
+ @field(cmd.options, opt.field).parse(opt_name, value);
+ continue :args_loop;
+ }
+ }
+ }
+ } else if (arg.len > 2) {
+ // We check for a long option name.
+ // If we don't have the format `name=value`, we get the
+ // next argument lazily, because for boolean flags we
+ // don't need any extra argument.
+ opt_name = arg[2..];
+ var value: ?[:0]const u8 = null;
+ if (std.mem.indexOfScalar(u8, arg, '=')) |eq| {
+ opt_name = arg[2..eq];
+ value = arg[eq + 1 ..];
+ }
+
+ if (std.mem.eql(u8, opt_name, "help")) {
+ cmd_info.print_help();
+ }
+
+ inline for (cmd_info.options) |opt| {
+ if (std.mem.eql(u8, opt_name, opt.long_name)) {
+ value = value orelse if (opt.arg_name.len == 0) "" else next_arg: {
+ const next = iter.next();
+ if (next == null or next.?[0] == '-') {
+ fatal("missing value for option '{s}'", .{opt_name});
+ }
+ break :next_arg next.?;
+ };
+ @field(cmd.options, opt.field).parse(opt_name, value.?);
+ continue :args_loop;
+ }
+ }
+ } else {
+ // A single "--" was detected which means the rest of the arguments
+ // are interpreted as positional. We are done parsing.
+ return null;
+ }
+
+ fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{opt_name});
+ }
+
+ return null;
+ }
+};
+
+pub const Print_Options = struct {
+ /// Indentation of the first line.
+ margin: u16 = 0,
+ /// Indentation of all lines except the first.
+ hanging_indent: u16 = 0,
+ /// The character by which the text should be split into multiple lines.
+ line_split: u8 = ' ',
+};
+
+const Usage_Column = struct {
+ cells: []const []const u8,
+ width: u16,
+};
+
+pub const Printer = struct {
+ writer: std.fs.File.Writer,
+ max_width: u16,
+
+ fn init(w: std.fs.File.Writer) Printer {
+ return Printer{
+ .writer = w,
+ .max_width = 80,
+ };
+ }
+
+ /// Drain the remaining buffered data and puts it to the sink.
+ pub fn flush(self: *Printer) void {
+ self.writer.interface.flush() catch {};
+ }
+
+ /// Write a value directly without splitting. This uses the formatting capabilities
+ /// of `std.fmt`.
+ pub fn write(self: *Printer, comptime fmt: []const u8, value: anytype) void {
+ self.writer.interface.print(fmt, value) catch {};
+ }
+
+ /// Write a value directly without splitting and append a newline. This uses the
+ /// formatting capabilities of `std.fmt`.
+ pub fn write_line(self: *Printer, comptime fmt: []const u8, value: anytype) void {
+ self.write(fmt ++ "\n", value);
+ }
+
+ /// Print the given text and split it into multiple lines if it exceeds
+ /// a certain width. The splitting behaviour can be configured using the
+ /// given print options.
+ pub fn print(self: *Printer, text: []const u8, opts: Print_Options) void {
+ if (text.len > 0) {
+ const max_len_first = self.max_width - opts.margin;
+ const max_len_others = self.max_width - opts.margin - opts.hanging_indent;
+
+ self.put_indent(opts.margin);
+ var rest = self.put_until_eol(text, max_len_first, opts.line_split);
+ while (rest.len > 0) {
+ self.put('\n');
+ self.put_indent(opts.margin + opts.hanging_indent);
+ rest = self.put_until_eol(rest, max_len_others, opts.line_split);
+ }
+ }
+ }
+
+ /// Same as `print`, but appends a newline at the end.
+ pub fn print_line(self: *Printer, text: []const u8, opts: Print_Options) void {
+ self.print(text, opts);
+ self.put('\n');
+ }
+
+ /// Print a formatted text and split it into multiple lines if if exceeds
+ /// a certain width. The splitting behaviour can be configured using the
+ /// given print options.
+ pub fn printf(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void {
+ if (@typeInfo(@TypeOf(args)).@"struct".fields.len == 0) {
+ self.print(format, opts);
+ } else {
+ // TODO: Shall we estimate the buffer size depending on the
+ // format string and arguments?
+ var buf: [2048]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, format, args) catch |err| switch (err) {
+ error.NoSpaceLeft => dots: {
+ buf[buf.len - 3] = '.';
+ buf[buf.len - 2] = '.';
+ buf[buf.len - 1] = '.';
+ break :dots buf[0..];
+ },
+ };
+ self.print(text, opts);
+ }
+ }
+
+ /// Same as `printf`, but appends a newline at the end.
+ pub fn printf_line(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void {
+ self.printf(format, args, opts);
+ self.put('\n');
+ }
+
+ /// Print multiple lines of text. This essentially calls `print_line` for each
+ /// given line of text.
+ pub fn print_lines(self: *Printer, text: []const u8, opts: Print_Options) void {
+ var rest = text;
+ while (std.mem.indexOfScalar(u8, rest, '\n')) |eol| {
+ self.print_line(rest[0..eol], opts);
+ rest = rest[eol + 1 ..];
+ }
+
+ if (rest.len > 0) {
+ self.print_line(rest, opts);
+ }
+ }
+
+ fn put(self: *Printer, c: u8) void {
+ self.writer.interface.writeByte(c) catch {};
+ }
+
+ fn put_str(self: *Printer, text: []const u8) void {
+ self.writer.interface.writeAll(text) catch {};
+ }
+
+ fn print_usage_table(self: *Printer, comptime left_column: Usage_Column, comptime right_column: Usage_Column, opts: Print_Options) void {
+ assert(opts.hanging_indent > 0);
+ assert(left_column.cells.len == right_column.cells.len);
+
+ const sep_len = 2;
+ const max_len: usize = @intCast(@min(left_column.width + sep_len + right_column.width, self.max_width - opts.margin));
+
+ if (left_column.width + sep_len > 2 * max_len / 3) {
+ // There is very little space for the right column. So we write each column
+ // to a separate line.
+ const left_margin = opts.margin;
+ const right_margin = opts.margin + opts.hanging_indent;
+ for (left_column.cells, right_column.cells) |left, right| {
+ self.print_line(left, .{ .margin = left_margin });
+ self.print_line(right, .{ .margin = right_margin });
+ }
+ } else {
+ // The left and the right column are on the same line. If the right column
+ // gets too long, we print multiple lines for the right column.
+ for (left_column.cells, right_column.cells) |left, right| {
+ assert(left.len <= left_column.width);
+ assert(right.len <= right_column.width);
+
+ self.put_indent(opts.margin);
+ self.put_str(left);
+ self.put_indent(left_column.width - left.len + sep_len);
+
+ const right_width = max_len - left_column.width - sep_len;
+ var rest = self.put_until_eol(right, right_width, opts.line_split);
+ while (rest.len > 0) {
+ self.put('\n');
+ self.put_indent(opts.margin + left_column.width + sep_len);
+ rest = self.put_until_eol(right, right_width, opts.line_split);
+ }
+ self.put('\n');
+ }
+ }
+ }
+
+ fn put_until_eol(self: *Printer, text: []const u8, line_len: usize, line_split: u8) []const u8 {
+ const width = @min(text.len, line_len);
+ var eol: usize = undefined;
+ if (text.len <= width) {
+ eol = text.len;
+ } else if (text[width] == line_split) {
+ eol = width;
+ } else if (std.mem.lastIndexOfScalar(u8, text[0..width], line_split)) |last_split| {
+ eol = last_split;
+ if (std.mem.allEqual(u8, text[0..last_split], line_split)) {
+ // There are leading line splits here, so we consider an indentation.
+ // We want to keep indentation intact and print to the next split.
+ eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len;
+ }
+ } else {
+ // We don't have a split char in the line. So we extend the line
+ // to the next space. This hopefully will not happen in the wild.
+ eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len;
+ }
+
+ self.put_str(text[0..eol]);
+ return if (eol < text.len) text[eol + 1 ..] else text[0..0];
+ }
+
+ fn put_indent(self: *Printer, n: usize) void {
+ const buf = [_]u8{' '} ** 32;
+ var remaining = n;
+ while (remaining > buf.len) : (remaining -= buf.len) {
+ self.put_str(&buf);
+ }
+ self.put_str(buf[0..remaining]);
+ }
+};
+
+pub fn fatal(comptime format: []const u8, args: anytype) noreturn {
+ stderr.printf_line(format, args, .{});
+ stderr.flush();
+ std.process.exit(1);
+}
+
+pub fn fatal_with_usage(comptime Command: type, comptime format: []const u8, args: anytype) noreturn {
+ const info = Command_Info.from_command(Command);
+ stderr.printf_line(format, args, .{});
+ stderr.put('\n');
+ info.print_usage(stderr);
+ std.process.exit(1);
+}
+
+/// Print the usage of the given command.
+/// This function automatically flushes.
+pub fn print_usage(p: *Printer, comptime Command: type) void {
+ Command_Info.from_command(Command).print_usage(p);
+ p.flush();
+}
+
+/// Print the help text of the given command.
+pub fn print_help(comptime Command: type) noreturn {
+ Command_Info.from_command(Command).print_help();
+}
+
+pub fn ask(comptime prompt: []const u8, args: anytype, buf: []u8) ?[]const u8 {
+ var stdin = std.fs.File.stdin().reader(buf);
+ while (true) {
+ stderr.printf(prompt, args, .{});
+ stderr.flush();
+ const line = stdin.interface.takeDelimiterExclusive('\n') catch |err| switch (err) {
+ error.StreamTooLong => {
+ stderr.printf_line("ERROR: The input exceeds the maximum of {} characters.", .{buf.len}, .{});
+ continue;
+ },
+ else => unreachable,
+ };
+
+ const input = std.mem.trim(u8, line, " \t\r");
+ return if (input.len == 0) null else input;
+ }
+}
+
+pub fn ask_confirmation(comptime prompt: []const u8, args: anytype, comptime default_answer: enum { yes, no }) bool {
+ comptime assert(prompt.len > 0 and prompt[prompt.len - 1] == '?');
+ const yes = "yes";
+ const no = "no";
+ const default_ans = switch (default_answer) {
+ .yes => yes,
+ .no => no,
+ };
+
+ var confirmbuf: [16]u8 = undefined;
+ return while (true) {
+ const ans = ask(prompt ++ " (" ++ yes ++ "/" ++ no ++ ") [" ++ default_ans ++ "]: ", args, &confirmbuf) orelse default_ans;
+ for (ans, 0..) |c, i| {
+ confirmbuf[i] = std.ascii.toLower(c);
+ }
+ if (std.mem.eql(u8, ans, yes[0..1]) or std.mem.eql(u8, ans, yes)) break true;
+ if (std.mem.eql(u8, ans, no[0..1]) or std.mem.eql(u8, ans, no)) break false;
+ };
+}
+
+const Pager = struct {
+ const Bold = struct {
+ text: []const u8,
+ dumb: bool,
+
+ pub fn format(self: Bold, w: *std.io.Writer) std.io.Writer.Error!void {
+ if (!self.dumb) try w.writeAll("\x1b[1m");
+ try w.writeAll(self.text);
+ if (!self.dumb) try w.writeAll("\x1b[m");
+ }
+ };
+
+ pid: ?std.posix.pid_t,
+ printer: Printer,
+ dumb: bool,
+
+ fn init(out_buf: []u8) Pager {
+ if (!stdout.writer.file.isTty()) {
+ return .{ .pid = null, .printer = stdout_printer, .dumb = true };
+ }
+
+ const fds = std.posix.pipe() catch return .{ .pid = null, .printer = stdout_printer, .dumb = true };
+ const fdin = fds[0];
+ const fdout = fds[1];
+
+ const pid = std.posix.fork() catch {
+ std.posix.close(fdin);
+ std.posix.close(fdout);
+ return .{ .pid = null, .printer = stdout_printer, .dumb = true };
+ };
+
+ if (pid == 0) {
+ // pager process
+ std.posix.close(fdout);
+ std.posix.dup2(fdin, std.posix.STDIN_FILENO) catch unreachable;
+
+ var env: [512:null]?[*:0]const u8 = undefined;
+ var envlen: usize = 0;
+ const environ = if (std.os.environ.len < 512) std.os.environ else std.os.environ[0..511];
+ for (environ) |e| {
+ env[envlen] = e;
+ envlen += 1;
+ }
+ env[envlen] = null;
+
+ // TODO: Allow environment PAGER to define custom pagers.
+ const pager_argv: [*:null]const ?[*:0]const u8 = &.{ "less", "-R", null };
+
+ std.posix.execvpeZ(pager_argv[0].?, pager_argv, &env) catch unreachable;
+ std.process.exit(1);
+ } else {
+ // application process
+ std.posix.close(fdin);
+ var out: std.fs.File = .{ .handle = fdout };
+ return .{
+ .pid = pid,
+ .printer = Printer.init(out.writer(out_buf)),
+ .dumb = if (std.posix.getenv("TERM")) |term| std.mem.eql(u8, term, "dumb") else true,
+ };
+ }
+ }
+
+ fn deinit(self: *Pager) noreturn {
+ self.printer.flush();
+ if (self.pid) |pid| {
+ self.printer.writer.file.close();
+ _ = std.posix.waitpid(pid, 0);
+ }
+ std.process.exit(0);
+ }
+
+ fn bold(self: *Pager, text: []const u8) Bold {
+ return .{ .text = text, .dumb = self.dumb };
+ }
+};
+
+/// Information about a single command.
+const Command_Info = struct {
+ type: type,
+ name: []const u8, // last part of the fully qualified name
+ fq_name: []const u8, // fully qualified name separated by spaces
+ synopsis: []const []const u8,
+ short_description: []const u8,
+ long_description: []const u8,
+ options: []const Option_Info,
+ tag: ?[]const u8, // The tag in the subcommands union.
+ sections: []const Command_Help.Section,
+
+ fn from_command(comptime Command: type) Command_Info {
+ const help: Command_Help = comptime hlp: {
+ if (!@hasDecl(Command, "help")) {
+ @compileError("missing help declaration in command " ++ @typeName(Command));
+ }
+ if (@TypeOf(Command.help) != Command_Help) {
+ @compileError("invalid help type in command " ++ @typeName(Command));
+ }
+ break :hlp Command.help;
+ };
+
+ const fq_name: []const u8 = comptime fqn: {
+ if (help.name.len == 0) {
+ @compileError("missing command name for " ++ @typeName(Command));
+ }
+
+ var first_non_space: usize = 0;
+ while (first_non_space < help.name.len and help.name[first_non_space] == ' ') {
+ first_non_space += 1;
+ }
+
+ var last_non_space = first_non_space;
+ if (last_non_space < help.name.len) {
+ var i = last_non_space;
+ while (i < help.name.len) : (i += 1) {
+ switch (help.name[i]) {
+ ' ' => {},
+ 'a'...'z', '0'...'9', '-' => last_non_space = i,
+ else => @compileError("invalid command name for " ++ @typeName(Command)),
+ }
+ }
+ }
+
+ if (last_non_space < help.name.len) last_non_space += 1;
+ break :fqn help.name[0..last_non_space];
+ };
+
+ const name: []const u8 = comptime nm: {
+ if (std.mem.lastIndexOfScalar(u8, fq_name, ' ')) |space| {
+ break :nm fq_name[space + 1 ..];
+ } else {
+ break :nm fq_name;
+ }
+ };
+
+ const synopsis: []const []const u8 = comptime usage: {
+ if (help.synopsis.len == 0) {
+ @compileError("missing synopsis in command " ++ @typeName(Command));
+ }
+ for (help.synopsis) |usage| {
+ if (usage.len == 0) {
+ @compileError("empty usage pattern in command " ++ @typeName(Command));
+ }
+ }
+ break :usage help.synopsis;
+ };
+
+ const short_description: []const u8 = comptime descr: {
+ if (help.short_description.len == 0) {
+ @compileError("missing short help description in command " ++ @typeName(Command));
+ }
+ if (contains_eol(help.short_description)) {
+ @compileError("short help description in command " ++ @typeName(Command) ++ " must be a single line");
+ }
+ break :descr sanitize_description(help.short_description);
+ };
+
+ const long_description: []const u8 = comptime descr: {
+ if (help.long_description.len == 0) {
+ @compileError("missing long help description in command " ++ @typeName(Command));
+ }
+ break :descr sanitize_description(help.long_description);
+ };
+
+ const options: []const Option_Info = comptime opts: {
+ for (std.meta.fields(Command)) |field| {
+ if (std.mem.eql(u8, field.name, "options")) {
+ break :opts Option_Info.from_options_field(Command, field);
+ }
+ }
+ break :opts &.{};
+ };
+
+ const sections: [help.extra_sections.len]Command_Help.Section = comptime secs: {
+ var extra_sections: [help.extra_sections.len]Command_Help.Section = undefined;
+ for (help.extra_sections, 0..) |sec, i| {
+ if (contains_eol(sec.heading)) {
+ @compileError("help section heading in command " ++ @typeName(Command) ++ " must be a single line");
+ }
+ if (!is_uppercase(sec.heading)) {
+ @compileError("help section heading in command " ++ @typeName(Command) ++ " must all be uppercase");
+ }
+ extra_sections[i] = .{
+ .heading = sec.heading,
+ .body = sanitize_description(sec.body),
+ };
+ }
+ break :secs extra_sections;
+ };
+
+ return Command_Info{
+ .type = Command,
+ .name = name,
+ .fq_name = fq_name,
+ .synopsis = synopsis,
+ .short_description = short_description,
+ .long_description = long_description,
+ .options = options,
+ .tag = null,
+ .sections = &sections,
+ };
+ }
+
+ fn from_subcommand(comptime Command: type) []Command_Info {
+ if (!@hasDecl(Command, "Subcommands")) {
+ return &.{};
+ }
+
+ const union_info = switch (@typeInfo(Command.Subcommands)) {
+ .@"union" => |u| u,
+ else => @compileError(@typeName(Command.Subcommands) ++ " is not a union"),
+ };
+ assert(union_info.tag_type != null);
+
+ var subcmds: [union_info.fields.len]Command_Info = undefined;
+ for (union_info.fields, 0..) |field, i| {
+ subcmds[i] = Command_Info.from_command(field.type);
+ subcmds[i].tag = field.name;
+ }
+ return &subcmds;
+ }
+
+ /// Flushes the printer
+ fn print_usage(comptime cmd: Command_Info, p: *Printer) void {
+ const margin0 = 0;
+ const margin1 = 4;
+ const margin2 = 8;
+
+ const Usage_Table = struct {
+ fn column(comptime Info: type, comptime infos: []const Info, comptime extract: fn (comptime Info) []const u8) Usage_Column {
+ const cells = comptime blk: {
+ var c: [infos.len][]const u8 = undefined;
+ for (infos, 0..) |info, i| {
+ c[i] = extract(info);
+ }
+ break :blk c;
+ };
+ const width = comptime blk: {
+ var w: usize = 0;
+ for (infos, 0..) |_, i| {
+ w = @max(w, cells[i].len);
+ }
+ break :blk w;
+ };
+
+ return .{ .cells = &cells, .width = width };
+ }
+ };
+
+ // usage
+ comptime assert(cmd.synopsis.len > 0);
+ p.print_line("Usage:", .{ .margin = margin0 });
+ inline for (cmd.synopsis) |usage| {
+ p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' });
+ }
+ p.put('\n');
+
+ // subcommands
+ const subcmds = Command_Info.from_subcommand(cmd.type);
+ if (subcmds.len > 0) {
+ const name_col = comptime Usage_Table.column(Command_Info, subcmds, struct {
+ fn extract(comptime subcmd: Command_Info) []const u8 {
+ return subcmd.name;
+ }
+ }.extract);
+
+ const descr_col = comptime Usage_Table.column(Command_Info, subcmds, struct {
+ fn extract(comptime subcmd: Command_Info) []const u8 {
+ return subcmd.short_description;
+ }
+ }.extract);
+
+ p.print_line("Commands:", .{ .margin = margin0 });
+ p.print_usage_table(name_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 });
+ p.put('\n');
+ }
+
+ // options
+ if (cmd.options.len > 0) {
+ const title_col = comptime Usage_Table.column(Option_Info, cmd.options, struct {
+ fn extract(comptime opt: Option_Info) []const u8 {
+ const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">";
+ return if (opt.short_name) |short|
+ std.fmt.comptimePrint("-{c}, --{s}{s}", .{ short, opt.long_name, argname })
+ else
+ std.fmt.comptimePrint(" --{s}{s}", .{ opt.long_name, argname });
+ }
+ }.extract);
+
+ const descr_col = comptime Usage_Table.column(Option_Info, cmd.options, struct {
+ fn extract(comptime opt: Option_Info) []const u8 {
+ return opt.short_description;
+ }
+ }.extract);
+
+ p.print_line("Options:", .{ .margin = margin0 });
+ p.print_usage_table(title_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 });
+ p.put('\n');
+ }
+ p.flush();
+ }
+
+ fn print_help(comptime cmd: Command_Info) noreturn {
+ var out_buf: [1024]u8 = undefined;
+ var pager = Pager.init(&out_buf);
+ defer pager.deinit();
+
+ var p = &pager.printer;
+ const margin0 = 0;
+ const margin1 = 4;
+ const margin2 = 8;
+
+ p.put('\n');
+
+ // name
+ p.printf_line("{f}", .{pager.bold("NAME")}, .{ .margin = margin0 });
+ p.print_line(cmd.fq_name ++ " - " ++ cmd.short_description, .{ .margin = margin1 });
+ p.put('\n');
+
+ // synopsis
+ p.printf_line("{f}", .{pager.bold("SYNOPSIS")}, .{ .margin = margin0 });
+ inline for (cmd.synopsis) |usage| {
+ p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' });
+ }
+ p.put('\n');
+
+ // description
+ p.printf_line("{f}", .{pager.bold("DESCRIPTION")}, .{ .margin = margin0 });
+ p.print_lines(cmd.long_description, .{ .margin = margin1 });
+ p.put('\n');
+
+ // options
+ if (cmd.options.len > 0) {
+ p.printf_line("{f}", .{pager.bold("OPTIONS")}, .{ .margin = margin0 });
+ inline for (cmd.options) |opt| {
+ const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">";
+ if (opt.short_name) |short| {
+ p.printf_line("-{f}, --{f}{s}", .{ pager.bold(&[1]u8{short}), pager.bold(opt.long_name), argname }, .{ .margin = margin1 });
+ } else {
+ p.printf_line("--{f}{s}", .{ pager.bold(opt.long_name), argname }, .{ .margin = margin1 });
+ }
+ p.print_lines(opt.long_description, .{ .margin = margin2 });
+ p.put('\n');
+ }
+ }
+
+ // subcommands
+ const subcmds = Command_Info.from_subcommand(cmd.type);
+ if (subcmds.len > 0) {
+ p.printf_line("{f}", .{pager.bold("COMMANDS")}, .{ .margin = margin0 });
+ inline for (subcmds) |subcmd| {
+ p.printf_line("{f}", .{pager.bold(subcmd.name)}, .{ .margin = margin1 });
+ p.print_lines(subcmd.short_description, .{ .margin = margin2 });
+ p.put('\n');
+ }
+ }
+
+ // sections
+ inline for (cmd.sections) |section| {
+ p.printf_line("{f}", .{pager.bold(section.heading)}, .{ .margin = margin0 });
+ p.print_lines(section.body, .{ .margin = margin1 });
+ p.put('\n');
+ }
+ }
+};
+
+/// Information about a single option. An option must contain a public declaration `help`
+/// of type `Option_Help(T)`.
+const Option_Info = struct {
+ field: []const u8,
+ short_name: ?u8,
+ long_name: []const u8,
+ arg_name: []const u8, // empty for boolean options
+ short_description: []const u8,
+ long_description: []const u8,
+
+ fn from_options_field(comptime Command: type, comptime options_field: std.builtin.Type.StructField) []const Option_Info {
+ const options = comptime blk: {
+ const struct_info = switch (@typeInfo(options_field.type)) {
+ .@"struct" => |s| s,
+ else => @compileError("options for command " ++ @typeName(Command) ++ " must be a struct"),
+ };
+
+ var opts: [struct_info.fields.len]Option_Info = undefined;
+ for (struct_info.fields, 0..) |field, i| {
+ const Opt = field.type;
+ const fq_fieldname = @typeName(Command) ++ "." ++ field.name;
+ if (!@hasDecl(Opt, "help")) {
+ @compileError("missing help declaration in option " ++ fq_fieldname);
+ }
+
+ const help: Option_Help(Opt.Type) = hlp: {
+ if (@TypeOf(Opt.help) != Option_Help(Opt.Type)) {
+ @compileError("invalid help type in option " ++ fq_fieldname ++ ": " ++ @typeName(@TypeOf(Opt.help)) ++ " (expected " ++ @typeName(Option_Help(Opt.Type)) ++ ")");
+ }
+ break :hlp Opt.help;
+ };
+
+ const short_name: ?u8 = name: {
+ if (help.short_name) |ch| {
+ switch (ch) {
+ 'a'...'z', 'A'...'Z', '0'...'9' => {},
+ else => @compileError("invalid short name for option " ++ fq_fieldname),
+ }
+ }
+
+ break :name help.short_name;
+ };
+
+ const long_name: []const u8 = name: {
+ if (std.mem.eql(u8, help.name, "help")) {
+ @compileError("option name 'help' is a reserved option name");
+ }
+ if (help.name.len == 0) {
+ @compileError("missing option name " ++ fq_fieldname);
+ }
+ if (contains_whitespace(help.name)) {
+ @compileError("invalid option name " ++ fq_fieldname);
+ }
+ break :name sanitize_name(help.name);
+ };
+
+ const arg_name: []const u8 = name: {
+ const arg = help.argument_name orelse break :name "";
+ if (contains_whitespace(arg)) {
+ @compileError("invalid argument name of option " ++ fq_fieldname);
+ }
+ break :name arg;
+ };
+
+ const short_description: []const u8 = descr: {
+ if (help.short_description.len == 0) {
+ @compileError("missing short help description in option " ++ fq_fieldname);
+ }
+ if (contains_eol(help.short_description)) {
+ @compileError("short help description in option " ++ fq_fieldname ++ " must be a single line");
+ }
+ break :descr help.short_description;
+ };
+
+ const long_description: []const u8 = descr: {
+ if (help.long_description.len == 0) {
+ @compileError("missing long help description in option " ++ fq_fieldname);
+ }
+ break :descr sanitize_description(help.long_description);
+ };
+
+ opts[i] = Option_Info{
+ .field = field.name,
+ .short_name = short_name,
+ .long_name = long_name,
+ .arg_name = arg_name,
+ .short_description = short_description,
+ .long_description = long_description,
+ };
+ }
+ break :blk opts;
+ };
+ return &options;
+ }
+
+ fn contains_whitespace(comptime text: []const u8) bool {
+ for (text) |c| {
+ switch (c) {
+ ' ', '\t', '\r', '\n' => return true,
+ else => {},
+ }
+ }
+ return false;
+ }
+};
+
+fn sanitize_name(comptime name: []const u8) []const u8 {
+ const sanitized = comptime blk: {
+ var res: [name.len]u8 = undefined;
+ for (name, 0..) |c, i| {
+ res[i] = switch (c) {
+ 'a'...'z', 'A'...'Z', '0'...'9' => c,
+ '_', '-' => '-',
+ else => @compileError("invalid character in cli name '" ++ name ++ "'"),
+ };
+ }
+ break :blk res;
+ };
+ return &sanitized;
+}
+
+fn sanitize_description(comptime text: []const u8) []const u8 {
+ @setEvalBranchQuota(100_000);
+
+ const sanitized = comptime blk: {
+ var res: [text.len]u8 = undefined;
+ var prev_was_eol: bool = false;
+ for (text, 0..) |c, i| {
+ var ch = c;
+ switch (ch) {
+ '\n' => {
+ if (prev_was_eol) {
+ res[i - 1] = '\n'; // preserve empty line
+ } else {
+ ch = ' ';
+ }
+ prev_was_eol = true;
+ },
+ '\t', ' ' => {
+ if (prev_was_eol) {
+ res[i - 1] = '\n';
+ } else {
+ ch = ' ';
+ }
+ prev_was_eol = false;
+ },
+ else => {
+ prev_was_eol = false;
+ },
+ }
+ res[i] = ch;
+ }
+ break :blk res;
+ };
+ return &sanitized;
+}
+
+fn contains_eol(comptime text: []const u8) bool {
+ for (text) |c| {
+ switch (c) {
+ '\r', '\n' => return true,
+ else => {},
+ }
+ }
+ return false;
+}
+
+fn is_uppercase(comptime text: []const u8) bool {
+ for (text) |c| {
+ if ('a' <= c and c <= 'z') return false;
+ }
+ return true;
+}
+
+fn join_enum_values(comptime E: type) []const u8 {
+ const fields = std.meta.fields(E);
+ return switch (fields.len) {
+ 0 => unreachable,
+ 1 => "'" ++ fields[0].name ++ "'",
+ 2 => "'" ++ fields[0].name ++ "' or '" ++ fields[1].name ++ "'",
+ 3 => str: {
+ var text: []const u8 = "";
+ for (fields[0 .. fields.len - 1]) |field| {
+ text = text ++ "'" ++ field.name ++ "', ";
+ }
+ break :str text ++ "or '" ++ fields[fields.len - 1].name ++ "'";
+ },
+ };
+}
diff --git a/src/conf.zig b/src/conf.zig
new file mode 100644
index 0000000..419c369
--- /dev/null
+++ b/src/conf.zig
@@ -0,0 +1,382 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+pub const Var = struct {
+ key: []const u8,
+ val: []const u8,
+
+ pub fn format(self: Var, w: *std.io.Writer) !void {
+ const spacebuf = [_]u8{' '} ** 32;
+ const equal_sign = " = ";
+
+ try w.writeAll(self.key);
+ try w.writeAll(equal_sign);
+ if (std.mem.indexOfScalar(u8, self.val, '\n')) |eol0| {
+ try w.writeAll(self.val[0 .. eol0 + 1]);
+ var val = self.val[eol0 + 1 ..];
+ while (val.len > 0) {
+ const eol = std.mem.indexOfScalar(u8, val, '\n') orelse val.len - 1;
+ const line = val[0 .. eol + 1];
+ var spaces = self.key.len;
+ while (spaces > 0) {
+ const n = @min(spaces, spacebuf.len);
+ try w.writeAll(spacebuf[0..n]);
+ spaces -= n;
+ }
+ try w.writeAll(equal_sign);
+ try w.writeAll(line);
+ val = val[eol + 1 ..];
+ }
+ } else {
+ try w.writeAll(self.val);
+ }
+ }
+};
+
+const Var_Header = packed struct(u64) {
+ key_len: u32,
+ val_len: u32,
+
+ fn encode(self: Var_Header) [8]u8 {
+ var header: u64 = @intCast(self.key_len);
+ header <<= 32;
+ header |= @intCast(self.val_len);
+ return std.mem.toBytes(std.mem.nativeToBig(u64, header));
+ }
+
+ fn decode(buf: []const u8) Var_Header {
+ const header = std.mem.bigToNative(u64, std.mem.bytesToValue(u64, buf[0..8]));
+ return .{
+ .key_len = @intCast(header >> 32),
+ .val_len = @intCast(header & 0xffffffff),
+ };
+ }
+};
+
+pub const Iterator = struct {
+ rest: []const u8,
+
+ pub fn next(self: *Iterator) ?Var {
+ if (self.rest.len == 0) return null;
+
+ const header = Var_Header.decode(self.rest);
+ const v: Var = .{
+ .key = self.rest[@sizeOf(Var_Header) .. @sizeOf(Var_Header) + header.key_len],
+ .val = self.rest[@sizeOf(Var_Header) + header.key_len .. @sizeOf(Var_Header) + header.key_len + header.val_len],
+ };
+ self.rest = self.rest[@sizeOf(Var_Header) + v.key.len + v.val.len ..];
+ return v;
+ }
+};
+
+pub const Parse_Result = union(enum) {
+ conf: Config,
+ err: struct {
+ msg: []const u8,
+ line: usize,
+ },
+};
+
+pub const Config = struct {
+ allocator: std.mem.Allocator,
+ buf: []u8, // encoded key/value pairs
+ len: usize, // used length of `buf`
+
+ pub fn init(allocator: std.mem.Allocator) Config {
+ return .{ .allocator = allocator, .buf = &.{}, .len = 0 };
+ }
+
+ pub fn from_file(allocator: std.mem.Allocator, path: []const u8) !Parse_Result {
+ var file = std.fs.openFileAbsolute(path, .{}) catch |err| {
+ return switch (err) {
+ error.FileNotFound => .{ .conf = .init(allocator) },
+ else => err,
+ };
+ };
+ defer file.close();
+
+ var read_buf: [1024]u8 = undefined;
+ var reader = file.reader(&read_buf);
+ return parse(allocator, &reader.interface, read_buf.len);
+ }
+
+ fn parse(allocator: std.mem.Allocator, reader: *std.io.Reader, comptime max_line_len: usize) !Parse_Result {
+ const spaces = " \t\r";
+
+ var allocating: std.io.Writer.Allocating = .init(allocator);
+
+ var header: Var_Header = undefined;
+ var header_off: usize = 0;
+ var need_key = true;
+ var file_line: usize = 0;
+ while (true) {
+ file_line += 1;
+ const raw_line = reader.takeDelimiterExclusive('\n') catch |err| switch (err) {
+ error.StreamTooLong => {
+ return .{
+ .err = .{
+ .msg = std.fmt.comptimePrint("key/value line exceeds the maximum of {} bytes", .{max_line_len}),
+ .line = file_line,
+ },
+ };
+ },
+ error.EndOfStream => {
+ break;
+ },
+ else => unreachable,
+ };
+
+ const line = std.mem.trim(u8, raw_line, spaces);
+ if (line.len == 0 or line[0] == '#') {
+ // After an empty line, we consider the end of a value.
+ need_key = true;
+ continue;
+ }
+
+ const eq = std.mem.indexOfScalar(u8, line, '=') orelse {
+ return .{
+ .err = .{
+ .msg = "invalid variable definition (no '=' found)",
+ .line = file_line,
+ },
+ };
+ };
+
+ const key = std.mem.trimRight(u8, line[0..eq], spaces);
+ const val = std.mem.trimLeft(u8, line[eq + 1 ..], spaces);
+
+ if (key.len == 0) {
+ // We have a value continuation here. So we append a newline
+ // to the existing value and write the value line below.
+ if (need_key) {
+ return .{
+ .err = .{
+ .msg = "missing key name",
+ .line = file_line,
+ },
+ };
+ }
+ header.val_len += 1;
+ try allocating.writer.writeByte('\n');
+ } else {
+ // We have a new key/value pair starting here. First, we need
+ // to write the header from the previous key/value pair if there
+ // was some.
+ if (allocating.writer.end > 0) {
+ @memcpy(allocating.writer.buffer[header_off .. header_off + @sizeOf(Var_Header)], &header.encode());
+ }
+
+ header_off = allocating.writer.end;
+ header.key_len = @intCast(key.len);
+ header.val_len = 0;
+
+ try allocating.writer.writeAll(&[_]u8{0} ** @sizeOf(Var_Header));
+ try allocating.writer.writeAll(key);
+ need_key = false;
+ }
+
+ header.val_len += @intCast(val.len);
+ try allocating.writer.writeAll(val);
+ }
+
+ if (allocating.writer.end > 0) {
+ @memcpy(allocating.writer.buffer[header_off .. header_off + @sizeOf(Var_Header)], &header.encode());
+ }
+ return .{
+ .conf = .{
+ .allocator = allocator,
+ .buf = allocating.writer.buffer,
+ .len = allocating.writer.end,
+ },
+ };
+ }
+
+ pub fn deinit(self: Config) void {
+ if (self.buf.len > 0) {
+ self.allocator.free(self.buf);
+ }
+ }
+
+ pub fn iterate(self: Config) Iterator {
+ return .{ .rest = self.buf[0..self.len] };
+ }
+
+ pub fn add_var(self: *Config, key: []const u8, val: []const u8) !void {
+ const var_size = @sizeOf(Var_Header) + key.len + val.len;
+ if (self.len + var_size > self.buf.len) {
+ const new_size = @max(256, self.buf.len + @max(var_size, self.buf.len / 2));
+ self.buf = try self.allocator.realloc(self.buf, new_size);
+ }
+ assert(self.len + var_size <= self.buf.len);
+
+ const header: Var_Header = .{
+ .key_len = @intCast(key.len),
+ .val_len = @intCast(val.len),
+ };
+ @memcpy(self.buf[self.len .. self.len + 8], &header.encode());
+ self.len += 8;
+ @memcpy(self.buf[self.len .. self.len + key.len], key);
+ self.len += key.len;
+ @memcpy(self.buf[self.len .. self.len + val.len], val);
+ self.len += val.len;
+ }
+
+ /// Find a variable by its key and returns the first variable that
+ /// is found. If the key cannot be found, null will be returned.
+ pub fn find_var(self: Config, key: []const u8) ?Var {
+ var iter = self.iterate();
+ while (iter.next()) |v| {
+ if (std.mem.eql(u8, v.key, key)) {
+ return v;
+ }
+ }
+ return null;
+ }
+};
+
+test "parse config - empty" {
+ var input = std.io.Reader.fixed("");
+ var buf: [32]u8 = undefined;
+ var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf);
+
+ var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len);
+ try std.testing.expect(result == .conf);
+ try std.testing.expectEqualSlices(u8, "", result.conf.buf[0..result.conf.len]);
+ defer result.conf.deinit();
+
+ var vars = result.conf.iterate();
+ try std.testing.expect(vars.next() == null);
+}
+
+test "parse config - singe line" {
+ var input = std.io.Reader.fixed("foo one = bar one");
+ var buf: [32]u8 = undefined;
+ var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf);
+
+ var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len);
+ try std.testing.expect(result == .conf);
+ try std.testing.expectEqualSlices(
+ u8,
+ "\x00\x00\x00\x07\x00\x00\x00\x07foo onebar one",
+ result.conf.buf[0..result.conf.len],
+ );
+ defer result.conf.deinit();
+
+ var vars = result.conf.iterate();
+
+ const var_one = vars.next() orelse return error.missing_var_one;
+ try std.testing.expectEqualSlices(u8, "foo one", var_one.key);
+ try std.testing.expectEqualSlices(u8, "bar one", var_one.val);
+
+ try std.testing.expectEqual(@as(?Var, null), vars.next());
+}
+
+test "parse config - mixed" {
+ var input = std.io.Reader.fixed(
+ \\foo_one = bar one
+ \\foo_two = bar two 1
+ \\ = bar two 2
+ \\ =
+ \\ = bar two 4
+ \\
+ \\foo_three =
+ \\ = bar three 2
+ \\ = bar three 3
+ \\
+ \\
+ \\
+ \\foo_four = bar four 1
+ \\ = bar four 2
+ \\ = bar four 3
+ \\ =
+ \\
+ \\foo_five =
+ \\
+ );
+ var buf: [32]u8 = undefined;
+ var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf);
+
+ var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len);
+ try std.testing.expect(result == .conf);
+ try std.testing.expectEqualSlices(
+ u8,
+ "" ++
+ "\x00\x00\x00\x07\x00\x00\x00\x07foo_onebar one" ++
+ "\x00\x00\x00\x07\x00\x00\x00\x1efoo_twobar two 1\nbar two 2\n\nbar two 4" ++
+ "\x00\x00\x00\x09\x00\x00\x00\x18foo_three\nbar three 2\nbar three 3" ++
+ "\x00\x00\x00\x08\x00\x00\x00\x21foo_fourbar four 1\nbar four 2\nbar four 3\n" ++
+ "\x00\x00\x00\x08\x00\x00\x00\x00foo_five",
+ result.conf.buf[0..result.conf.len],
+ );
+ defer result.conf.deinit();
+
+ var vars = result.conf.iterate();
+
+ const var_one = vars.next() orelse return error.missing_var_one;
+ try std.testing.expectEqualSlices(u8, "foo_one", var_one.key);
+ try std.testing.expectEqualSlices(u8, "bar one", var_one.val);
+
+ const var_two = vars.next() orelse return error.missing_var_two;
+ try std.testing.expectEqualSlices(u8, "foo_two", var_two.key);
+ try std.testing.expectEqualSlices(u8, "bar two 1\nbar two 2\n\nbar two 4", var_two.val);
+
+ const var_three = vars.next() orelse return error.missing_var_three;
+ try std.testing.expectEqualSlices(u8, "foo_three", var_three.key);
+ try std.testing.expectEqualSlices(u8, "\nbar three 2\nbar three 3", var_three.val);
+
+ const var_four = vars.next() orelse return error.missing_var_four;
+ try std.testing.expectEqualSlices(u8, "foo_four", var_four.key);
+ try std.testing.expectEqualSlices(u8, "bar four 1\nbar four 2\nbar four 3\n", var_four.val);
+
+ const var_five = vars.next() orelse return error.missing_var_five;
+ try std.testing.expectEqualSlices(u8, "foo_five", var_five.key);
+ try std.testing.expectEqualSlices(u8, "", var_five.val);
+
+ try std.testing.expectEqual(@as(?Var, null), vars.next());
+}
+
+test "parse config - with comment" {
+ var input = std.io.Reader.fixed(
+ \\# first var:
+ \\foo_one = bar one
+ \\# second var:
+ \\foo_two = bar two 1
+ \\ = bar two 2
+ \\ =
+ \\ = bar two 4
+ \\ # third var:
+ \\foo_three =
+ \\ = bar three 2
+ \\ = bar three 3
+ );
+ var buf: [32]u8 = undefined;
+ var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf);
+
+ var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len);
+ try std.testing.expect(result == .conf);
+ try std.testing.expectEqualSlices(
+ u8,
+ "" ++
+ "\x00\x00\x00\x07\x00\x00\x00\x07foo_onebar one" ++
+ "\x00\x00\x00\x07\x00\x00\x00\x1efoo_twobar two 1\nbar two 2\n\nbar two 4" ++
+ "\x00\x00\x00\x09\x00\x00\x00\x18foo_three\nbar three 2\nbar three 3",
+ result.conf.buf[0..result.conf.len],
+ );
+ defer result.conf.deinit();
+
+ var vars = result.conf.iterate();
+
+ const var_one = vars.next() orelse return error.missing_var_one;
+ try std.testing.expectEqualSlices(u8, "foo_one", var_one.key);
+ try std.testing.expectEqualSlices(u8, "bar one", var_one.val);
+
+ const var_two = vars.next() orelse return error.missing_var_two;
+ try std.testing.expectEqualSlices(u8, "foo_two", var_two.key);
+ try std.testing.expectEqualSlices(u8, "bar two 1\nbar two 2\n\nbar two 4", var_two.val);
+
+ const var_three = vars.next() orelse return error.missing_var_three;
+ try std.testing.expectEqualSlices(u8, "foo_three", var_three.key);
+ try std.testing.expectEqualSlices(u8, "\nbar three 2\nbar three 3", var_three.val);
+
+ try std.testing.expectEqual(@as(?Var, null), vars.next());
+}
diff --git a/src/env.zig b/src/env.zig
new file mode 100644
index 0000000..7e8b355
--- /dev/null
+++ b/src/env.zig
@@ -0,0 +1,200 @@
+const options = @import("options");
+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 Self = @This();
+
+allocator: std.mem.Allocator,
+prefix: []const u8,
+etc: []const u8, // ${PREFIX}/etc/
+args: *cli.Args,
+
+config: conf.Config,
+ports_dir: []const u8,
+distfiles_dir: []const u8,
+distfiles_history: usize,
+default_category: ?[]const u8,
+default_repo_branch: []const u8,
+editor_cmd: ?[]const u8,
+
+pub fn init(allocator: std.mem.Allocator, etc: []const u8, args: *cli.Args) Self {
+ const prefix = if (options.path_prefix[options.path_prefix.len - 1] == '/')
+ options.path_prefix[0 .. options.path_prefix.len - 1]
+ else
+ options.path_prefix;
+
+ const config = read_config(allocator, .join(.{ etc, "porteur/porteur.conf" }));
+ var self: Self = .{
+ .allocator = allocator,
+ .prefix = prefix,
+ .etc = etc,
+ .args = args,
+
+ .config = config,
+ .ports_dir = std.posix.getenvZ("PORTSDIR") orelse "/usr/ports",
+ .distfiles_dir = prefix ++ "/porteur/distfiles/{{TREENAME}}",
+ .distfiles_history = 1,
+ .default_category = null,
+ .default_repo_branch = "main",
+ .editor_cmd = null,
+ };
+
+ var iter = config.iterate();
+ while (iter.next()) |v| {
+ if (std.mem.eql(u8, v.key, "portsdir") and v.val.len > 0) {
+ self.ports_dir = v.val;
+ } else if (std.mem.eql(u8, v.key, "distfiles-dir") and v.val.len > 0) {
+ self.distfiles_dir = std.mem.trimEnd(u8, v.val, "/");
+ } else if (std.mem.eql(u8, v.key, "distfiles-history") and v.val.len > 0) {
+ self.distfiles_history = history: {
+ var res: usize = 0;
+ for (v.val) |c| {
+ if (c < '0' or c > '9') {
+ self.warn("invalid distfile history `{s}` (falling back to 1)", .{v.val});
+ break :history 1;
+ }
+ const m = @mulWithOverflow(res, @as(usize, 10));
+ const a = @addWithOverflow(m[0], @as(usize, @intCast(c - '0')));
+ if (m[1] != 0 or a[1] != 0) {
+ break :history std.math.maxInt(usize);
+ }
+ res = a[0];
+ }
+ break :history res;
+ };
+ } else if (std.mem.eql(u8, v.key, "category") and v.val.len > 0) {
+ self.default_category = v.val;
+ } else if (std.mem.eql(u8, v.key, "git-branch") and v.val.len > 0) {
+ self.default_repo_branch = v.val;
+ } else if (std.mem.eql(u8, v.key, "editor") and v.val.len > 0) {
+ self.editor_cmd = v.val;
+ } else {
+ self.warn("unknown porteur configuration `{s}`", .{v.key});
+ }
+ }
+
+ return self;
+}
+
+/// Return a subpath of the configuration directory.
+pub fn etc_path(self: Self, subpath: anytype) fs.Path {
+ var etc: fs.Path = .join(.{ self.etc, "porteur" });
+ etc.append(subpath);
+ return etc;
+}
+
+/// Return a subpath of a ports tree's root directory.
+pub fn ports_tree_path(self: Self, tree_name: []const u8, subpath: anytype) fs.Path {
+ var p: fs.Path = .join(.{ self.prefix, "porteur/ports", tree_name });
+ p.append(subpath);
+ return p;
+}
+
+/// Return the directory that contains the port's distfiles.
+pub fn ports_dist_dir(self: Self, tree_name: []const u8, port_name: []const u8) fs.Path {
+ var dest: fs.Path = undefined;
+ dest.len = 0;
+
+ var src = self.distfiles_dir;
+ loop: while (src.len > 0) {
+ inline for ([_]struct { key: []const u8, val: []const u8 }{
+ .{ .key = "{{TREENAME}}", .val = tree_name },
+ .{ .key = "{{PORTNAME}}", .val = port_name },
+ }) |v| {
+ if (std.mem.indexOf(u8, src, v.key)) |idx| {
+ @memcpy(dest.buf[dest.len .. dest.len + idx], src[0..idx]);
+ dest.len += idx;
+ @memcpy(dest.buf[dest.len .. dest.len + v.val.len], v.val);
+ dest.len += v.val.len;
+ src = src[idx + v.key.len ..];
+ continue :loop;
+ }
+ }
+
+ @memcpy(dest.buf[dest.len .. dest.len + src.len], src);
+ dest.len += src.len;
+ break :loop;
+ }
+
+ dest.buf[dest.len] = 0;
+ return dest;
+}
+
+/// Return a subpath of the directory that contains the sources of all the ports of a
+/// ports tree.
+pub fn ports_src_path(self: Self, tree_name: []const u8, subpath: anytype) fs.Path {
+ var p: fs.Path = .join(.{ self.prefix, "porteur/src", tree_name });
+ p.append(subpath);
+ return p;
+}
+
+/// Return the subpath of a temporary working directory.
+pub fn work_path(self: Self, subpath: anytype) fs.Path {
+ var p: fs.Path = .join(.{ self.prefix, "porteur/.wrk" });
+ p.append(subpath);
+ return p;
+}
+
+pub fn alloc(self: Self, size: usize) []u8 {
+ return self.allocator.alloc(u8, size) catch cli.fatal("out of memory", .{});
+}
+
+pub fn dealloc(self: Self, bytes: []const u8) void {
+ self.allocator.free(bytes);
+}
+
+pub fn err(self: Self, comptime fmt: []const u8, args: anytype) void {
+ _ = self;
+ cli.stderr.write_line("Error: " ++ fmt, args);
+ cli.stderr.flush();
+}
+
+pub fn warn(self: Self, comptime fmt: []const u8, args: anytype) void {
+ _ = self;
+ cli.stderr.write_line("Warning: " ++ fmt, args);
+ cli.stderr.flush();
+}
+
+pub fn info(self: Self, comptime fmt: []const u8, args: anytype) void {
+ _ = self;
+ cli.stdout.write_line(fmt, args);
+ cli.stdout.flush();
+}
+
+fn read_config(allocator: std.mem.Allocator, conf_file: fs.Path) conf.Config {
+ if (conf.Config.from_file(allocator, conf_file.name())) |res| {
+ return switch (res) {
+ .conf => |c| c,
+ .err => |e| empty: {
+ cli.stderr.write_line("Error: {s} (line {d})", .{ e.msg, e.line });
+ cli.stderr.flush();
+ break :empty .init(allocator);
+ },
+ };
+ } else |e| {
+ cli.stderr.write_line("Error: cannot read config ({s})", .{@errorName(e)});
+ cli.stderr.flush();
+ return .init(allocator);
+ }
+}
+
+test "replace distdir variables" {
+ var env: Self = undefined;
+ var dir: fs.Path = undefined;
+
+ env.distfiles_dir = "/porteur/distfiles";
+ dir = env.ports_dist_dir("not-relevant", "not-relevant");
+ try std.testing.expectEqualStrings("/porteur/distfiles", dir.name());
+
+ env.distfiles_dir = "/porteur/distfiles/{{TREENAME}}";
+ dir = env.ports_dist_dir("default", "not-relevant");
+ try std.testing.expectEqualStrings("/porteur/distfiles/default", dir.name());
+
+ env.distfiles_dir = "/porteur/distfiles/{{TREENAME}}/{{PORTNAME}}";
+ dir = env.ports_dist_dir("default", "myport");
+ try std.testing.expectEqualStrings("/porteur/distfiles/default/myport", dir.name());
+}
diff --git a/src/fs.zig b/src/fs.zig
new file mode 100644
index 0000000..f1bee2d
--- /dev/null
+++ b/src/fs.zig
@@ -0,0 +1,269 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const fatal = @import("cli.zig").fatal;
+
+pub const Path = struct {
+ buf: [std.fs.max_path_bytes]u8,
+ len: usize,
+
+ pub fn init(path: []const u8) Path {
+ var self: Path = undefined;
+ assert(path.len < self.buf.len);
+ self.len = path.len;
+ @memcpy(self.buf[0..path.len], path);
+ self.buf[self.len] = 0;
+ return self;
+ }
+
+ pub fn join(segments: anytype) Path {
+ var self: Path = undefined;
+ self.len = 0;
+ self.append(segments);
+ return self;
+ }
+
+ pub fn append(self: *Path, segments: anytype) void {
+ const info = @typeInfo(@TypeOf(segments)).@"struct";
+ comptime assert(info.is_tuple);
+ inline for (info.fields) |field| {
+ var seg: []const u8 = @field(segments, field.name);
+ if (seg.len > 0) {
+ if (self.len > 0 and seg[0] == std.fs.path.sep) seg = seg[1..];
+ if (seg[seg.len - 1] == std.fs.path.sep) seg = seg[0 .. seg.len - 1];
+ }
+ if (seg.len > 0) {
+ if (self.len > 0) {
+ self.buf[self.len] = std.fs.path.sep;
+ self.len += 1;
+ }
+ @memcpy(self.buf[self.len .. self.len + seg.len], seg);
+ self.len += seg.len;
+ }
+ }
+ self.buf[self.len] = 0;
+ }
+
+ pub fn name(self: *const Path) [:0]const u8 {
+ return self.buf[0..self.len :0];
+ }
+
+ pub fn basename(self: *const Path) []const u8 {
+ return std.fs.path.basename(self.name());
+ }
+
+ pub fn dir(self: *const Path) ?Path {
+ const dirname = std.fs.path.dirname(self.name()) orelse return null;
+ return .init(dirname);
+ }
+
+ /// Returns the relative name of `self` regarding to `root`.
+ /// Both paths must be absolute.
+ pub fn relname(self: *const Path, root: Path) ?[]const u8 {
+ assert(std.fs.path.isAbsolute(root.name()));
+ assert(std.fs.path.isAbsolute(self.name()));
+ if (!std.mem.startsWith(u8, self.name(), root.name())) return null;
+ if (self.len == root.len) return "";
+ var rel = self.buf[root.len..self.len];
+ if (rel[0] == std.fs.path.sep) rel = rel[1..];
+ return rel;
+ }
+};
+
+pub fn mkdir_all(path: Path) !void {
+ try std.fs.cwd().makePath(path.name());
+}
+
+pub fn touch(path: Path) !void {
+ const cwd = std.fs.cwd();
+ if (std.fs.path.dirname(path.name())) |dir| {
+ try cwd.makePath(dir);
+ }
+ const f = try cwd.createFileZ(path.name(), .{ .mode = 0o644 });
+ f.close();
+}
+
+pub fn rm(path: Path) !void {
+ std.fs.deleteTreeAbsolute(path.name()) catch |err| {
+ if (err != error.FileNotFound) return err;
+ };
+}
+
+pub fn cp_file(from_path: Path, to_path: Path) !void {
+ const from = from_path.name();
+ const to = to_path.name();
+ std.fs.deleteTreeAbsolute(to) catch |err| {
+ if (err != error.FileNotFound) return err;
+ };
+ if (to_path.dir()) |to_parent| try mkdir_all(to_parent);
+ try std.fs.copyFileAbsolute(from, to, .{});
+}
+
+pub fn cp_dir(allocator: std.mem.Allocator, from_path: Path, to_path: Path) !void {
+ const from = from_path.name();
+ const to = to_path.name();
+ std.fs.deleteTreeAbsolute(to) catch |err| {
+ if (err != error.FileNotFound) return err;
+ };
+ try mkdir_all(to_path);
+ try cp_tree(allocator, from, to);
+}
+
+pub fn mv_dir(allocator: std.mem.Allocator, from_path: Path, to_path: Path) !void {
+ const from = from_path.name();
+ const to = to_path.name();
+ std.fs.deleteTreeAbsolute(to) catch |err| {
+ if (err != error.FileNotFound) return err;
+ };
+ if (to_path.dir()) |to_parent| try mkdir_all(to_parent);
+
+ std.fs.renameAbsoluteZ(from, to) catch |err| {
+ if (err != error.RenameAcrossMountPoints) return err;
+ try std.fs.makeDirAbsoluteZ(to);
+ try cp_tree(allocator, from, to);
+ try rm(from_path);
+ };
+}
+
+pub fn mv_file(from_path: Path, to_path: Path) !void {
+ const from = from_path.name();
+ const to = to_path.name();
+ std.fs.deleteTreeAbsolute(to) catch |err| {
+ if (err != error.FileNotFound) return err;
+ };
+ if (to_path.dir()) |to_parent| try mkdir_all(to_parent);
+
+ std.fs.renameAbsoluteZ(from, to) catch |err| {
+ if (err != error.RenameAcrossMountPoints) return err;
+ try std.fs.copyFileAbsolute(from, to, .{});
+ try rm(from_path);
+ };
+}
+
+fn cp_tree(allocator: std.mem.Allocator, from: [:0]const u8, to: [:0]const u8) !void {
+ var from_dir = try std.fs.openDirAbsoluteZ(from, .{ .iterate = true });
+ defer from_dir.close();
+
+ var walker = try from_dir.walk(allocator);
+ defer walker.deinit();
+ while (try walker.next()) |entry| {
+ const dest: Path = .join(.{ to, entry.path });
+ switch (entry.kind) {
+ .directory => try std.fs.makeDirAbsoluteZ(dest.name()),
+ .file => {
+ const src: Path = .join(.{ from, entry.path });
+ try std.fs.copyFileAbsolute(src.name(), dest.name(), .{});
+ },
+ else => |k| fatal("cannot copy {s}", .{@tagName(k)}),
+ }
+ }
+}
+
+pub const File_Iterator = struct {
+ iter: std.fs.Dir.Iterator,
+
+ pub fn deinit(self: *File_Iterator) void {
+ self.iter.dir.close();
+ }
+
+ pub fn next(self: *File_Iterator) ?[]const u8 {
+ while (true) {
+ const entry = self.iter.next() catch unreachable orelse return null;
+ if (entry.kind == .file) return entry.name;
+ }
+ }
+};
+
+/// Iterate files of a directory with the given path. The path must be absolute.
+/// If the directory does not exist, null will be returned.
+pub fn iterate_files(path: Path) !?File_Iterator {
+ var dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| {
+ return switch (err) {
+ error.FileNotFound => null,
+ error.BadPathName, error.InvalidUtf8 => error.BadPathName,
+ error.NameTooLong => unreachable,
+ else => err,
+ };
+ };
+ return .{
+ .iter = dir.iterate(),
+ };
+}
+
+pub const Dir_Info = struct {
+ exists: bool,
+ empty: bool,
+};
+
+pub fn dir_info(path: Path) !Dir_Info {
+ var dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| {
+ return switch (err) {
+ error.FileNotFound => .{ .exists = false, .empty = false },
+ error.BadPathName, error.InvalidUtf8 => error.BadPathName,
+ error.NameTooLong => unreachable,
+ else => err,
+ };
+ };
+ defer dir.close();
+
+ var iter = dir.iterate();
+ const child = iter.next() catch unreachable;
+ return .{
+ .exists = true,
+ .empty = child == null,
+ };
+}
+
+pub fn file_exists(path: Path) !bool {
+ std.fs.accessAbsoluteZ(path.name(), .{}) catch |err| {
+ switch (err) {
+ error.FileNotFound => return false,
+ error.PermissionDenied => return false,
+ error.NameTooLong => unreachable,
+ else => return err,
+ }
+ };
+ return true;
+}
+
+/// Read the whole file into a buffer and returns the buffer. The caller is responsible
+/// to deallocate the memory with the given allocator.
+/// If the file does not exist, null will be returned.
+pub fn read_file(allocator: std.mem.Allocator, path: Path) !?[]const u8 {
+ var f = std.fs.openFileAbsoluteZ(path.name(), .{}) catch |err| {
+ return switch (err) {
+ error.FileNotFound => null,
+ error.NameTooLong => unreachable,
+ else => err,
+ };
+ };
+ defer f.close();
+
+ const stats = try f.stat();
+ const buf = try allocator.alloc(u8, @intCast(stats.size));
+ errdefer allocator.free(buf);
+
+ var r = f.reader(buf);
+ var off: usize = 0;
+ while (off < buf.len) {
+ const n = try r.readStreaming(buf[off..]);
+ off += n;
+ }
+ return buf;
+}
+
+test "path join" {
+ var path: Path = undefined;
+
+ path = .join(.{});
+ try std.testing.expectEqualStrings("", path.name());
+
+ path = .join(.{ "relative", "local/dir" });
+ try std.testing.expectEqualStrings("relative/local/dir", path.name());
+
+ path = .join(.{ "relative/", "/local/dir" });
+ try std.testing.expectEqualStrings("relative/local/dir", path.name());
+
+ path = .join(.{ "/absolute", "local/dir" });
+ try std.testing.expectEqualStrings("/absolute/local/dir", path.name());
+}
diff --git a/src/git.zig b/src/git.zig
new file mode 100644
index 0000000..82e19b0
--- /dev/null
+++ b/src/git.zig
@@ -0,0 +1,179 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const fs = @import("fs.zig");
+const shell = @import("shell.zig");
+const cli = @import("cli.zig");
+const Env = @import("env.zig");
+const fatal = cli.fatal;
+
+pub const Commit = struct {
+ hash: []const u8,
+ timestamp: u64,
+
+ pub fn init_empty() Commit {
+ return .{ .hash = "", .timestamp = 0 };
+ }
+
+ pub fn parse(version_str: []const u8) !Commit {
+ const s = std.mem.trim(u8, version_str, " \t");
+ if (s.len == 0) return .init_empty();
+
+ const pos = std.mem.indexOfScalar(u8, s, '@') orelse return error.invalid_commit;
+ return .{
+ .hash = s[0..pos],
+ .timestamp = std.fmt.parseInt(u64, s[pos + 1 ..], 10) catch return error.invalid_commit,
+ };
+ }
+
+ pub fn format(self: Commit, w: *std.io.Writer) std.io.Writer.Error!void {
+ if (self.hash.len > 0) {
+ try w.print("{s}@{d}", .{ self.hash, self.timestamp });
+ }
+ }
+};
+
+pub const Repo = struct {
+ path: fs.Path,
+ url: []const u8,
+ branch: []const u8,
+
+ pub fn clone(self: Repo, env: Env) !void {
+ const dir = try fs.dir_info(self.path);
+ if (!dir.exists or dir.empty) {
+ var branch_buf: [std.fs.max_name_bytes]u8 = undefined;
+ const branch_param = branch_buf[0 .. 9 + self.branch.len];
+ @memcpy(branch_param[0..9], "--branch=");
+ @memcpy(branch_param[9..], self.branch);
+ const res = shell.exec(.{
+ .env = env,
+ .argv = &.{ "git", "clone", "--quiet", "--depth=1", branch_param, self.url, self.path.name() },
+ });
+ switch (res) {
+ .success => |out| env.dealloc(out),
+ .failure => |out| {
+ env.err("failed to clone {s}\n", .{self.url});
+ fatal("{s}", .{out});
+ },
+ }
+ }
+ }
+
+ pub fn reset(self: Repo, env: Env) void {
+ var res = shell.exec(.{
+ .env = env,
+ .argv = &.{ "git", "reset", "--hard" },
+ .cwd = self.path,
+ });
+ switch (res) {
+ .success => |out| env.dealloc(out),
+ .failure => |out| {
+ env.err("failed to reset repository: {s}\n", .{self.path.name()});
+ fatal("{s}", .{out});
+ },
+ }
+
+ res = shell.exec(.{
+ .env = env,
+ .argv = &.{ "git", "clean", "-fdx" },
+ .cwd = self.path,
+ });
+ switch (res) {
+ .success => |out| env.dealloc(out),
+ .failure => |out| {
+ env.err("failed to clean repository: {s}\n", .{self.path.name()});
+ fatal("{s}", .{out});
+ },
+ }
+ }
+
+ pub fn update(self: Repo, env: Env) !Commit {
+ const dir = try fs.dir_info(self.path);
+ if (!dir.exists or dir.empty) {
+ try self.clone(env);
+ } else {
+ var res = shell.exec(.{
+ .env = env,
+ .argv = &.{ "git", "fetch", "--quiet", "--depth=1", "origin", self.branch },
+ .cwd = self.path,
+ });
+ switch (res) {
+ .success => |out| env.dealloc(out),
+ .failure => |out| {
+ env.err("failed to fetch branch {s}: {s}\n", .{ self.branch, self.url });
+ fatal("{s}", .{out});
+ },
+ }
+
+ const branch_spec: fs.Path = .join(.{ "origin", self.branch });
+ res = shell.exec(.{
+ .env = env,
+ .argv = &.{ "git", "reset", "--hard", branch_spec.name() },
+ .cwd = self.path,
+ });
+ switch (res) {
+ .success => |out| env.dealloc(out),
+ .failure => |out| {
+ env.err("failed to reset branch {s}: {s}\n", .{ self.branch, self.path.name() });
+ fatal("{s}", .{out});
+ },
+ }
+ }
+
+ // We leak `out` intentionally here.
+ const out = self.must_run(env, &.{ "git", "show", "--no-patch", "--format=format:%H %at", self.branch });
+ const space = std.mem.indexOfScalar(u8, out, ' ') orelse {
+ fatal("unexpected output for 'git show'", .{});
+ };
+ return .{
+ .hash = out[0..space],
+ .timestamp = ts: {
+ var timestamp: u64 = 0;
+ for (out[space + 1 ..]) |c| {
+ if (c < '0' or c > '9') {
+ fatal("unexpected timestamp in 'git show'", .{});
+ }
+ timestamp *= 10;
+ timestamp += @intCast(c - '0');
+ }
+ break :ts timestamp;
+ },
+ };
+ }
+
+ fn must_run(self: Repo, env: Env, argv: []const []const u8) []const u8 {
+ const res = shell.exec(.{
+ .env = env,
+ .argv = argv,
+ .cwd = self.path,
+ });
+ switch (res) {
+ .success => |out| return out,
+ .failure => |out| {
+ var errmsg = out;
+ if (std.mem.indexOfScalar(u8, errmsg, '\n')) |eol| {
+ errmsg = errmsg[0..eol];
+ if (errmsg.len > 0 and errmsg[errmsg.len - 1] == '\r') {
+ errmsg = errmsg[0 .. errmsg.len - 1];
+ }
+ }
+ fatal("{s}", .{errmsg});
+ },
+ }
+ }
+};
+
+pub fn is_valid_branch_name(name: []const u8) bool {
+ var last_c: u8 = 0;
+ for (name) |c| {
+ switch (c) {
+ 0...31 => return false,
+ ':', '?', '[', '\\', '^', '~', ' ', '*' => return false,
+ '.' => if (last_c == '.') return false,
+ '{' => if (last_c == '@') return false,
+ else => {},
+ }
+ last_c = c;
+ }
+ return true;
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 0000000..bafcbd8
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,1195 @@
+const options = @import("options");
+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 tmpl = @import("template.zig");
+const tree = @import("tree.zig");
+const port = @import("port.zig");
+const git = @import("git.zig");
+const Env = @import("env.zig");
+const Time = @import("time.zig").Time;
+
+const default_tree_name = "default";
+
+pub fn main() !void {
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ const allocator = gpa.allocator();
+
+ var main_cmd: Main_Command = undefined;
+ var args: cli.Args = try .init(allocator, "porteur", &main_cmd);
+ defer args.deinit(allocator);
+ try main_cmd.execute(allocator, &args);
+}
+
+const Main_Command = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur",
+ .synopsis = &.{
+ "porteur [--etc=⟨path⟩] tree ⟨command⟩ [options] [⟨args⟩]",
+ "porteur [--etc=⟨path⟩] tmpl ⟨command⟩ [options] [⟨args⟩]",
+ "porteur [--etc=⟨path⟩] port ⟨command⟩ [options] [⟨args⟩]",
+ "porteur help [⟨command⟩ [⟨subcommand⟩]]",
+ },
+ .short_description = "Build your own ports.",
+ .long_description = std.fmt.comptimePrint(
+ \\Porteur is a tool for managing and creating custom ports from a Git repository.
+ \\Each port is part of a ports tree and results in a FreeBSD port usable by
+ \\`make` or `poudriere`. It uses a template to generate the files that describe
+ \\how to build the application from source code.
+ \\
+ \\To add a new port some basic information (e.g. name, category) needs to be
+ \\provided including a Git repository where the source code can be found. Porteur
+ \\always uses the configured branch of the Git repository and operates only on
+ \\that version of the source code. When a port is updated, porteur checks the
+ \\latest commit on this branch and updates the port information accordingly.
+ \\
+ \\Each port lives in a ports tree which can be used by the FreeBSD ports system.
+ \\When operating on ports there must always be a tree specified. If no tree name
+ \\is provided explicitly the default tree `{[default_tree]s}` will be used.
+ , .{ .default_tree = default_tree_name }),
+ .extra_sections = &.{
+ .{
+ .heading = "TEMPLATES",
+ .body =
+ \\Each FreeBSD port consists of a collection of files that automate the
+ \\compilation of an application. Since each port in the FreeBSD ports
+ \\tree requires its own set of files, maintaining them can be tedious,
+ \\especially when large parts of the content is identical.
+ \\
+ \\To make the definition of such files reusable, porteur uses templates,
+ \\located in the `⟨etc⟩/porteur/tmpl/` directory (see FILES). Each port
+ \\must reference a template and defines the variables used within it.
+ \\
+ \\A template is a collection of files that will used to render a port
+ \\usable by FreeBSD. Each file can use variables to render port specific
+ \\data, where a variable `v` can be referenced with the placeholder
+ \\`{{v}}`. It is also possible to use variables in filenames. For more
+ \\information how to define variables, see the VARIABLES section.
+ \\
+ \\Normally, the processed template files are used to create the port
+ \\files, the distribution file is created, and a distinfo is generated.
+ \\To have more control over this process, a template can define a file
+ \\named `.config` in its root directory. For a documentation of all
+ \\configurable options, see the sample configuration file (see FILES).
+ ,
+ },
+ .{
+ .heading = "VARIABLES",
+ .body =
+ \\Variables are used to substitute port specific values in templates.
+ \\Defining variables is file-based and has the following syntax:
+ \\
+ \\ key = value
+ \\
+ \\Keys define the variable name that can later be referenced (e.g. in
+ \\a template file) and the assigned value is the string that replaces
+ \\this key. Leading and trailing whitespace in a value is trimmed.
+ \\Normally, a key/value pair is defined on a single line, but multi-line
+ \\values are also supported:
+ \\
+ \\ key = value line one
+ \\ = value line two
+ \\ =
+ \\ = value line three
+ \\
+ \\Each continuation line starts with an empty key and is rendered as a
+ \\separate line in the template. This also allows empty lines to be
+ \\part of the value. Again, leading and trailing whitespace of each line
+ \\is trimmed.
+ \\
+ \\To avoid redefining common information already known (e.g., port name,
+ \\port version), porteur predefines certain variables. All predefined
+ \\variable names start with a dot (`.`), so it is recommended not to use
+ \\custom variables starting with a dot.
+ \\
+ \\List of predefined variables:
+ \\
+ \\ .portname - Name of the port
+ \\ .portversion - Current version of the port
+ \\ .category - Category of the port
+ \\ .distdir - Directory containing all distfiles
+ \\ .distname - Name of the archive file containing the distribution
+ ,
+ },
+ .{
+ .heading = "DISTFILES",
+ .body =
+ \\To compile a port, a distribution is necessary that contains the source
+ \\code. Porteur creates a gzipped archive of the Git repository and puts
+ \\it in the configured `distfiles-dir`. The filename of this archive is
+ \\provided in the `.dist.name` variable and the directory where its put in
+ \\is provided in the `.dist.dir` variable (see VARIABLES). So the template
+ \\can reference the archive's location and tell FreeBSD where to find the
+ \\source code.
+ \\
+ \\Sometimes it is necessary to do extra steps before the distribution is
+ \\created (e.g. vendoring). These steps can be defined as a target in the
+ \\port Makefile of the template. When porteur executes this target it will
+ \\set ${WRKSRC} to the port's source tree. The target name can be defined
+ \\with `dist-prepare-target` in the template's configuration file (see
+ \\TEMPLATES).
+ ,
+ },
+ .{
+ .heading = "FILES",
+ .body = std.fmt.comptimePrint(
+ \\Porteur uses two locations where data will be stored. The first location
+ \\is `{[prefix]s}/etc/` and contains all configuration files (ports and
+ \\templates). This location can be overwritten with the --etc flag. The
+ \\second location is `{[prefix]s}/porteur/` and contains a copy of the
+ \\sources and all generated port files that FreeBSD uses.
+ \\
+ \\The following directory structure is used:
+ \\
+ \\ {[prefix]s}/etc/porteur/porteur.conf
+ \\ Configuration file for porteur.
+ \\
+ \\ {[prefix]s}/etc/porteur/porteur.conf.sample
+ \\ Sample configuration file for porteur.
+ \\
+ \\ {[prefix]s}/etc/porteur/template.conf.sample
+ \\ Sample configuration file for a template.
+ \\
+ \\ {[prefix]s}/etc/porteur/ports/<tree>/
+ \\ Configuration for each port.
+ \\
+ \\ {[prefix]s}/etc/porteur/tmpl/⟨template⟩/
+ \\ Template definitions used by the ports.
+ \\
+ \\ {[prefix]s}/porteur/ports/⟨tree⟩/
+ \\ Generated ports tree used by FreeBSD.
+ \\
+ \\ {[prefix]s}/porteur/src/⟨tree⟩/
+ \\ Copies of the sources for each port.
+ \\
+ \\ {[prefix]s}/porteur/.wrk/
+ \\ Working directory for porteur.
+ , .{ .prefix = options.path_prefix }),
+ },
+ },
+ };
+
+ options: struct {
+ etc: cli.Option([]const u8, .{
+ .name = "etc",
+ .argument_name = "path",
+ .short_description = "The path to porteur's configuration directory.",
+ .long_description = std.fmt.comptimePrint(
+ \\The path of the directory porteur is storing its configuration.
+ \\Defaults to `{[prefix]s}/etc/`. See the FILES section for more
+ \\information.
+ , .{ .prefix = options.path_prefix }),
+ .default_value = options.path_prefix ++ "/etc/",
+ }),
+ },
+
+ pub const Subcommands = union(enum) {
+ help: Help_Command,
+ tmpl: Tmpl_Command,
+ tree: Tree_Command,
+ port: Port_Command,
+ };
+
+ pub fn execute(self: *Main_Command, allocator: std.mem.Allocator, args: *cli.Args) !void {
+ const subcmd = args.next_subcommand(Main_Command) orelse {
+ cli.print_usage(cli.stdout, Main_Command);
+ return;
+ };
+
+ const etc_path = self.options.etc.value_or_default();
+ const env: Env = .init(allocator, etc_path, args);
+ switch (subcmd) {
+ .help => |cmd| cmd.execute(env),
+ .tmpl => |cmd| cmd.execute(env),
+ .tree => |cmd| cmd.execute(env),
+ .port => |cmd| cmd.execute(env),
+ }
+ }
+};
+
+const Help_Command = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur help",
+ .synopsis = &.{
+ "porteur help [⟨command⟩ [⟨subcommand⟩]]",
+ },
+ .short_description = "Display help information.",
+ .long_description =
+ \\Without ⟨command⟩ and ⟨subcommand⟩ given, the synopsis of the porteur command
+ \\will be printed. This is equivalent to using `porteur` without any arguments.
+ \\
+ \\When ⟨command⟩ is specified without ⟨subcommand⟩, a detailed description of this
+ \\command is printed. An additional ⟨subcommand⟩ prints the detailed description of
+ \\the subcommand.
+ \\
+ \\The `porteur help ...` and `porteur ... --help` commands are identical.
+ \\
+ \\This help text can be displayed with `porteur help help` or `porteur help --help`.
+ ,
+ };
+
+ pub const Subcommands = Help_Subcommands(Main_Command.Subcommands);
+
+ fn execute(self: Help_Command, env: Env) void {
+ _ = self;
+ const subcmd = env.args.next_subcommand(Help_Command) orelse {
+ cli.print_usage(cli.stdout, Main_Command);
+ return;
+ };
+
+ switch (subcmd) {
+ inline else => |cmd| execute_subcmd(env.args, @TypeOf(cmd)),
+ }
+ }
+
+ fn execute_subcmd(args: *cli.Args, comptime Command: type) void {
+ if (@hasDecl(Command, "Subcommands")) {
+ if (args.next_subcommand(Command)) |subcmd| {
+ switch (subcmd) {
+ inline else => |cmd| {
+ execute_subcmd(args, @TypeOf(cmd));
+ return;
+ },
+ }
+ }
+ }
+ cli.print_help(Command);
+ }
+
+ fn Help_Subcommands(comptime Subcmds: type) type {
+ assert(@typeInfo(Subcmds) == .@"union");
+ const union_info = @typeInfo(Subcmds).@"union";
+
+ var fields: [union_info.fields.len]std.builtin.Type.UnionField = undefined;
+ for (union_info.fields, 0..) |field, i| {
+ fields[i] = .{
+ .name = field.name,
+ .alignment = field.alignment,
+ .type = struct {
+ pub const help = field.type.help;
+ },
+ };
+ }
+ return @Type(.{ .@"union" = .{
+ .layout = union_info.layout,
+ .tag_type = union_info.tag_type,
+ .fields = &fields,
+ .decls = &.{},
+ } });
+ }
+};
+
+const Tmpl_Command = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tmpl",
+ .synopsis = &.{
+ "porteur tmpl ⟨command⟩ [options] [⟨args⟩]",
+ },
+ .short_description = "Provide management of port templates.",
+ .long_description =
+ \\This command manages port templates. A port template defines the files
+ \\a port uses to automate the compilation of its source code. To use a
+ \\template with multiple ports variables can be used which are defined
+ \\for each port and later replaced when the port will be updated.
+ \\
+ \\A variable definition within a template file has the form `{{key}}`,
+ \\where the value of `key` can be defined for each port using this
+ \\template. If no such value is defined for a port, it will simply be
+ \\replaced by an empty string.
+ \\
+ \\For more information about defining and using templates, see the
+ \\TEMPLATES section in `porteur --help`.
+ ,
+ };
+
+ pub const Subcommands = union(enum) {
+ list: Tmpl_Ls,
+ conf: Tmpl_Conf,
+ };
+
+ pub fn execute(self: Tmpl_Command, env: Env) void {
+ _ = self;
+ const subcmd = env.args.next_subcommand(Tmpl_Command) orelse {
+ cli.fatal_with_usage(Tmpl_Command, "missing subcommand", .{});
+ };
+
+ switch (subcmd) {
+ .list => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to list templates"),
+ .conf => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to update template configuration"),
+ }
+ }
+
+ const Tmpl_Ls = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tmpl ls",
+ .synopsis = &.{
+ "porteur tmpl [options] ls",
+ },
+ .short_description = "List all port templates.",
+ .long_description =
+ \\This command lists the names of all port templates that are known by
+ \\porteur.
+ ,
+ };
+ options: struct {
+ long: cli.Option(bool, .{
+ .name = "long",
+ .short_name = 'l',
+ .short_description = "List templates in the long format.",
+ .long_description =
+ \\List the templates in a longer format that includes more information
+ \\about each template. In the long format a template is listed with its
+ \\path.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Tmpl_Ls, env: Env) !void {
+ const long = self.options.long.value_or_default();
+ const print_opts: cli.Print_Options = .{ .hanging_indent = 4 };
+ var iter = try tmpl.iterate(env);
+ defer iter.deinit();
+ if (long) {
+ while (try iter.next()) |template| {
+ cli.stdout.printf_line("{s} {s}", .{ template.name, template.path.name() }, print_opts);
+ }
+ } else {
+ while (try iter.next()) |template| {
+ cli.stdout.print_line(template.name, print_opts);
+ }
+ }
+ cli.stdout.flush();
+ }
+ };
+
+ const Tmpl_Conf = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tmpl conf",
+ .synopsis = &.{
+ "porteur tmpl [options] conf ⟨template⟩",
+ "porteur tmpl [options] conf (--edit | -e) ⟨template⟩",
+ "porteur tmpl [options] conf --import=⟨path⟩ ⟨template⟩",
+ },
+ .short_description = "Manage the configuration for a template.",
+ .long_description =
+ \\Manage the configuration for a template. Without a flag specified it lists
+ \\the current configuration values for this template. To edit the configuration
+ \\either use the --edit or the --import option. With the --edit option set,
+ \\the existing configuration can be modified interactively using an editor.
+ \\The --import flag imports an external configuration from a file and overwrites
+ \\the existing one. The configuration file consists of a list of variables (see
+ \\the VARIABLES section in `porteur --help`). A documentation of possible
+ \\configuration values can be found in `⟨etc⟩/porteur/template.conf.sample`.
+ ,
+ };
+ options: struct {
+ edit: cli.Option(bool, .{
+ .name = "edit",
+ .short_name = 'e',
+ .short_description = "Edit the template configuration interactively.",
+ .long_description =
+ \\Edit the existing configuration for the template interactively. An
+ \\editor will be invoked where the existing configuration can be updated.
+ \\After exiting the editor the updates need to be confirmed. This
+ \\flag is mutual exclusive with --import.
+ ,
+ }),
+ import: cli.Option([]const u8, .{
+ .name = "import",
+ .argument_name = "path",
+ .short_description = "Import new configuration from the given file.",
+ .long_description =
+ \\Import the new configuration for the template from the specified file.
+ \\This file must contain valid variable definitions and will overwrite
+ \\the existing configuration for the template. This flag is mutual
+ \\exclusive with --edit.
+ ,
+ }),
+ },
+
+ fn execute(self: Tmpl_Conf, env: Env) !void {
+ const template_name = env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing template name", .{});
+ };
+
+ const edit = self.options.edit.value;
+ const import = self.options.import.value;
+ if (edit != null and import != null) {
+ cli.fatal("can either edit or import configuration", .{});
+ }
+
+ if (edit) |_| {
+ try tmpl.edit_config(env, template_name);
+ } else if (import) |filename| {
+ try tmpl.import_config(env, template_name, .init(filename));
+ } else {
+ const template = try tmpl.must_get(env, template_name);
+ const config = try tmpl.read_config(env, template);
+ defer config.deinit();
+ var iter = config.raw.iterate();
+ while (iter.next()) |c| {
+ cli.stdout.write_line("{f}", .{c});
+ }
+ cli.stdout.flush();
+ }
+ }
+ };
+};
+
+const Tree_Command = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tree",
+ .synopsis = &.{
+ "porteur tree ⟨command⟩ [options] [⟨args⟩]",
+ },
+ .short_description = "Provide management of ports trees.",
+ .long_description =
+ \\This command manages ports trees. A ports tree is a collection of your own
+ \\ports which can be used with the FreeBSD ports system. A port is a collection
+ \\of files that automate the compilation of an application from its source code.
+ ,
+ };
+
+ pub const Subcommands = union(enum) {
+ list: Tree_Ls,
+ rm: Tree_Rm,
+ };
+
+ pub fn execute(self: Tree_Command, env: Env) void {
+ _ = self;
+ const subcmd = env.args.next_subcommand(Tree_Command) orelse {
+ cli.fatal_with_usage(Tree_Command, "missing subcommand", .{});
+ };
+
+ switch (subcmd) {
+ .list => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to iterate ports trees"),
+ .rm => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to remove ports trees"),
+ }
+ }
+
+ const Tree_Ls = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tree ls",
+ .synopsis = &.{
+ "porteur tree ls",
+ },
+ .short_description = "List all existing ports trees.",
+ .long_description =
+ \\This command lists the names of all ports trees that are initialized with
+ \\porteur. The name is used to specify a specific tree when dealing with
+ \\ports.
+ \\
+ \\It simply prints all tree names line by line. For more information about
+ \\a specific tree use the `porteur tree info` command.
+ ,
+ };
+
+ fn execute(self: Tree_Ls, env: Env) !void {
+ _ = self;
+ var iter = try tree.iterate(env);
+ defer iter.deinit();
+ while (try iter.next()) |t| {
+ cli.stdout.print_line(t.name, .{});
+ }
+ cli.stdout.flush();
+ }
+ };
+
+ const Tree_Rm = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur tree rm",
+ .synopsis = &.{
+ "porteur tree rm ⟨tree⟩",
+ },
+ .short_description = "Remove an existing ports tree.",
+ .long_description =
+ \\This command removes an existing ports tree and deletes all its ports.
+ \\It asks for confirmation before deleting a tree.
+ ,
+ };
+ options: struct {
+ force: cli.Option(bool, .{
+ .name = "force",
+ .short_name = 'f',
+ .short_description = "Do not prompt for confirmation.",
+ .long_description =
+ \\Attempt to remove a ports tree without prompting for confirmation.
+ \\If the tree does not exist, do not report an error.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Tree_Rm, env: Env) !void {
+ const force = self.options.force.value_or_default();
+ const name = env.args.next_positional() orelse {
+ if (force) std.process.exit(0);
+ cli.fatal_with_usage(@This(), "missing tree name", .{});
+ };
+
+ const ports_tree = try tree.get(env, name) orelse {
+ if (force) return;
+ cli.fatal("tree not found", .{});
+ };
+
+ if (!force) {
+ const confirmed = cli.ask_confirmation("Remove tree {s}?", .{ports_tree.name}, .no);
+ if (!confirmed) return;
+ }
+
+ try port.remove_all(.{
+ .env = env,
+ .tree = ports_tree,
+ });
+ tree.remove(env, ports_tree) catch {};
+ }
+ };
+};
+
+const Port_Command = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port",
+ .synopsis = &.{
+ "porteur port [--tree=⟨tree⟩ | -t ⟨tree⟩] ⟨command⟩ [options] [⟨args⟩]",
+ },
+ .short_description = "Provide management of ports in a ports tree.",
+ .long_description =
+ \\This command manages the ports of a ports tree. A port is a collection of
+ \\files that automate the compilation of an application from its source code.
+ \\It references a Git repository that contains the source code and a template
+ \\which is used to generate the port files from.
+ ,
+ };
+
+ options: struct {
+ tree: cli.Option([]const u8, .{
+ .name = "tree",
+ .short_name = 't',
+ .argument_name = "tree",
+ .short_description = "The name of the ports tree.",
+ .long_description = std.fmt.comptimePrint(
+ \\The name of the ports tree. If this option is not specified the default
+ \\tree `{[default_tree]s}` will be used.
+ , .{ .default_tree = default_tree_name }),
+ .default_value = default_tree_name,
+ }),
+ },
+
+ pub const Subcommands = union(enum) {
+ list: Port_Ls,
+ info: Port_Info,
+ add: Port_Add,
+ vars: Port_Vars,
+ update: Port_Update,
+ bump_version: Port_Bump_Version,
+ history: Port_History,
+ rollback: Port_Rollback,
+ rm: Port_Rm,
+ };
+
+ pub fn execute(self: Port_Command, env: Env) void {
+ const subcmd = env.args.next_subcommand(Port_Command) orelse {
+ cli.fatal_with_usage(Port_Command, "missing subcommand", .{});
+ };
+
+ const tree_name = self.options.tree.value_or_default();
+ const ports_tree = tree.get(env, tree_name) catch |err| fatal_err(err, "failed to get tree");
+ if (ports_tree == null) cli.fatal("ports tree not found: {s}", .{tree_name});
+
+ const ctx = port.Ctx{
+ .env = env,
+ .tree = ports_tree.?,
+ };
+
+ switch (subcmd) {
+ .list => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to list ports"),
+ .info => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to fetch port"),
+ .add => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to write port"),
+ .vars => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to update port variables"),
+ .update => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to update port"),
+ .bump_version => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to bump the port version"),
+ .history => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to list distfile history"),
+ .rollback => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to roll back the port version"),
+ .rm => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to delete port"),
+ }
+ }
+
+ const Port_Ls = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port ls",
+ .synopsis = &.{
+ "porteur port [options] ls",
+ },
+ .short_description = "List all ports of a ports tree.",
+ .long_description =
+ \\This command lists all ports the ports tree contains. It simply prints
+ \\all port names along with the port versions line by line. For more
+ \\information about a specific port see the `porteur port info` command.
+ ,
+ };
+ options: struct {
+ long: cli.Option(bool, .{
+ .name = "long",
+ .short_name = 'l',
+ .short_description = "List ports in the long format.",
+ .long_description =
+ \\List the ports in a longer format that includes more information about
+ \\each port. In the long format a port is listed with its current version
+ \\and its category.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Port_Ls, ctx: port.Ctx) !void {
+ const long = self.options.long.value_or_default();
+ const print_opts: cli.Print_Options = .{ .hanging_indent = 4 };
+ var iter = try port.iterate(ctx);
+ defer iter.deinit();
+ if (long) {
+ while (try iter.next()) |p| {
+ const version = try port.read_version(ctx, p);
+ cli.stdout.printf_line("{s}@{f} {s}", .{ p.name, version, p.category }, print_opts);
+ }
+ } else {
+ while (try iter.next()) |p| {
+ cli.stdout.print_line(p.name, print_opts);
+ }
+ }
+ cli.stdout.flush();
+ }
+ };
+
+ const Port_Info = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port info",
+ .synopsis = &.{
+ "porteur port [options] info ⟨port⟩",
+ },
+ .short_description = "Show information about a port.",
+ .long_description =
+ \\This command prints information about the specified port of a ports
+ \\tree. Variables for this port are not listed here. To show them use
+ \\the `porteur port vars` command. If the port cannot be found in the
+ \\ports tree an error will be reported.
+ ,
+ };
+ fn execute(self: Port_Info, ctx: port.Ctx) !void {
+ _ = self;
+ const port_name = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing port name", .{});
+ };
+ const p = try port.must_get(ctx, port_name);
+ defer p.deinit();
+ const version = try port.read_version(ctx, p);
+ const source = try port.read_source(ctx, p);
+ defer source.deinit();
+ const distdir = ctx.env.ports_dist_dir(ctx.tree.name, p.name);
+ cli.stdout.printf_line("Name: {s}", .{p.name}, .{});
+ cli.stdout.printf_line("Category: {s}", .{p.category}, .{});
+ cli.stdout.printf_line("Template: {s}", .{p.template}, .{});
+ cli.stdout.printf_line("Version: {f}", .{version}, .{});
+ cli.stdout.printf_line("Git Repo: {s}", .{source.repo.url}, .{});
+ cli.stdout.printf_line("Git Branch: {s}", .{source.repo.branch}, .{});
+ if (source.commit.hash.len > 0) {
+ cli.stdout.printf_line("Commit Hash: {s}", .{source.commit.hash}, .{});
+ cli.stdout.printf_line("Commit Time: {f}", .{Time.from_timestamp(source.commit.timestamp)}, .{});
+ } else {
+ cli.stdout.printf_line("Commit Hash: -", .{}, .{});
+ cli.stdout.printf_line("Commit Time: -", .{}, .{});
+ }
+ cli.stdout.printf_line("Distdir: {s}", .{distdir.name()}, .{});
+ cli.stdout.flush();
+ }
+ };
+
+ const Port_Add = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port add",
+ .synopsis = &.{
+ "porteur port [options] add",
+ },
+ .short_description = "Add a new port to the ports tree.",
+ .long_description =
+ \\This command adds a new port to the ports tree. A port needs to reference a
+ \\Git repository that holds the source code for distribution and compilation.
+ \\Porteur creates a local clone of this repository.
+ \\
+ \\When adding a new port there will be no variables defined and no FreeBSD
+ \\ports tree files generated. To add variables for the new port, use the
+ \\`porteur port vars` command. If everything is set up correctly, execute
+ \\a `porteur port update` to initially render the port.
+ \\
+ \\This command is interactive.
+ ,
+ };
+
+ fn execute(self: Port_Add, ctx: port.Ctx) !void {
+ _ = self;
+ if (!try tmpl.exists_any(ctx.env)) {
+ const tmpl_dir = tmpl.etc_dir(ctx.env);
+ cli.stderr.print_line("No templates found.", .{});
+ cli.fatal("Please define a template in `{s}`.", .{tmpl_dir.name()});
+ }
+
+ const namebuf = try ctx.env.allocator.alloc(u8, 64);
+ defer ctx.env.allocator.free(namebuf);
+ const name = while (true) {
+ const input = cli.ask("Name: ", .{}, namebuf) orelse {
+ cli.stderr.print_line("ERROR: The port name is required.", .{});
+ cli.stderr.flush();
+ continue;
+ };
+ if (std.mem.indexOfAny(u8, input, "/\\\t ")) |_| {
+ cli.stderr.print_line("ERROR: The port name must not contain slash, backslash, or space.", .{});
+ cli.stderr.flush();
+ continue;
+ }
+ break input;
+ };
+
+ var catbuf: [128]u8 = undefined;
+ var default_cat_buf: [128]u8 = undefined;
+ const default_cat = if (ctx.env.default_category) |cat|
+ try std.fmt.bufPrint(&default_cat_buf, " [{s}]", .{cat})
+ else
+ "";
+ const category = while (true) {
+ const input = cli.ask("Category{s}: ", .{default_cat}, &catbuf) orelse default_cat: {
+ if (ctx.env.default_category) |c| break :default_cat c;
+ cli.stderr.print_line("ERROR: The port category is required.", .{});
+ cli.stderr.flush();
+ continue;
+ };
+ if (std.mem.indexOfAny(u8, input, "/\\\t ")) |_| {
+ cli.stderr.print_line("ERROR: The port category must not contain slash, backslash, or space.", .{});
+ cli.stderr.flush();
+ continue;
+ }
+ break input;
+ };
+
+ var verbuf: [64]u8 = undefined;
+ const version = while (true) {
+ const input = cli.ask("Initial Version [0.1.0]: ", .{}, &verbuf) orelse "0.1.0";
+ const ver = port.Version.parse_semver(input) catch {
+ cli.stderr.print_line("ERROR: The port version must be in the format `⟨major⟩[[.⟨minor⟩[.⟨patch⟩]`.", .{});
+ cli.stderr.flush();
+ continue;
+ };
+ break ver;
+ };
+
+ var repourlbuf: [std.fs.max_path_bytes]u8 = undefined;
+ const repo_url = while (true) {
+ const input = cli.ask("Git Repository: ", .{}, &repourlbuf) orelse {
+ cli.stderr.print_line("ERROR: The Git repository is required.", .{});
+ cli.stderr.flush();
+ continue;
+ };
+ break input;
+ };
+
+ var repobranchbuf: [64]u8 = undefined;
+ const repo_branch = while (true) {
+ const default_branch = ctx.env.default_repo_branch;
+ const input = cli.ask("Git Branch [{s}]: ", .{default_branch}, &repobranchbuf) orelse default_branch;
+ if (!git.is_valid_branch_name(input)) {
+ cli.stderr.print_line("ERROR: Invalid Git branch name.", .{});
+ cli.stderr.flush();
+ continue;
+ }
+ break input;
+ };
+
+ var templatebuf: [64]u8 = undefined;
+ const template = while (true) {
+ const input = cli.ask("Template: ", .{}, &templatebuf) orelse {
+ cli.stderr.print_line("ERROR: The template is required.", .{});
+ cli.stderr.flush();
+ continue;
+ };
+ if (!try tmpl.exists(ctx.env, input)) {
+ cli.stderr.print_line("ERROR: Template does not exist.", .{});
+ cli.stderr.flush();
+ continue;
+ }
+ break input;
+ };
+
+ cli.stderr.print_line("", .{});
+ cli.stderr.printf_line("Name: {s}", .{name}, .{});
+ cli.stderr.printf_line("Category: {s}", .{category}, .{});
+ cli.stderr.printf_line("Initial Version: {f}", .{version}, .{});
+ cli.stderr.printf_line("Git Repository: {s}", .{repo_url}, .{});
+ cli.stderr.printf_line("Git Branch: {s}", .{repo_branch}, .{});
+ cli.stderr.printf_line("Template: {s}", .{template}, .{});
+ cli.stderr.flush();
+
+ const ok = cli.ask_confirmation("OK?", .{}, .yes);
+ if (ok) {
+ const new_port: port.Info = .init(ctx.tree, name, category, template);
+ try port.create(ctx, new_port, .{
+ .repo_url = repo_url,
+ .repo_branch = repo_branch,
+ .initial_version = version,
+ });
+
+ cli.stderr.print_line("", .{});
+ cli.stderr.printf_line("port created: {s}", .{new_port.name}, .{});
+ cli.stderr.print_line("", .{});
+ cli.stderr.print_line("Next steps:", .{});
+ cli.stderr.print_line("(1) use `porteur port vars` to define variables", .{ .margin = 2 });
+ cli.stderr.print_line("(2) use `porteur port update` to initially render the port", .{ .margin = 2 });
+ cli.stderr.flush();
+ }
+ }
+ };
+
+ const Port_Vars = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port vars",
+ .synopsis = &.{
+ "porteur port [options] vars ⟨port⟩",
+ "porteur port [options] vars (--edit | -e) ⟨port⟩",
+ "porteur port [options] vars --import=⟨path⟩ ⟨port⟩",
+ },
+ .short_description = "Manage variables for a port.",
+ .long_description =
+ \\Manage the variables for a port. Without a flag specified it lists all
+ \\existing variables for this port. To edit the variables either use the
+ \\--edit or the --import option. With the --edit option set, all existing
+ \\variables can be modified interactively using an editor. The --import
+ \\flag imports the variables from a file and overwrites all existing ones.
+ \\For more information on how to format the variables see the VARIABLES
+ \\section in `porteur --help`.
+ ,
+ };
+ options: struct {
+ edit: cli.Option(bool, .{
+ .name = "edit",
+ .short_name = 'e',
+ .short_description = "Edit the port variables interactively.",
+ .long_description =
+ \\Edit the existing variables for the port interactively. An editor
+ \\will be invoked where the existing variables can be updated. After
+ \\exiting the editor the updates need to be confirmed. This flag is
+ \\mutual exclusive with --import.
+ ,
+ }),
+ import: cli.Option([]const u8, .{
+ .name = "import",
+ .argument_name = "path",
+ .short_description = "Import new port variables from the given file.",
+ .long_description =
+ \\Import the new variables for the port from the specified file. This
+ \\file must contain valid variable definitions. It will overwrite all
+ \\existing port variables. This flag is mutual exclusive with --edit.
+ ,
+ }),
+ },
+
+ fn execute(self: Port_Vars, ctx: port.Ctx) !void {
+ const port_name = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing port name", .{});
+ };
+
+ const edit = self.options.edit.value;
+ const import = self.options.import.value;
+ if (edit != null and import != null) {
+ cli.fatal("can either edit or import variables", .{});
+ }
+
+ if (edit) |_| {
+ try port.edit_vars(ctx, port_name);
+ } else if (import) |filename| {
+ try port.import_vars(ctx, port_name, .init(filename));
+ } else {
+ const p = try port.must_get(ctx, port_name);
+ defer p.deinit();
+ const vars = try port.read_vars(ctx, p);
+ defer vars.deinit();
+ var iter = vars.iterate();
+ while (iter.next()) |v| {
+ cli.stdout.write_line("{f}", .{v});
+ }
+ cli.stdout.flush();
+ }
+ }
+ };
+
+ const Port_Update = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port update",
+ .synopsis = &.{
+ "porteur port [options] update [⟨port⟩ ...]",
+ },
+ .short_description = "Update ports in a ports tree.",
+ .long_description =
+ \\This command updates one or more ports in the ports tree. For each port, it
+ \\checks if there is a new commit for the configured Git branch. If so, the
+ \\files in the ports tree will be generated from the template and the new
+ \\distribution files will be created. A Git commit is considered new if it has
+ \\a newer date than the last commit from which the port files were generated
+ \\or if the date is the same, but the commit hash is different.
+ \\
+ \\If no port name is provided it updates all existing ports in the ports tree.
+ ,
+ };
+ options: struct {
+ force: cli.Option(bool, .{
+ .name = "force",
+ .short_name = 'f',
+ .short_description = "Force the port update.",
+ .long_description =
+ \\Attempt to do the port update although the current commit for the branch
+ \\is not newer than the last one. This option forces a rerender for the
+ \\port based on the branch's latest commit.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Port_Update, ctx: port.Ctx) !void {
+ const opts: port.Update_Options = .{
+ .force = self.options.force.value_or_default(),
+ };
+ var count: usize = 0;
+ while (ctx.env.args.next_positional()) |port_name| {
+ count += 1;
+ try update_single_port(ctx, port_name, opts);
+ }
+ if (count == 0) {
+ var iter = try port.iterate(ctx);
+ defer iter.deinit();
+ while (try iter.next()) |p| {
+ try update_single_port(ctx, p.name, opts);
+ }
+ }
+ }
+
+ fn update_single_port(ctx: port.Ctx, port_name: []const u8, opts: port.Update_Options) !void {
+ if (try port.update_version(ctx, port_name, opts)) |new_version| {
+ cli.stderr.printf_line("updated {s} to {f}", .{ port_name, new_version }, .{});
+ } else {
+ cli.stderr.printf_line("already up to date: {s}", .{port_name}, .{});
+ }
+ cli.stderr.flush();
+ }
+ };
+
+ const Port_Bump_Version = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port bump-version",
+ .synopsis = &.{
+ "porteur port [options] bump-version [--major | --minor | --patch] ⟨port⟩",
+ },
+ .short_description = "Increment the version of a port.",
+ .long_description =
+ \\This command increments the version of a port without updating its sources.
+ \\It is possible to increment the major version, the minor version, or the
+ \\patch version. By default, the patch version will be incremented.
+ ,
+ };
+ options: struct {
+ major: cli.Option(bool, .{
+ .name = "major",
+ .short_description = "Increment the major version.",
+ .long_description =
+ \\Increment the major version of the port by one and reset the minor and patch
+ \\version to zero. This flag in mutual exclusive with --minor and --patch.
+ ,
+ .default_value = false,
+ }),
+ minor: cli.Option(bool, .{
+ .name = "minor",
+ .short_description = "Increment the minor version.",
+ .long_description =
+ \\Increment the minor version of the port by one and reset the patch version
+ \\to zero. The major version stays untouched. This flag in mutual exclusive
+ \\with --major and --patch.
+ ,
+ .default_value = false,
+ }),
+ patch: cli.Option(bool, .{
+ .name = "patch",
+ .short_description = "Increment the patch version.",
+ .long_description =
+ \\Increment the patch version of the port by one. The major and minior version
+ \\stays untouched. This flag in mutual exclusive with --major and --minor.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Port_Bump_Version, ctx: port.Ctx) !void {
+ const bump_major: u8 = @intFromBool(self.options.major.value_or_default());
+ const bump_minor: u8 = @intFromBool(self.options.minor.value_or_default());
+ const bump_patch: u8 = @intFromBool(self.options.patch.value_or_default());
+ switch (bump_major + bump_minor + bump_patch) {
+ 0, 1 => {},
+ else => cli.fatal("can either bump major, minor, or patch version", .{}),
+ }
+
+ const port_name = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing port name", .{});
+ };
+
+ const new_version = try port.bump_version(ctx, port_name, .{
+ .which = if (bump_major != 0) .major else if (bump_minor != 0) .minor else .patch,
+ });
+
+ cli.stderr.printf_line("port version bumped to {f}", .{new_version}, .{});
+ cli.stderr.flush();
+ }
+ };
+
+ const Port_History = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port history",
+ .synopsis = &.{
+ "porteur port [options] history ⟨port⟩",
+ },
+ .short_description = "List all existing distfile version of a port.",
+ .long_description =
+ \\This command lists all existing distfile versions for the given port.
+ \\The number of distfiles kept can be configured in `porteur.conf`.
+ ,
+ };
+ options: struct {
+ long: cli.Option(bool, .{
+ .name = "long",
+ .short_name = 'l',
+ .short_description = "List distfile version in the long format.",
+ .long_description =
+ \\List the distfile version in a longer format that also includes the
+ \\distfile locations.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Port_History, ctx: port.Ctx) !void {
+ const long = self.options.long.value_or_default();
+ const port_name = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing port name", .{});
+ };
+ var distfiles = try port.list_distfiles(ctx, port_name);
+ defer distfiles.deinit(ctx.env.allocator);
+
+ if (long) {
+ const version_strings = try ctx.env.allocator.alloc(port.Version_String, distfiles.items.len);
+ defer ctx.env.allocator.free(version_strings);
+ var max_version_len: usize = 0;
+ for (distfiles.items, 0..) |distfile, i| {
+ version_strings[i] = distfile.version.to_string();
+ max_version_len = @max(max_version_len, version_strings[i].len);
+ }
+
+ const spaces = [_]u8{' '} ** 64;
+ for (distfiles.items, version_strings) |distfile, version| {
+ const fill = spaces[0 .. max_version_len - version.len];
+ cli.stdout.write_line("{s}{s} {s}", .{ version.slice(), fill, distfile.path.name() });
+ }
+ } else {
+ for (distfiles.items) |d| {
+ cli.stdout.write_line("{f}", .{d.version});
+ }
+ }
+ cli.stdout.flush();
+ }
+ };
+
+ const Port_Rollback = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port rollback",
+ .synopsis = &.{
+ "porteur port [options] rollback ⟨port⟩ ⟨version⟩",
+ },
+ .short_description = "Roll back a port to a specific version.",
+ .long_description =
+ \\This command rolls back an existing ports to the given version.
+ \\The distfile of this version must still be in the port's history.
+ \\It asks for confirmation before rolling back a port.
+ ,
+ };
+
+ fn execute(self: Port_Rollback, ctx: port.Ctx) !void {
+ _ = self;
+ const port_name = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing port name", .{});
+ };
+ const version_str = ctx.env.args.next_positional() orelse {
+ cli.fatal_with_usage(@This(), "missing version", .{});
+ };
+ const version = port.Version.parse(version_str) catch {
+ cli.fatal("incorrect version: {s}", .{version_str});
+ };
+
+ try port.rollback(ctx, port_name, version);
+ }
+ };
+
+ const Port_Rm = struct {
+ pub const help = cli.Command_Help{
+ .name = "porteur port rm",
+ .synopsis = &.{
+ "porteur port [options] rm ⟨port⟩ [⟨port⟩ ...]",
+ },
+ .short_description = "Remove ports from a ports tree.",
+ .long_description =
+ \\This command removes one or more existing ports from the ports tree.
+ \\It asks for confirmation before deleting a port.
+ ,
+ };
+ options: struct {
+ force: cli.Option(bool, .{
+ .name = "force",
+ .short_name = 'f',
+ .short_description = "Do not prompt for confirmation.",
+ .long_description =
+ \\Attempt to remove ports without prompting for confirmation. If the port
+ \\does not exist, do not report an error.
+ ,
+ .default_value = false,
+ }),
+ },
+
+ fn execute(self: Port_Rm, ctx: port.Ctx) !void {
+ const force = self.options.force.value_or_default();
+
+ var count: usize = 0;
+ var exit_code: u8 = 0;
+ while (ctx.env.args.next_positional()) |port_name| {
+ count += 1;
+ if (!force) {
+ const confirmed = cli.ask_confirmation("Remove port {s}?", .{port_name}, .no);
+ if (!confirmed) continue;
+ }
+ const deleted = try port.remove(ctx, port_name);
+ if (!deleted and !force) {
+ cli.stderr.printf_line("port not found: {s}", .{port_name}, .{});
+ cli.stderr.flush();
+ exit_code = 1;
+ }
+ }
+ if (count == 0 and !force) {
+ cli.fatal_with_usage(Port_Rm, "missing port name", .{});
+ } else if (exit_code != 0) {
+ std.process.exit(exit_code);
+ }
+ }
+ };
+};
+
+fn fatal_err(err: anyerror, comptime errmsg: []const u8) noreturn {
+ cli.fatal(errmsg ++ ": {s}", .{@errorName(err)});
+}
+
+test {
+ std.testing.refAllDecls(@This());
+}
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));
+}
diff --git a/src/shell.zig b/src/shell.zig
new file mode 100644
index 0000000..d361b09
--- /dev/null
+++ b/src/shell.zig
@@ -0,0 +1,191 @@
+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 Env = @import("env.zig");
+const fatal = @import("cli.zig").fatal;
+
+pub const Exec_Args = struct {
+ env: Env,
+ argv: []const []const u8,
+ cwd: ?fs.Path = null,
+};
+
+pub const Exec_Result = union(enum) {
+ success: []const u8,
+ failure: []const u8,
+};
+
+pub fn exec(args: Exec_Args) Exec_Result {
+ assert(args.argv.len > 0);
+
+ const res = std.process.Child.run(.{
+ .allocator = args.env.allocator,
+ .argv = args.argv,
+ .cwd = if (args.cwd) |cwd| cwd.name() else null,
+ .cwd_dir = if (args.cwd == null) std.fs.cwd() else null,
+ }) catch |err| {
+ fatal("error executing {s}: {s}", .{ args.argv[0], @errorName(err) });
+ };
+ switch (res.term) {
+ .Exited => |code| {
+ if (code == 0) {
+ args.env.dealloc(res.stderr);
+ return .{ .success = res.stdout };
+ } else if (res.stderr.len > 0) {
+ args.env.dealloc(res.stdout);
+ return .{ .failure = res.stderr };
+ } else {
+ args.env.dealloc(res.stderr);
+ return .{ .failure = res.stdout };
+ }
+ },
+ .Signal => fatal("signal received executing {s}", .{args.argv[0]}),
+ .Stopped => fatal("stopped executing {s}", .{args.argv[0]}),
+ .Unknown => fatal("unknown error executing {s}", .{args.argv[0]}),
+ }
+}
+
+pub fn run(args: Exec_Args) void {
+ assert(args.argv.len > 0);
+
+ var child = std.process.Child.init(args.argv, args.env.allocator);
+ if (args.cwd) |cwd| child.cwd = cwd.name();
+ child.cwd_dir = if (args.cwd == null) std.fs.cwd() else null;
+ child.spawn() catch |err| {
+ fatal("error executing {s}: {s}", .{ args.argv[0], @errorName(err) });
+ };
+ errdefer _ = child.kill() catch {};
+
+ const term = child.wait() catch |err| {
+ fatal("error waiting for {s}: {s}", .{ args.argv[0], @errorName(err) });
+ };
+ switch (term) {
+ .Exited => |code| if (code != 0) std.process.exit(code),
+ .Signal => fatal("signal received executing {s}", .{args.argv[0]}),
+ .Stopped => fatal("stopped executing {s}", .{args.argv[0]}),
+ .Unknown => fatal("unknown error executing {s}", .{args.argv[0]}),
+ }
+}
+
+pub fn edit_config_file(env: Env, file: fs.Path, comptime verify: fn (env: Env, vars: conf.Config) bool) !void {
+ const tmp_file = tmp_edit_file(env, file);
+
+ fs.cp_file(file, tmp_file) catch |err| {
+ switch (err) {
+ error.FileNotFound => try fs.touch(tmp_file),
+ else => return err,
+ }
+ };
+
+ edit: while (true) {
+ const vars: ?conf.Config = parse: while (true) {
+ try exec_editor(env, tmp_file);
+ const parsed = try conf.Config.from_file(env.allocator, file.name());
+ switch (parsed) {
+ .conf => |c| break :parse c,
+ .err => |e| {
+ env.err("{s} (line {d})", .{ e.msg, e.line });
+ switch (ask_edit_again()) {
+ .again => continue,
+ .cancel => break :parse null,
+ }
+ },
+ }
+ };
+
+ if (vars) |v| {
+ defer v.deinit();
+ if (verify(env, v)) {
+ try fs.mv_file(tmp_file, file);
+ return;
+ }
+ switch (ask_edit_again()) {
+ .again => continue,
+ .cancel => break :edit,
+ }
+ } else {
+ break :edit;
+ }
+ }
+ try fs.rm(tmp_file);
+}
+
+pub fn edit_config_file_noverify(env: Env, file: fs.Path) !void {
+ try edit_config_file(env, file, struct {
+ fn noverify(_: Env, _: conf.Config) bool {
+ return true;
+ }
+ }.noverify);
+}
+
+fn ask_edit_again() enum { again, cancel } {
+ while (true) {
+ var again_buf: [16]u8 = undefined;
+ const again = cli.ask("Edit again? (yes/no) [yes] ", .{}, &again_buf) orelse "yes";
+ for (again, 0..) |c, i| again_buf[i] = std.ascii.toLower(c);
+ if (std.mem.eql(u8, again, "y") or std.mem.eql(u8, again, "yes")) {
+ return .again;
+ }
+ if (std.mem.eql(u8, again, "n") or std.mem.eql(u8, again, "no")) {
+ cli.stderr.print_line("cancelled (changes discarded)", .{});
+ cli.stderr.flush();
+ return .cancel;
+ }
+ }
+}
+
+fn exec_editor(env: Env, file: fs.Path) !void {
+ const pid = std.posix.fork() catch |err| {
+ fatal("cannot fork editor: {s}", .{@errorName(err)});
+ };
+
+ if (pid == 0) {
+ // editor process
+ var editor_env: [512:null]?[*:0]const u8 = undefined;
+ var editor_envlen: usize = 0;
+ const environ = if (std.os.environ.len < 512) std.os.environ else std.os.environ[0..511];
+ for (environ) |e| {
+ editor_env[editor_envlen] = e;
+ editor_envlen += 1;
+ }
+ editor_env[editor_envlen] = null;
+
+ var configured_editor_buf: [std.fs.max_path_bytes]u8 = undefined;
+ var configured_editor: ?[:0]u8 = null;
+ if (env.editor_cmd) |cmd| {
+ @memcpy(configured_editor_buf[0..cmd.len], cmd);
+ configured_editor_buf[cmd.len] = 0;
+ configured_editor = configured_editor_buf[0..cmd.len :0];
+ }
+
+ const editor = configured_editor orelse std.posix.getenv("VISUAL") orelse std.posix.getenv("EDITOR") orelse "vi";
+ const editor_argv: [*:null]const ?[*:0]const u8 = &.{ editor, file.name(), null };
+ std.posix.execvpeZ(editor_argv[0].?, editor_argv, &editor_env) catch unreachable;
+ std.process.exit(1);
+ }
+
+ _ = std.posix.waitpid(pid, 0);
+}
+
+/// Derive the filename for the temporary file of `file`.
+/// The filename is "{relname}.{timestamp}", where `relname` is the relative
+/// part of file regarding to etc.
+fn tmp_edit_file(env: Env, file: fs.Path) fs.Path {
+ const relname = file.relname(env.etc_path(.{})).?;
+
+ var tmp_filename_buf: [std.fs.max_name_bytes]u8 = undefined;
+ var tmp_filename_len: usize = 0;
+
+ tmp_filename_len += std.fmt.printInt(tmp_filename_buf[tmp_filename_len..], std.time.timestamp(), 16, .lower, .{});
+ tmp_filename_buf[tmp_filename_len] = '.';
+ tmp_filename_len += 1;
+ for (relname) |c| {
+ tmp_filename_buf[tmp_filename_len] = if (c == std.fs.path.sep) '_' else c;
+ tmp_filename_len += 1;
+ }
+
+ return env.work_path(.{ "edit", tmp_filename_buf[0..tmp_filename_len] });
+}
diff --git a/src/template.zig b/src/template.zig
new file mode 100644
index 0000000..210e77d
--- /dev/null
+++ b/src/template.zig
@@ -0,0 +1,476 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = std.debug.assert;
+
+const cli = @import("cli.zig");
+const fs = @import("fs.zig");
+const conf = @import("conf.zig");
+const shell = @import("shell.zig");
+const Env = @import("env.zig");
+const fatal = cli.fatal;
+
+pub const Config = struct {
+ raw: conf.Config,
+
+ prepare_dist_target: ?[]const u8,
+
+ fn from_file(env: Env, tmpl: Template) !Config {
+ const config_file = tmpl.config_file();
+ const conf_res = try conf.Config.from_file(env.allocator, config_file.name());
+ switch (conf_res) {
+ .conf => |c| return .init(env, c),
+ .err => |e| {
+ env.err("{s} (line {d})", .{ e.msg, e.line });
+ tmpl_corrupted(tmpl.name, "invalid configuration");
+ },
+ }
+ }
+
+ fn init(env: Env, raw: conf.Config) Config {
+ var prepare_dist_target: ?[]const u8 = null;
+
+ var iter = raw.iterate();
+ while (iter.next()) |v| {
+ if (std.mem.eql(u8, v.key, "prepare-dist-target") and v.val.len > 0) {
+ prepare_dist_target = v.val;
+ } else {
+ env.warn("unknown template configuration `{s}`", .{v.key});
+ }
+ }
+
+ return .{
+ .raw = raw,
+ .prepare_dist_target = prepare_dist_target,
+ };
+ }
+
+ pub fn deinit(self: Config) void {
+ self.raw.deinit();
+ }
+};
+
+pub const Template = struct {
+ const config_filename = ".config";
+
+ name: []const u8,
+ path: fs.Path,
+
+ pub fn init(env: Env, template_name: []const u8) Template {
+ var template_path = etc_dir(env);
+ template_path.append(.{template_name});
+ return .{
+ .name = template_name,
+ .path = template_path,
+ };
+ }
+
+ pub fn render(self: *const Template, env: Env, tmpl_conf: []const conf.Config, target: fs.Path) !void {
+ var dir = try std.fs.openDirAbsoluteZ(self.path.name(), .{ .iterate = true });
+ defer dir.close();
+
+ var walker = try dir.walk(env.allocator);
+ defer walker.deinit();
+
+ while (try walker.next()) |entry| {
+ if (std.mem.eql(u8, entry.path, config_filename)) {
+ continue;
+ }
+
+ const entry_path = try replace_vars_in_path(target, entry.path, tmpl_conf);
+ switch (entry.kind) {
+ .directory => {
+ try fs.mkdir_all(entry_path);
+ },
+ .file => {
+ env.info("writing template file {s}", .{entry_path.name()[target.len + 1 ..]});
+ const input = try fs.read_file(env.allocator, .join(.{ self.path.name(), entry.path }));
+
+ var output_file = try std.fs.createFileAbsoluteZ(entry_path.name(), .{ .mode = 0o644 });
+ defer output_file.close();
+
+ var write_buf: [1024]u8 = undefined;
+ var output_writer = output_file.writer(&write_buf);
+ const output = &output_writer.interface;
+
+ try replace_vars(output, input.?, tmpl_conf);
+ try output.flush();
+ },
+ else => {},
+ }
+ }
+ }
+
+ /// Does not flush the writer.
+ fn replace_vars(w: *std.io.Writer, text: []const u8, tmpl_confs: []const conf.Config) !void {
+ var rest = text;
+ while (rest.len > 0) {
+ const pos = std.mem.indexOfScalar(u8, rest, '{') orelse {
+ try w.writeAll(rest);
+ return;
+ };
+
+ if (pos > 0) {
+ try w.writeAll(rest[0..pos]);
+ rest = rest[pos..];
+ }
+
+ assert(rest.len > 0 and rest[0] == '{');
+ if (rest.len == 1) {
+ try w.writeByte(rest[0]);
+ return;
+ }
+ if (rest[1] != '{') {
+ try w.writeAll(rest[0..2]);
+ rest = rest[2..];
+ continue;
+ }
+
+ // Consume all opening braces to take the innermost pair.
+ var begin: usize = 2;
+ while (begin < rest.len and rest[begin] == '{') {
+ try w.writeByte('{');
+ begin += 1;
+ }
+
+ const end = std.mem.indexOfScalarPos(u8, rest, begin, '}') orelse return error.missing_variable_closing;
+ if (end == rest.len - 1 or rest[end + 1] != '}') return error.missing_variable_closing;
+
+ const var_name = rest[begin..end];
+ if (var_name.len == 0) return error.missing_variable_name;
+ var found_var = false;
+ for (tmpl_confs) |config| {
+ if (config.find_var(var_name)) |v| {
+ if (v.val.len > 0) {
+ try w.writeAll(v.val);
+ }
+ found_var = true;
+ break;
+ }
+ }
+ if (!found_var) {
+ fatal("variable `{s}` not defined", .{var_name});
+ }
+
+ rest = rest[end + 2 ..];
+ }
+ }
+
+ fn replace_vars_in_path(root: fs.Path, subpath: []const u8, tmpl_confs: []const conf.Config) !fs.Path {
+ assert(root.len > 0);
+ assert(subpath.len > 0);
+
+ const slash = std.fs.path.sep;
+ var res: fs.Path = root;
+ res.buf[res.len] = slash;
+ res.len += 1;
+ var res_writer = std.io.Writer.fixed(res.buf[res.len..]);
+
+ var name = subpath;
+ while (name.len > 0) {
+ const idx = std.mem.indexOfScalar(u8, name, slash) orelse name.len - 1;
+ const part = name[0 .. idx + 1];
+ const pos = res_writer.end;
+ replace_vars(&res_writer, part, tmpl_confs) catch |err| switch (err) {
+ error.missing_variable_closing, error.missing_variable_name => {
+ res_writer.undo(res_writer.end - pos);
+ try res_writer.writeAll(part);
+ },
+ else => return err,
+ };
+ name = name[idx + 1 ..];
+ }
+ try res_writer.flush();
+
+ res.len += res_writer.end;
+ res.buf[res.len] = 0;
+ return res;
+ }
+
+ fn config_file(self: Template) fs.Path {
+ return .join(.{ self.path.name(), config_filename });
+ }
+};
+
+pub const Iterator = struct {
+ path: fs.Path,
+ dir: ?std.fs.Dir = null,
+ iter: ?std.fs.Dir.Iterator = null,
+
+ pub fn deinit(self: *Iterator) void {
+ if (self.dir) |*dir| dir.close();
+ }
+
+ pub fn next(self: *Iterator) !?Template {
+ if (self.iter) |*iter| {
+ while (try iter.next()) |entry| {
+ if (entry.kind == .directory) {
+ var path = self.path;
+ path.append(.{entry.name});
+ return .{
+ .name = entry.name,
+ .path = path,
+ };
+ }
+ }
+ }
+ return null;
+ }
+};
+
+pub inline fn iterate(env: Env) !Iterator {
+ const path = etc_dir(env);
+ const dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| {
+ if (err == error.FileNotFound) {
+ return .{ .path = path };
+ }
+ return err;
+ };
+ return .{
+ .path = path,
+ .dir = dir,
+ .iter = dir.iterate(),
+ };
+}
+
+pub fn exists(env: Env, template_name: []const u8) !bool {
+ return try get(env, template_name) != null;
+}
+
+pub fn exists_any(env: Env) !bool {
+ var iter = try iterate(env);
+ defer iter.deinit();
+ if (try iter.next()) |_| {
+ return true;
+ }
+ return false;
+}
+
+pub fn get(env: Env, template_name: []const u8) !?Template {
+ const tmpl: Template = .init(env, template_name);
+ const info = try fs.dir_info(tmpl.path);
+ return if (info.exists) tmpl else null;
+}
+
+pub fn must_get(env: Env, template_name: []const u8) !Template {
+ return try get(env, template_name) orelse {
+ fatal("template not found: {s}", .{template_name});
+ };
+}
+
+pub fn read_config(env: Env, tmpl: Template) !Config {
+ return Config.from_file(env, tmpl);
+}
+
+pub fn edit_config(env: Env, template_name: []const u8) !void {
+ const tmpl = try must_get(env, template_name);
+ try shell.edit_config_file(env, tmpl.config_file(), verify_config);
+}
+
+pub fn import_config(env: Env, template_name: []const u8, new_config_file: fs.Path) !void {
+ const tmpl = try must_get(env, template_name);
+
+ const parsed = try conf.Config.from_file(env.allocator, new_config_file.name());
+ const config = switch (parsed) {
+ .conf => |c| c,
+ .err => |e| {
+ env.err("{s} (line {d})", .{ e.msg, e.line });
+ fatal("failed to import {s}", .{new_config_file.name()});
+ },
+ };
+ defer config.deinit();
+
+ if (!verify_config(env, config)) {
+ std.process.exit(1);
+ }
+ try fs.mv_file(new_config_file, tmpl.config_file());
+}
+
+pub fn etc_dir(env: Env) fs.Path {
+ return env.etc_path(.{"tmpl"});
+}
+
+fn verify_config(env: Env, raw: conf.Config) bool {
+ const tmpl_conf: Config = .init(env, raw);
+ var valid = true;
+ if (tmpl_conf.prepare_dist_target) |prepare_dist_target| {
+ if (std.mem.indexOfAny(u8, prepare_dist_target, " \n\t\r")) |_| {
+ cli.stderr.write_line("Error: invalid value `prepare-dist-target`", .{});
+ valid = false;
+ }
+ }
+ return valid;
+}
+
+fn tmpl_corrupted(tmpl_name: []const u8, comptime reason: []const u8) noreturn {
+ fatal("template {s} corrupted (" ++ reason ++ ")", .{tmpl_name});
+}
+
+test "render simple template - variable at end" {
+ const tmpl = "This is a simple {{test}} with multiple {{vars}} in different {{places}}";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("test", "template");
+ try config.add_var("not", "used");
+ try config.add_var("vars", "variables");
+ try config.add_var("places", "positions");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, 2 * tmpl.len);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("This is a simple template with multiple variables in different positions", rendered.written());
+}
+
+test "render simple template - variable at beginning" {
+ const tmpl = "{{this}} is a simple {{test}} with multiple {{vars}} in different places";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("this", "This");
+ try config.add_var("test", "template");
+ try config.add_var("not", "used");
+ try config.add_var("vars", "variables");
+ try config.add_var("places", "positions");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, 2 * tmpl.len);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("This is a simple template with multiple variables in different places", rendered.written());
+}
+
+test "render empty template" {
+ const tmpl = "";
+
+ const config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ var rendered: std.io.Writer.Allocating = .init(std.testing.allocator);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("", rendered.written());
+}
+
+test "render template without braces" {
+ const tmpl = "No braces at all";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("No braces at all", rendered.written());
+}
+
+test "render template without variable and brace" {
+ const tmpl = "{No variable seen here {";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("{No variable seen here {", rendered.written());
+}
+
+test "render template with multiple opening braces" {
+ const tmpl = "Use ${{{foo}}} in your env.";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("foo", "FOOBAR");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ try Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectEqualStrings("Use ${FOOBAR} in your env.", rendered.written());
+}
+
+test "render template with missing variable closing" {
+ const tmpl = "Invalid template {{foo";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ const res = Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectError(error.missing_variable_closing, res);
+}
+
+test "render template with missing variable closing at the end" {
+ const tmpl = "Invalid template {{";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ const res = Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectError(error.missing_variable_closing, res);
+}
+
+test "render template with incomplete variable closing" {
+ const tmpl = "Invalid {{foo} template";
+
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len);
+ defer rendered.deinit();
+
+ const res = Template.replace_vars(&rendered.writer, tmpl, &.{config});
+ try std.testing.expectError(error.missing_variable_closing, res);
+}
+
+test "replace variables in path" {
+ var config: conf.Config = .init(std.testing.allocator);
+ defer config.deinit();
+
+ try config.add_var("a", "foo");
+ try config.add_var("b", "bar");
+
+ var replaced: fs.Path = undefined;
+
+ replaced = try Template.replace_vars_in_path(.init("root"), ".config/foo", &.{config});
+ try std.testing.expectEqualStrings("root/.config/foo", replaced.name());
+
+ replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc", &.{config});
+ try std.testing.expectEqualStrings("/usr/local/etc", replaced.name());
+
+ replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{a}}.{{b}}", &.{config});
+ try std.testing.expectEqualStrings("/usr/local/etc/foo.bar", replaced.name());
+
+ replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{/foo", &.{config});
+ try std.testing.expectEqualStrings("/usr/local/etc/{{/foo", replaced.name());
+
+ replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{}}/foo", &.{config});
+ try std.testing.expectEqualStrings("/usr/local/etc/{{}}/foo", replaced.name());
+}
diff --git a/src/time.zig b/src/time.zig
new file mode 100644
index 0000000..290c9de
--- /dev/null
+++ b/src/time.zig
@@ -0,0 +1,134 @@
+const std = @import("std");
+
+pub const Time = struct {
+ year: u16,
+ month: u8,
+ day: u8,
+ hour: u8,
+ minute: u8,
+ second: u8,
+
+ pub fn from_timestamp(timestamp: u64) Time {
+ // Source: https://git.musl-libc.org/cgit/musl/tree/src/time/__secs_to_tm.c
+ // Copyright: https://git.musl-libc.org/cgit/musl/tree/COPYRIGHT
+
+ const leapoch = 946684800 + 86400 * (31 + 29); // 2000-03-01 (mod 400 year, immediately after feb29)
+ const days_per_400y = 365 * 400 + 97;
+ const days_per_100y = 365 * 100 + 24;
+ const days_per_4y = 365 * 4 + 1;
+ const days_in_month = [_]i64{ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 29 };
+
+ var days: i64 = undefined;
+ var remsecs: i64 = undefined;
+ if (timestamp < leapoch) {
+ const secs: i64 = @as(i64, @intCast(timestamp)) - leapoch;
+ days = @divTrunc(secs, 86400);
+ remsecs = @rem(secs, 86400);
+ } else {
+ const secs: u64 = timestamp - leapoch;
+ days = @intCast(secs / 86400);
+ remsecs = @intCast(@rem(secs, 86400));
+ }
+ if (remsecs < 0) {
+ remsecs += 86400;
+ days -= 1;
+ }
+
+ var qc_cycles = @divTrunc(days, days_per_400y);
+ var remdays = @rem(days, days_per_400y);
+ if (remdays < 0) {
+ remdays += days_per_400y;
+ qc_cycles = -1;
+ }
+
+ var c_cycles = @divTrunc(remdays, days_per_100y);
+ if (c_cycles == 4) {
+ c_cycles -= 1;
+ }
+ remdays -= c_cycles * days_per_100y;
+
+ var q_cycles = @divTrunc(remdays, days_per_4y);
+ if (q_cycles == 25) {
+ q_cycles -= 1;
+ }
+ remdays -= q_cycles * days_per_4y;
+
+ var remyears = @divTrunc(remdays, 365);
+ if (remyears == 4) {
+ remyears -= 1;
+ }
+ remdays -= remyears * 365;
+
+ var years = remyears + 4 * q_cycles + 100 * c_cycles + 400 * qc_cycles;
+ var months: u64 = 0;
+ while (remdays >= days_in_month[months]) {
+ remdays -= days_in_month[months];
+ months += 1;
+ }
+
+ if (months < 10) {
+ months += 3;
+ } else {
+ months -= 9;
+ years += 1;
+ }
+
+ return .{
+ .year = @intCast(years + 2000),
+ .month = @intCast(months),
+ .day = @intCast(remdays + 1),
+ .hour = @intCast(@divTrunc(remsecs, 3600)),
+ .minute = @intCast(@rem(@divTrunc(remsecs, 60), 60)),
+ .second = @intCast(@rem(remsecs, 60)),
+ };
+ }
+
+ pub fn format(self: Time, w: *std.io.Writer) std.io.Writer.Error!void {
+ try w.print(
+ "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC",
+ .{ self.year, self.month, self.day, self.hour, self.minute, self.second },
+ );
+ }
+};
+
+pub fn now() u64 {
+ const ts = std.posix.clock_gettime(.REALTIME) catch return 0;
+ return if (ts.sec < 0) 0 else @intCast(ts.sec);
+}
+
+test "time from timestamp" {
+ var t: Time = undefined;
+ const leapoch = 946684800 + 86400 * (31 + 29);
+
+ t = .from_timestamp(0);
+ try std.testing.expectEqual(1970, t.year);
+ try std.testing.expectEqual(1, t.month);
+ try std.testing.expectEqual(1, t.day);
+ try std.testing.expectEqual(0, t.hour);
+ try std.testing.expectEqual(0, t.minute);
+ try std.testing.expectEqual(0, t.second);
+
+ t = .from_timestamp(leapoch - 1);
+ try std.testing.expectEqual(2000, t.year);
+ try std.testing.expectEqual(2, t.month);
+ try std.testing.expectEqual(29, t.day);
+ try std.testing.expectEqual(23, t.hour);
+ try std.testing.expectEqual(59, t.minute);
+ try std.testing.expectEqual(59, t.second);
+
+ t = .from_timestamp(leapoch);
+ try std.testing.expectEqual(2000, t.year);
+ try std.testing.expectEqual(3, t.month);
+ try std.testing.expectEqual(1, t.day);
+ try std.testing.expectEqual(0, t.hour);
+ try std.testing.expectEqual(0, t.minute);
+ try std.testing.expectEqual(0, t.second);
+
+ t = .from_timestamp(leapoch + 1);
+ try std.testing.expectEqual(2000, t.year);
+ try std.testing.expectEqual(3, t.month);
+ try std.testing.expectEqual(1, t.day);
+ try std.testing.expectEqual(0, t.hour);
+ try std.testing.expectEqual(0, t.minute);
+ try std.testing.expectEqual(1, t.second);
+}
diff --git a/src/tree.zig b/src/tree.zig
new file mode 100644
index 0000000..8599c7c
--- /dev/null
+++ b/src/tree.zig
@@ -0,0 +1,67 @@
+const std = @import("std");
+const assert = std.debug.assert;
+
+const fs = @import("fs.zig");
+const Env = @import("env.zig");
+
+pub const Tree = struct {
+ name: []const u8,
+ root: fs.Path,
+
+ pub fn port_path(self: Tree, port_name: []const u8) fs.Path {
+ return .join(.{ self.root.name(), port_name });
+ }
+};
+
+pub const Iterator = struct {
+ root: fs.Path,
+ dir: ?std.fs.Dir = null,
+ iter: ?std.fs.Dir.Iterator = null,
+
+ pub fn deinit(self: *Iterator) void {
+ if (self.dir) |*dir| dir.close();
+ }
+
+ pub fn next(self: *Iterator) !?Tree {
+ if (self.iter) |*iter| {
+ while (try iter.next()) |entry| {
+ if (entry.kind == .directory) {
+ return .{
+ .name = entry.name,
+ .root = .join(.{ self.root.name(), entry.name }),
+ };
+ }
+ }
+ }
+ return null;
+ }
+};
+
+pub inline fn iterate(env: Env) !Iterator {
+ const root = tree_root_dir(env);
+ const dir = std.fs.openDirAbsoluteZ(root.name(), .{ .iterate = true }) catch |err| {
+ if (err == error.FileNotFound) return .{ .root = undefined };
+ return err;
+ };
+ return .{
+ .root = root,
+ .dir = dir,
+ .iter = dir.iterate(),
+ };
+}
+
+pub fn get(env: Env, tree_name: []const u8) !?Tree {
+ var tree_root = tree_root_dir(env);
+ tree_root.append(.{tree_name});
+ return .{ .name = tree_name, .root = tree_root };
+}
+
+pub fn remove(env: Env, tree: Tree) !void {
+ try fs.rm(tree.root);
+ fs.rm(env.ports_tree_path(tree.name, .{})) catch {};
+ fs.rm(env.ports_src_path(tree.name, .{})) catch {};
+}
+
+fn tree_root_dir(env: Env) fs.Path {
+ return env.etc_path(.{"ports"});
+}
diff --git a/template.conf.sample b/template.conf.sample
new file mode 100644
index 0000000..f28b5e5
--- /dev/null
+++ b/template.conf.sample
@@ -0,0 +1,12 @@
+# This is a sample configuration file for a porteur template. To configure a template
+# `mytemplate` create a file `⟨etc⟩/porteur/tmpl/mytemplate/.config` and put the
+# configuration values in there.
+
+
+# The Makefile target that will be executed to prepare the source tree before creating the
+# distribution. This target should execute what needs to be executed to deterministically
+# build the port's source (e.g. vendoring). It needs to be defined in the port's Makefile
+# within the template. When executing this target, porteur sets WRKSRC to the Git repository
+# of the port.
+# If no such target is defined, the preparation step will be skipped.
+dist-prepare-target = prepare-dist