feat(build): invoke zeta-makepkg with conditional --force

- Add tofu.build module with runMakepkg() public API
- BuildException for missing binary, non-zero exit, missing output
- Real-time stdout/stderr streaming with last-20-lines stderr capture
- Conditional --force flag based on force parameter
- zetaToolchainPath resolution from Config
- 7 unittests using fake zeta-makepkg shell scripts
- pipeProcess + Redirect.stderr for tee approach
This commit is contained in:
2026-08-08 18:05:07 -04:00
parent 7928b4d5f2
commit da19ac98b1
3 changed files with 515 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
/// tofu build — invoke zeta-makepkg as a subprocess with conditional --force.
///
/// Streams stdout/stderr to terminal in real time. Captures the last 20 lines
/// of stderr for error reporting (tee approach: piped stderr → forward + buffer).
module tofu.build;
import tofu.config : Config;
import std.process : pipeProcess, Redirect, ProcessException, Pid, wait;
import std.file : exists, mkdirRecurse;
import std.path : baseName;
import std.string : indexOf, strip, splitLines, join, toStringz;
import std.conv : to, octal;
import std.stdio : stderr;
import std.exception : basicExceptionCtors;
import core.thread : Thread;
version (unittest) {
import std.file : write, tempDir, remove, rmdirRecurse, readText;
import std.path : buildPath;
import std.string : format;
import core.sys.posix.sys.stat : chmod;
}
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown on build failures: missing binary, non-zero exit, missing output.
class BuildException : Exception {
mixin basicExceptionCtors;
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow {
super(message, file, line);
}
}
// ─── Private helpers ─────────────────────────────────────────────────────────
/// Minimal ring buffer for stderr capture (up to `cap` lines).
/// Callers MUST ensure happens-before via Thread.join() before calling get().
private final class StderrRing {
string[] _lines;
size_t cap;
this(size_t n) @safe nothrow {
cap = n;
}
void add(string line) @trusted nothrow {
_lines ~= line;
if (_lines.length > cap)
_lines = _lines[1 .. $]; // drop oldest
}
string[] get() @trusted nothrow {
return _lines.dup;
}
}
/// Run zeta-makepkg through pipeProcess, tee stderr, return exit code + last 20
/// stderr lines in `last20`. stdout inherits the parent terminal.
///
/// The reader thread runs `stderrFile.byLine` and both buffers the line and
/// writes it to the real `stderr`. `join()` guarantees the buffer is filled
/// before this function returns.
private @trusted
int execMakepkg(const string[] cmd, out string[] last20) {
auto pp = pipeProcess(cmd, Redirect.stderr);
auto ring = new StderrRing(20);
// Forward-and-capture thread — reads piped stderr line by line.
auto reader = new Thread(delegate () {
try {
auto f = pp.stderr;
foreach (line; f.byLine()) {
// byLine returns char[] with terminator; idup for safe storage
string s = line.idup;
ring.add(s);
// Write to parent stderr — tee
stderr.write(line);
}
} catch (Exception) {
// Pipe closed / broken — safe to ignore.
}
});
reader.start();
auto code = wait(pp.pid);
reader.join();
last20 = ring.get();
return code;
}
/// Derive package name from recipe filename: strip directory and .recipe suffix.
/// "hello.recipe" → "hello", "/path/to/pkg.recipe" → "pkg".
private @safe string pkgNameFromPath(string recipePath) {
auto f = baseName(recipePath);
auto pos = f.indexOf(".recipe");
if (pos > 0)
return f[0 .. pos];
// No .recipe suffix — use whole filename (minus any other extension)
auto dot = f.indexOf('.');
if (dot > 0)
return f[0 .. dot];
return f;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/// Invoke zeta-makepkg to build a recipe into `outputDir`.
///
/// Parameters:
/// recipePath = path to the .recipe file
/// outputDir = directory where packages/<name>/package.lua is produced
/// jobs = value for -j<N> flag (parallel build jobs)
/// force = if true, pass --force (overwrite existing package)
/// cfg = tofu config (used for zetaToolchainPath resolution)
///
/// Returns:
/// Absolute path to the produced package.lua on exit code 0.
///
/// Throws:
/// BuildException when the binary is missing, the build exits non-zero,
/// or the expected package.lua is not found after a successful exit.
string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
Config cfg) @safe {
// ── 1. Resolve zeta-makepkg binary ───────────────────────────────────────
string zetaMakepkg;
if (cfg.zetaToolchainPath.length > 0) {
zetaMakepkg = cfg.zetaToolchainPath;
if (!() @trusted { return exists(zetaMakepkg); }())
throw new BuildException(
"zeta-makepkg not found. Install zeta-toolchain or set "
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
} else {
zetaMakepkg = "zeta-makepkg";
}
// ── 2. Ensure output directory exists ────────────────────────────────────
() @trusted { mkdirRecurse(outputDir); }();
// ── 3. Derive package name ───────────────────────────────────────────────
string pkgName = pkgNameFromPath(recipePath);
// ── 4. Build command-line arguments ─────────────────────────────────────
string[] cmd = [
zetaMakepkg,
recipePath,
"--output", outputDir,
"-j" ~ to!string(jobs),
"--no-index",
"--repo", "https://files.spectoria.dev/zuur/binary",
];
if (force)
cmd ~= "--force";
// ── 5. Execute ──────────────────────────────────────────────────────────
string[] last20Lines;
int exitCode;
try {
() @trusted {
exitCode = execMakepkg(cmd, last20Lines);
}();
} catch (ProcessException e) {
// Binary not found on PATH (or exec failed)
if (cfg.zetaToolchainPath.length == 0
&& e.msg.indexOf("not found") >= 0) {
throw new BuildException(
"zeta-makepkg not found. Install zeta-toolchain or set "
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
}
throw new BuildException(
"failed to execute zeta-makepkg: " ~ e.msg);
}
// ── 6. Check result ─────────────────────────────────────────────────────
if (exitCode != 0) {
string errDetail;
if (last20Lines.length > 0)
errDetail = last20Lines.join("\n");
else
errDetail = "(no stderr output)";
throw new BuildException(
"build failed for " ~ pkgName ~ ":\n" ~ errDetail);
}
// ── 7. Verify package.lua exists ─────────────────────────────────────────
auto pkgPath = outputDir ~ "/packages/" ~ pkgName ~ "/package.lua";
if (!() @trusted { return exists(pkgPath); }())
throw new BuildException(
"zeta-makepkg reported success but package.lua not found at "
~ pkgPath);
return pkgPath;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
version (unittest) {
import std.process : thisProcessID;
/// Write text to a file (trusted).
private @trusted void sWrite(string path, string content) {
write(path, content);
}
/// Remove a directory tree, ignoring errors.
private @trusted void sRmdirRecurse(string path) {
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a unique temp directory path for a test and create it.
private @trusted string testTempDir(string suffix) {
auto path = buildPath(tempDir, "tofu-test-build-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
/// Create a fake zeta-makepkg shell script that:
/// - parses --output <dir> from its args
/// - derives the package name from the first argument (recipe path)
/// - creates packages/<name>/package.lua
/// - echoes to stderr for output testing
/// - writes all args to ARGS_FILE if set (for --force assertion tests)
/// - exits with code from EXIT_CODE if set (default 0)
private string makeFakeMakepkg(string dir, string name,
string exitCode = "0", string argsFile = "") @trusted {
auto path = buildPath(dir, name);
string script = "#!/bin/bash\nset -e\n";
script ~= `RECIPE_PATH="$1"
RECIPE_FILE=$(basename "$RECIPE_PATH")
PKG_NAME="${RECIPE_FILE%.recipe}"
`;
if (argsFile.length > 0) {
// Args are saved in the unconditional block below (ORIGINAL_ARGS).
}
script ~= `
# Parse --output
OUTPUT_DIR="."
ORIGINAL_ARGS="$@"
while [[ $# -gt 0 ]]; do
case "$1" in
--output) OUTPUT_DIR="$2"; shift 2;;
*) shift;;
esac
done
`;
if (argsFile.length > 0) {
script ~= "echo \"$ORIGINAL_ARGS\" > '" ~ argsFile ~ "'\n";
}
script ~= `
# Echo some stderr output (simulate build log)
echo "zeta-makepkg: building ${PKG_NAME}..." >&2
echo "zeta-makepkg: configure phase" >&2
echo "zeta-makepkg: build phase" >&2
echo "zeta-makepkg: install phase" >&2
# Create output
mkdir -p "${OUTPUT_DIR}/packages/${PKG_NAME}"
echo "return { version = '1.0.0' }" > "${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua"
exit ` ~ exitCode ~ "\n";
write(path, script);
chmod(toStringz(path), octal!755);
return path;
}
// ── Test (1): valid recipe → returns package.lua path, exit 0 ──────────
@safe unittest {
auto tmp = testTempDir("valid");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.zuurUrl = "https://files.spectoria.dev/zuur";
auto result = runMakepkg(
buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(result == buildPath(outDir, "packages", "hello", "package.lua"),
"Expected packages/hello/package.lua, got: " ~ result);
}
// ── Test (2): fake script exits 1 with stderr → BuildException ─────────
@safe unittest {
auto tmp = testTempDir("fail");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-fail", "1");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException");
} catch (BuildException e) {
assert(e.msg.indexOf("build failed for hello") >= 0,
"Expected 'build failed for hello', got: " ~ e.msg);
}
}
// ── Test (3): force=true → fake script receives --force ─────────────────
@safe unittest {
auto tmp = testTempDir("force-true");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-force", "0", argsFile);
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, true, cfg);
auto rawArgs = () @trusted {
try { return readText(argsFile); } catch (Exception) { return ""; }
}();
assert(rawArgs.indexOf("--force") >= 0,
"Expected --force in args, got: " ~ rawArgs);
}
// ── Test (4): force=false → fake script receives no --force ─────────────
@safe unittest {
auto tmp = testTempDir("force-false");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-noforce", "0", argsFile);
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
auto rawArgs = () @trusted {
try { return readText(argsFile); } catch (Exception) { return ""; }
}();
assert(rawArgs.indexOf("--force") < 0,
"Expected no --force in args, got: " ~ rawArgs);
}
// ── Test (5): zeta-makepkg not found (nonexistent path) ─────────────────
@safe unittest {
auto tmp = testTempDir("notfound");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto outDir = buildPath(tmp, "output");
() @trusted { mkdirRecurse(outDir); }();
Config cfg;
cfg.zetaToolchainPath = buildPath(tmp, "nonexistent-binary");
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException for missing binary");
} catch (BuildException e) {
assert(e.msg.indexOf("zeta-makepkg not found") >= 0,
"Expected 'zeta-makepkg not found', got: " ~ e.msg);
}
}
// ── Test (6): exits 0 but doesn't create package.lua → BuildException ───
@safe unittest {
auto tmp = testTempDir("missing-pkg");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
// Create a fake that exits 0 but creates no package.lua
auto fakePath = buildPath(tmp, "fake-nopkg");
string noPkgScript = "#!/bin/bash\necho 'done' >&2\nexit 0\n";
() @trusted {
write(fakePath, noPkgScript);
chmod(toStringz(fakePath), octal!755);
}();
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakePath;
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException for missing package.lua");
} catch (BuildException e) {
assert(e.msg.indexOf("package.lua not found") >= 0,
"Expected 'package.lua not found', got: " ~ e.msg);
}
}
// ── Test (7): pkgNameFromPath edge cases ────────────────────────────────
@safe unittest {
assert(pkgNameFromPath("hello.recipe") == "hello");
assert(pkgNameFromPath("/path/to/pkg.recipe") == "pkg");
assert(pkgNameFromPath("a.b.recipe") == "a.b"); // last .recipe
assert(pkgNameFromPath("norecipe") == "norecipe"); // no dot at all
assert(pkgNameFromPath("/tmp/.config/build.sh") == "build");
}
}