From 44e5ad763794a438ecfd50c8b7f6ea760ea82da5 Mon Sep 17 00:00:00 2001 From: tsne Date: Wed, 1 Jan 2025 14:58:28 +0100 Subject: initial commit --- .gitignore | 2 + LICENSE | 22 + Makefile | 14 + README.md | 45 ++ build.zig | 45 ++ build.zig.zon | 13 + porteur.conf.sample | 37 ++ src/cli.zig | 1162 ++++++++++++++++++++++++++++++++++++++++++++++++ src/conf.zig | 382 ++++++++++++++++ src/env.zig | 200 +++++++++ src/fs.zig | 269 ++++++++++++ src/git.zig | 179 ++++++++ src/main.zig | 1195 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/port.zig | 846 +++++++++++++++++++++++++++++++++++ src/shell.zig | 191 ++++++++ src/template.zig | 476 ++++++++++++++++++++ src/time.zig | 134 ++++++ src/tree.zig | 67 +++ template.conf.sample | 12 + 19 files changed, 5291 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 porteur.conf.sample create mode 100644 src/cli.zig create mode 100644 src/conf.zig create mode 100644 src/env.zig create mode 100644 src/fs.zig create mode 100644 src/git.zig create mode 100644 src/main.zig create mode 100644 src/port.zig create mode 100644 src/shell.zig create mode 100644 src/template.zig create mode 100644 src/time.zig create mode 100644 src/tree.zig create mode 100644 template.conf.sample 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 = §ions, + }; + } + + 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// + \\ 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: //ports//* + // * dist file: //dists/ + // + const port_dir = ctx.env.ports_tree_path(ctx.tree.name, .{ opts.info.category, opts.info.name }); + const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, opts.info.name); + const dist_file: fs.Path = .join(.{ dist_dir.name(), distname }); + const wrk_port_dir = ctx.env.work_path(.{ ctx.tree.name, "ports", opts.info.name }); + const wrk_dist_file = ctx.env.work_path(.{ ctx.tree.name, "dist", distname }); + try fs.rm(wrk_port_dir); + try fs.rm(wrk_dist_file.dir().?); + try fs.mkdir_all(wrk_port_dir); + try fs.mkdir_all(wrk_dist_file.dir().?); + + const template: tmpl.Template = .init(ctx.env, opts.info.template); + const template_conf = try tmpl.read_config(ctx.env, template); + defer template_conf.deinit(); + + const port_vars = try read_vars(ctx, opts.info); + defer port_vars.deinit(); + + var predefined_vars: conf.Config = .init(ctx.env.allocator); + defer predefined_vars.deinit(); + try predefined_vars.add_var(".portname", opts.info.name); + try predefined_vars.add_var(".portversion", version_str); + try predefined_vars.add_var(".category", opts.info.category); + try predefined_vars.add_var(".distdir", dist_dir.name()); + try predefined_vars.add_var(".distname", distname); + + var make_portsdir_buf: [std.fs.max_path_bytes]u8 = undefined; + var make_distdir_buf: [std.fs.max_path_bytes]u8 = undefined; + const make_portsdir = concat(&make_portsdir_buf, &.{ "PORTSDIR=", ctx.env.ports_dir }); + const make_distdir = concat(&make_distdir_buf, &.{ "DISTDIR=", dist_dir.name() }); + + ctx.env.info("===> {s}: writing port files", .{opts.info.name}); + const vars = [_]conf.Config{ predefined_vars, port_vars }; + try template.render(ctx.env, &vars, wrk_port_dir); + try fs.mv_dir(ctx.env.allocator, wrk_port_dir, port_dir); + + if (opts.source) |src| { + var prepared_dist = false; + if (template_conf.prepare_dist_target) |prepare_dist_target| { + ctx.env.info("===> {s}: prepare distribution", .{opts.info.name}); + + var make_target_buf: [std.fs.max_name_bytes]u8 = undefined; + const res = shell.exec(.{ + .env = ctx.env, + .argv = &.{ "make", "-V", concat(&make_target_buf, &.{ "${.ALLTARGETS:M", prepare_dist_target, "}" }), make_portsdir }, + .cwd = port_dir, + }); + const has_target = switch (res) { + .success => |out| has: { + defer ctx.env.dealloc(out); + const target_found = std.mem.trim(u8, out, " \t\n"); + break :has std.mem.eql(u8, target_found, prepare_dist_target); + }, + .failure => |out| { + ctx.env.err("failed to check for makefile target `{s}`\n", .{prepare_dist_target}); + fatal("{s}", .{out}); + }, + }; + if (has_target) { + var make_wrksrc_buf: [std.fs.max_path_bytes]u8 = undefined; + const make_wrksrc = concat(&make_wrksrc_buf, &.{ "WRKSRC=", src.repo.path.name() }); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "make", make_portsdir, make_distdir, make_wrksrc, prepare_dist_target }, + .cwd = port_dir, + }); + prepared_dist = true; + } else { + ctx.env.info("Target `{s}` not found (skipping)", .{prepare_dist_target}); + } + } + + ctx.env.info("===> {s}: creating distfiles", .{opts.info.name}); + var tar_rename_buf: [std.fs.max_path_bytes]u8 = undefined; + const tar_rename = concat(&tar_rename_buf, &.{ ",^,", distname[0 .. distname.len - distname_ext.len], "/," }); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "tar", "-czf", wrk_dist_file.name(), "-C", src.repo.path.name(), "--exclude-vcs", "-s", tar_rename, "." }, + }); + if (prepared_dist) src.repo.reset(ctx.env); + try fs.mv_file(wrk_dist_file, dist_file); + ctx.env.info("distfile: {s}", .{dist_file.name()}); + } + + ctx.env.info("===> {s}: creating distinfo", .{opts.info.name}); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "make", make_portsdir, make_distdir, "makesum" }, + .cwd = port_dir, + }); + + if (opts.source != null) { + ctx.env.info("===> {s}: cleaning up old distfiles", .{opts.info.name}); + var history = list_distfiles_sorted(ctx.env.allocator, opts.info.name, dist_dir) catch |err| { + ctx.env.warn("cannot remove old distfiles from {s} ({s})", .{ dist_dir.name(), @errorName(err) }); + return; + }; + defer history.deinit(ctx.env.allocator); + + if (history.items.len > ctx.env.distfiles_history) { + var to_delete: usize = history.items.len - ctx.env.distfiles_history; + var i: usize = history.items.len; + while (i > 0 and to_delete > 0) { + i -= 1; + const dist: *const Distfile = &history.items[i]; + if (!std.mem.endsWith(u8, dist.path.name(), distname)) { + fs.rm(dist.path) catch |err| { + ctx.env.warn("cannot remove {s} ({s})", .{ dist.path.name(), @errorName(err) }); + }; + to_delete -= 1; + } + } + } + } +} + +/// Get a list of all distfiles sorted from new to old. +fn list_distfiles_sorted(allocator: std.mem.Allocator, port_name: []const u8, dist_dir: fs.Path) !std.ArrayList(Distfile) { + var iter = try fs.iterate_files(dist_dir) orelse return .empty; + defer iter.deinit(); + + var distfiles: std.ArrayList(Distfile) = .empty; + while (iter.next()) |distfile| { + if (std.mem.startsWith(u8, distfile, port_name) and std.mem.endsWith(u8, distfile, distname_ext)) { + const version = Version.parse(distfile[port_name.len + 1 .. distfile.len - distname_ext.len]) catch continue; + try distfiles.append(allocator, .{ + .version = version, + .path = .join(.{ dist_dir.name(), distfile }), + }); + } + } + + std.mem.sortUnstable(Distfile, distfiles.items, {}, struct { + pub fn gt(_: void, left: Distfile, right: Distfile) bool { + return Version.less(right.version, left.version); + } + }.gt); + return distfiles; +} + +/// Build a distname that matches FreeBSD's default value for ${DISTNAME}. +fn assemble_distname(buf: []u8, port_name: []const u8, version: []const u8) []const u8 { + return concat(buf, &.{ port_name, "-", version, distname_ext }); +} + +fn parse_int(comptime Int: type, text: []const u8) ?Int { + var res: Int = 0; + for (text) |c| { + if (c < '0' or c > '9') return null; + res *= 10; + res += @intCast(c - '0'); + } + return res; +} + +fn concat(buf: []u8, parts: []const []const u8) []const u8 { + var off: usize = 0; + for (parts) |part| { + @memcpy(buf[off .. off + part.len], part); + off += part.len; + } + return buf[0..off]; +} + +fn port_corrupted(port_name: []const u8, comptime reason: []const u8) noreturn { + fatal("port {s} corrupted (" ++ reason ++ ")", .{port_name}); +} + +test "parse version" { + var version: Version = undefined; + + version = try .parse("1.2.3"); + try std.testing.expectEqual(@as(u16, 1), version.major); + try std.testing.expectEqual(@as(u16, 2), version.minor); + try std.testing.expectEqual(@as(u16, 3), version.patch); + try std.testing.expectEqual(@as(u32, 0), version.date); + try std.testing.expectEqual(@as(u32, 0), version.revision); + + version = try .parse("3.2.1.d20180709"); + try std.testing.expectEqual(@as(u16, 3), version.major); + try std.testing.expectEqual(@as(u16, 2), version.minor); + try std.testing.expectEqual(@as(u16, 1), version.patch); + try std.testing.expectEqual(@as(u32, 20180709), version.date); + try std.testing.expectEqual(@as(u32, 0), version.revision); + + version = try .parse("2.1.3.d20210429.r7"); + try std.testing.expectEqual(@as(u16, 2), version.major); + try std.testing.expectEqual(@as(u16, 1), version.minor); + try std.testing.expectEqual(@as(u16, 3), version.patch); + try std.testing.expectEqual(@as(u32, 20210429), version.date); + try std.testing.expectEqual(@as(u32, 7), version.revision); +} + +test "compare version" { + var lhs: Version = undefined; + var rhs: Version = undefined; + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3 }; + try std.testing.expect(!Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 4 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 3, .patch = 3 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 2, .minor = 2, .patch = 3 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250429 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20251222 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); +} 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 -- cgit v1.2.3