feat(resolve): generate build plan from constrained dep tree

Add generateBuildPlan() to tofu.resolve — filters constrained dep tree
to recipe-only packages, verifies cached recipes exist, and produces
an ordered BuildPlan ready for the build orchestrator (task 15).

- Skips binary-satisfied deps (Zeta handles those)
- Preserves topological order from constrained nodes (deps-first)
- Missing recipe files: re-fetch via injectable delegate seam
  (production wires tofu.fetch.fetchRecipe; tests inject mocks)
- Empty plan (all binary) → returns empty BuildPlan + log info
- Root always recipe — always included in plan
- 6 unittests: filter binary, fetch seam, missing no-seam,
  all-binary empty, root with binary dep, cache hit no-fetch
- dub test + dub build pass with warningsAsErrors
This commit is contained in:
2026-08-08 17:58:25 -04:00
parent ba411e9fc4
commit 7928b4d5f2
3 changed files with 548 additions and 1 deletions
+342 -1
View File
@@ -19,7 +19,10 @@ module tofu.resolve;
import tofu.types; // DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult
import tofu.deps; // DepTree, DepNode
import tofu.log; // logInfo
import tofu.config; // Config
import tofu.fetch; // FetchException
import tofu.log; // logInfo, logStep, logOk
import std.file : exists;
import std.string : indexOf;
// ────────────────────────────────────────────────────────────
@@ -226,6 +229,80 @@ ConstrainedNode[] constrainDepTree(DepTree tree, scope const PackageIndex[] inde
return result;
}
// ────────────────────────────────────────────────────────────
// Build Plan Generation
// ────────────────────────────────────────────────────────────
/// Generate a BuildPlan from a constrained dependency tree.
///
/// Iterates over the constrained nodes in their existing topological
/// order (deps-first, root last). For each node whose source is
/// recipe (excluding binary-satisfied deps), verifies the recipe
/// file exists in the local cache and adds it to the build plan.
///
/// If a recipe file is missing from cache:
/// - With a `fetchRecipe` delegate provided: calls it to re-fetch.
/// - Without a delegate: throws `FetchException`.
///
/// If all constrained nodes are binary-satisfied, returns an empty
/// BuildPlan — the caller should print "nothing to build".
///
/// Params:
/// constrained = Source-annotated nodes from constrainDepTree
/// (already in topological / deps-first order).
/// tree = The original dependency tree (preserved for context).
/// cfg = Runtime configuration (used for cache paths).
/// fetchRecipe = Optional delegate to re-fetch a recipe if missing
/// from cache. Takes package name, returns recipe path.
/// Tests inject mocks; production wires a lambda over
/// tofu.fetch.fetchRecipe.
@safe
BuildPlan generateBuildPlan(scope const ConstrainedNode[] constrained,
scope const DepTree tree, scope const Config cfg,
scope string delegate(string) @safe fetchRecipe = null)
{
logStep("generating build plan");
BuildPlan plan;
foreach (node; constrained)
{
// Exclude binary-satisfied deps — Zeta handles those.
if (node.source == DepSource.binary)
continue;
// Build the expected cache path for the recipe file.
string recipePath = cfg.recipesCacheDir(node.name)
~ "/" ~ node.name ~ ".recipe";
// Verify the recipe exists in the local cache.
bool recipeExists = () @trusted {
return exists(recipePath);
}();
if (!recipeExists)
{
if (fetchRecipe is null)
{
throw new FetchException(
"recipe not cached and no fetcher provided");
}
logInfo("recipe %s not in cache, re-fetching", node.name);
recipePath = fetchRecipe(node.name);
}
logInfo(" + %s (recipe)", node.name);
plan.add(node.name, recipePath, Source.recipe);
}
if (plan.isEmpty())
logInfo("nothing to build (all binary)");
else
logOk("build plan: %d packages", plan.entries.length);
return plan;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@@ -508,3 +585,267 @@ private @safe PackageIndex mkIndexEntry(string name, Pool pool)
assert(result.length == 0, "empty tree → empty result");
}
// ────────────────────────────────────────────────────────────
// generateBuildPlan helpers (unittest-only)
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.file : mkdirRecurse, write, rmdirRecurse, tempDir;
import std.conv : to;
import std.process : thisProcessID;
/// Create a recipe file in a temp cache dir that matches
/// what `Config.recipesCacheDir` produces.
private @trusted void createCachedRecipe(string cacheDir, string name)
{
auto dir = cacheDir ~ "/recipes/" ~ name;
if (!exists(dir))
mkdirRecurse(dir);
write(dir ~ "/" ~ name ~ ".recipe", "return {}");
}
/// Create a unique temp directory for test isolation.
private @trusted string testTempDir(string suffix)
{
auto dir = tempDir() ~ "/tofu-resolve-" ~ suffix ~ "-"
~ thisProcessID.to!string;
if (exists(dir))
rmdirRecurse(dir);
mkdirRecurse(dir);
return dir;
}
/// Recursively remove a temp test directory.
private @trusted void testRmdir(string path)
{
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a minimal test Config pointing at a temp cache dir.
private @safe Config testConfig(string cacheDir)
{
Config cfg;
cfg.cacheDir = cacheDir;
return cfg;
}
}
// ── Test (9): [C(recipe), B(binary), A(recipe)] → [C, A] ──
@safe unittest
{
auto tmpDir = testTempDir("t9");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "C");
createCachedRecipe(tmpDir, "A");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "C";
cn.source = DepSource.recipe;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "B";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "A";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.entries.length == 2,
"should contain C and A, got " ~ plan.entries.length.to!string);
assert(plan.entries[0].name == "C", "C should be first (dep before root)");
assert(plan.entries[0].source == Source.recipe);
assert(plan.entries[1].name == "A", "A is root, should be last");
assert(plan.entries[1].source == Source.recipe);
}
// ── Test (10): missing cache + fetch delegate → fetch called ──
@safe unittest
{
auto tmpDir = testTempDir("t10");
scope (exit) testRmdir(tmpDir);
// No recipe file on disk — must trigger fetch delegate.
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
bool fetchCalled = false;
string fetchResult;
scope fetchRecipe = delegate (string name) @safe {
fetchCalled = true;
fetchResult = tmpDir ~ "/re-fetched/" ~ name ~ ".recipe";
return fetchResult;
};
auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe);
assert(fetchCalled, "fetch delegate should have been called");
assert(plan.entries.length == 1);
assert(plan.entries[0].name == "libfoo");
assert(plan.entries[0].recipePath == fetchResult,
"plan should use the path returned by the fetch delegate");
}
// ── Test (11): missing cache + no fetch delegate → FetchException ──
@safe unittest
{
auto tmpDir = testTempDir("t11");
scope (exit) testRmdir(tmpDir);
// No recipe file on disk, no delegate → exception.
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
bool caught = false;
try
{
generateBuildPlan(constrained, tree, cfg);
assert(false, "expected FetchException");
}
catch (FetchException e)
{
caught = true;
assert(e.msg.indexOf("not cached") >= 0,
"exception message should mention 'not cached', got: " ~ e.msg);
}
assert(caught, "should have thrown FetchException");
}
// ── Test (12): all binary → empty plan ──
@safe unittest
{
auto tmpDir = testTempDir("t12");
scope (exit) testRmdir(tmpDir);
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "B";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "A";
cn.source = DepSource.binary;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.isEmpty(), "all-binary deps → empty build plan");
}
// ── Test (13): root included even when all deps are binary ──
@safe unittest
{
auto tmpDir = testTempDir("t13");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "mypkg");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "dep";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "mypkg";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.entries.length == 1,
"root is always recipe — must appear even with binary-only deps");
assert(plan.entries[0].name == "mypkg");
assert(plan.entries[0].source == Source.recipe);
}
// ── Test (14): recipe files already in cache → no fetch call ──
@safe unittest
{
auto tmpDir = testTempDir("t14");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "libfoo");
createCachedRecipe(tmpDir, "mypkg");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "mypkg";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
// Fetch delegate that throws if called — must not be invoked.
scope fetchRecipe = delegate (string name) @safe {
assert(false, "fetchRecipe should NOT be called when recipe exists in cache");
return "";
};
auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe);
assert(plan.entries.length == 2);
assert(plan.entries[0].name == "libfoo");
assert(plan.entries[0].recipePath ==
tmpDir ~ "/recipes/libfoo/libfoo.recipe");
assert(plan.entries[0].source == Source.recipe);
assert(plan.entries[1].name == "mypkg");
assert(plan.entries[1].recipePath ==
tmpDir ~ "/recipes/mypkg/mypkg.recipe");
assert(plan.entries[1].source == Source.recipe);
}