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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user