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