feat(build): orchestrate sequential topological builds with fail-fast

Add buildAll() to src/tofu/build.d — sequential builder with:
- Empty plan → 'nothing to build' no-op
- Recipe existence pre-check → fail-fast on missing
- Skip-if-exists: don't rebuild when output present (unless --force)
- Separator line: U+2500 box chars '──── building <name> (i/n) ────'
- Fail-fast: halt immediately on first BuildException

6 unittests: all succeed, first fails, empty plan, skip existing,
force rebuild, missing recipe.
This commit is contained in:
2026-08-08 18:09:06 -04:00
parent c43c1a78d9
commit 8f9a3c85a4
3 changed files with 448 additions and 0 deletions
+257
View File
@@ -5,6 +5,8 @@
module tofu.build;
import tofu.config : Config;
import tofu.types : BuildPlan, BuildResult, BuildFailure, Source;
import tofu.log : logStep, logOk, logError, logInfo;
import std.process : pipeProcess, Redirect, ProcessException, Pid, wait;
import std.file : exists, mkdirRecurse;
import std.path : baseName;
@@ -196,6 +198,68 @@ string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
return pkgPath;
}
/// Build all packages in plan order (deps-first). Sequential, fail-fast.
///
/// Parameters:
/// plan = ordered BuildPlan (caller ensures deps-first topological order)
/// cfg = tofu configuration
/// force = if true, pass --force to zeta-makepkg (overwrite existing)
///
/// Returns:
/// BuildResult with succeeded/failed lists. On first failure, returns
/// immediately — remaining packages are NOT attempted.
@safe
BuildResult buildAll(BuildPlan plan, Config cfg, bool force = false) {
BuildResult result;
auto entries = plan.order();
// Empty plan → no-op
if (entries.length == 0) {
logInfo("nothing to build");
return result;
}
size_t total = entries.length;
size_t idx = 0;
foreach (entry; entries) {
idx++;
// Pre-check: recipe file must exist
if (!() @trusted { return exists(entry.recipePath); }()) {
auto reason = "recipe not found at " ~ entry.recipePath;
logError("build failed for %s: %s", entry.name, reason);
result.failed ~= BuildFailure(entry.name, reason);
return result;
}
// Separator
logStep("──── building %s (%d/%d) ────", entry.name, idx, total);
// Skip-if-exists: check for pre-built output
auto pkgPath = cfg.builtDir() ~ "/packages/" ~ entry.name ~ "/package.lua";
if (!force && () @trusted { return exists(pkgPath); }()) {
logInfo("%s already built, skipping", entry.name);
result.succeeded ~= entry.name;
continue;
}
// Execute build
try {
runMakepkg(entry.recipePath, cfg.builtDir(), cfg.defaultJobs, force, cfg);
result.succeeded ~= entry.name;
logOk("%s", entry.name);
} catch (BuildException e) {
logError("build failed for %s: %s", entry.name, e.msg);
result.failed ~= BuildFailure(entry.name, e.msg);
return result; // halt immediately
}
}
return result;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
version (unittest) {
@@ -426,4 +490,197 @@ exit ` ~ exitCode ~ "\n";
assert(pkgNameFromPath("norecipe") == "norecipe"); // no dot at all
assert(pkgNameFromPath("/tmp/.config/build.sh") == "build");
}
// ── Test (8): buildAll — plan [depA, depB, target] all valid → all 3 succeed ─
@safe unittest {
auto tmp = testTempDir("buildall-valid");
scope (exit) sRmdirRecurse(tmp);
// Create recipes for all 3
foreach (n; ["depA", "depB", "target"]) {
sWrite(buildPath(tmp, n ~ ".recipe"),
"return { name = '" ~ n ~ "', version = '1.0.0' }");
}
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
// Build plan — deps-first order (caller guarantees this)
BuildPlan plan;
plan.add("depA", buildPath(tmp, "depA.recipe"), Source.recipe);
plan.add("depB", buildPath(tmp, "depB.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "target.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 3,
format("Expected 3 succeeded, got %d: %s",
result.succeeded.length, result.succeeded));
assert(result.succeeded[0] == "depA");
assert(result.succeeded[1] == "depB");
assert(result.succeeded[2] == "target");
assert(result.failed.length == 0);
// Verify outputs were created
foreach (n; ["depA", "depB", "target"]) {
auto pkg = () @trusted {
return exists(
buildPath(tmp, "built", "packages", n, "package.lua"));
}();
assert(pkg, "Expected package.lua for " ~ n);
}
}
// ── Test (9): buildAll — first recipe fails → rest skipped ──────────────
@safe unittest {
auto tmp = testTempDir("buildall-failfast");
scope (exit) sRmdirRecurse(tmp);
foreach (n; ["depA", "depB", "target"]) {
sWrite(buildPath(tmp, n ~ ".recipe"),
"return { name = '" ~ n ~ "', version = '1.0.0' }");
}
// Fake that always exits 1 (fails every invocation)
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-fail", "1");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("depA", buildPath(tmp, "depA.recipe"), Source.recipe);
plan.add("depB", buildPath(tmp, "depB.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "target.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 0,
"Expected 0 succeeded, got: " ~ result.succeeded.to!string);
assert(result.failed.length == 1,
"Expected 1 failed (only depA), got " ~ result.failed.length.to!string);
assert(result.failed[0].name == "depA",
"Expected depA to fail, got: " ~ result.failed[0].name);
assert(result.failed[0].reason.indexOf("build failed for depA") >= 0,
"Expected 'build failed for depA' in reason, got: "
~ result.failed[0].reason);
}
// ── Test (10): buildAll — empty plan → "nothing to build" ────────────────
@safe unittest {
Config cfg;
cfg.cacheDir = "/tmp/dummy";
BuildPlan plan; // empty
auto result = buildAll(plan, cfg);
assert(result.succeeded.length == 0);
assert(result.failed.length == 0);
}
// ── Test (11): buildAll — existing output + force=false → skipped ────────
@safe unittest {
auto tmp = testTempDir("buildall-skip");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "pkg.recipe"),
"return { name = 'pkg', version = '1.0.0' }");
// Pre-create the output
auto pkgLuaDir = buildPath(tmp, "built", "packages", "pkg");
() @trusted { mkdirRecurse(pkgLuaDir); }();
sWrite(buildPath(pkgLuaDir, "package.lua"),
"return { version = '1.0.0' }");
// Fake with args file as invocation counter
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg", "0", argsFile);
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("pkg", buildPath(tmp, "pkg.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 1);
assert(result.succeeded[0] == "pkg");
assert(result.failed.length == 0);
// Fake script must NOT have been invoked
auto argsExist = () @trusted {
try { return exists(argsFile); } catch (Exception) { return false; }
}();
assert(!argsExist, "Fake makepkg was invoked but should have been skipped");
}
// ── Test (12): buildAll — existing output + force=true → rebuilt ─────────
@safe unittest {
auto tmp = testTempDir("buildall-force");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "pkg.recipe"),
"return { name = 'pkg', version = '1.0.0' }");
// Pre-create the output
auto pkgLuaDir = buildPath(tmp, "built", "packages", "pkg");
() @trusted { mkdirRecurse(pkgLuaDir); }();
sWrite(buildPath(pkgLuaDir, "package.lua"),
"return { version = '1.0.0' }");
// Fake with args file as invocation counter
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg", "0", argsFile);
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("pkg", buildPath(tmp, "pkg.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, true);
assert(result.succeeded.length == 1);
assert(result.succeeded[0] == "pkg");
assert(result.failed.length == 0);
// Fake script MUST have been invoked
auto argsExist = () @trusted {
try { return exists(argsFile); } catch (Exception) { return false; }
}();
assert(argsExist, "Fake makepkg was NOT invoked but should have been (force=true)");
}
// ── Test (13): buildAll — missing recipe file → fail-fast ────────────────
@safe unittest {
auto tmp = testTempDir("buildall-norecipe");
scope (exit) sRmdirRecurse(tmp);
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("noexist", buildPath(tmp, "nonexistent.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "will-not-build.recipe"), Source.recipe);
auto result = buildAll(plan, cfg);
assert(result.succeeded.length == 0);
assert(result.failed.length == 1,
"Expected 1 failed, got " ~ result.failed.length.to!string);
assert(result.failed[0].name == "noexist");
assert(result.failed[0].reason.indexOf("recipe not found") >= 0,
"Expected 'recipe not found' in reason, got: "
~ result.failed[0].reason);
}
}