feat(resolve): add version-constraint-aware dep resolution with binary fallback
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
/// tofu.resolve — Version-constraint-aware dependency source resolution.
|
||||
///
|
||||
/// Annotates a dependency tree with source decisions (binary vs recipe)
|
||||
/// by checking zuur/binary package versions against dependency constraints
|
||||
/// via an injectable delegate. Falls back to recipe builds when binaries
|
||||
/// are missing or too old.
|
||||
///
|
||||
/// Design:
|
||||
/// - `constrainDepTree` returns one `ConstrainedNode` per unique package
|
||||
/// name in the tree, including the root (which is always recipe).
|
||||
/// - The `binaryCheck` delegate is the testability seam — production
|
||||
/// wires `tofu.binary.checkBinaryVersion`; tests inject mocks.
|
||||
/// - Deduplication: the same dep name appearing in multiple parent
|
||||
/// constraints is resolved ONCE. If ANY constraint fails binary
|
||||
/// satisfaction, the dep is marked recipe.
|
||||
/// - The `PackageIndex[]` parameter enables recipe-existence checks
|
||||
/// without network I/O — the caller passes the already-fetched index.
|
||||
module tofu.resolve;
|
||||
|
||||
import tofu.types; // DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult
|
||||
import tofu.deps; // DepTree, DepNode
|
||||
import tofu.log; // logInfo
|
||||
import std.string : indexOf;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown when a dependency cannot be resolved from either
|
||||
/// binary repository or recipe index.
|
||||
class ResolveException : Exception
|
||||
{
|
||||
@safe this(string msg)
|
||||
{
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Data structures
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Where a resolved dependency comes from.
|
||||
enum DepSource
|
||||
{
|
||||
binary,
|
||||
recipe,
|
||||
}
|
||||
|
||||
/// A dependency tree node annotated with its resolved source.
|
||||
struct ConstrainedNode
|
||||
{
|
||||
string name = "";
|
||||
DepSource source = DepSource.recipe;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether a package name appears in the index with a
|
||||
/// pool that includes recipes (`recipes` or `both`).
|
||||
private @safe bool hasRecipe(string name, scope const PackageIndex[] index)
|
||||
{
|
||||
foreach (entry; index)
|
||||
{
|
||||
if (entry.name == name
|
||||
&& (entry.pool == Pool.recipes || entry.pool == Pool.both))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Format a dependency constraint for human-readable log messages.
|
||||
private @safe string formatConstraint(DepConstraint c)
|
||||
{
|
||||
final switch (c.op)
|
||||
{
|
||||
case DepOp.none:
|
||||
return c.name ~ " (unconstrained)";
|
||||
case DepOp.ge:
|
||||
return c.name ~ ">=" ~ c.ver;
|
||||
case DepOp.le:
|
||||
return c.name ~ "<=" ~ c.ver;
|
||||
case DepOp.eq:
|
||||
return c.name ~ "==" ~ c.ver;
|
||||
case DepOp.ne:
|
||||
return c.name ~ "~=" ~ c.ver;
|
||||
case DepOp.gt:
|
||||
return c.name ~ ">" ~ c.ver;
|
||||
case DepOp.lt:
|
||||
return c.name ~ "<" ~ c.ver;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Annotate a dependency tree with source decisions (binary vs recipe).
|
||||
///
|
||||
/// For each unique package name in the tree, all applicable constraints
|
||||
/// (aggregated from every parent node that depends on it) are tested
|
||||
/// against the binary repository via the `binaryCheck` delegate. If ANY
|
||||
/// constraint is unsatisfied, the dep is marked recipe. If ALL constraints
|
||||
/// are satisfied, the dep is marked binary.
|
||||
///
|
||||
/// The root (last node in the topological order) is always recipe — it is
|
||||
/// the target the user asked to build from ZUUR recipes.
|
||||
///
|
||||
/// If a dep cannot be resolved as binary AND has no recipe in the index,
|
||||
/// throws `ResolveException`.
|
||||
///
|
||||
/// Params:
|
||||
/// tree = Topologically ordered dependency tree (from deps.resolveDepTree).
|
||||
/// index = ZUUR package index for recipe existence checks.
|
||||
/// binaryCheck = Delegate that checks binary version against a constraint
|
||||
/// (testability seam — production wires tofu.binary.checkBinaryVersion).
|
||||
///
|
||||
/// Returns:
|
||||
/// `ConstrainedNode[]` with one entry per unique package name in the tree,
|
||||
/// ordered by first appearance in the tree's topological order.
|
||||
@safe
|
||||
ConstrainedNode[] constrainDepTree(DepTree tree, scope const PackageIndex[] index,
|
||||
scope BinaryCheckResult delegate(string, DepConstraint) @safe binaryCheck)
|
||||
{
|
||||
if (tree.nodes.length == 0)
|
||||
return [];
|
||||
|
||||
// Root is the target package — always recipe.
|
||||
string rootName = tree.nodes[$ - 1].name;
|
||||
|
||||
// ── Pass 1: Aggregate all constraints per dep name ──
|
||||
// For each dep name, collect every DepConstraint from every
|
||||
// parent node that lists it as a dependency.
|
||||
DepConstraint[][string] allConstraints;
|
||||
|
||||
foreach (node; tree.nodes)
|
||||
{
|
||||
foreach (constraint; node.constraints)
|
||||
{
|
||||
allConstraints[constraint.name] ~= constraint;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 2: Resolve source for each unique dep name ──
|
||||
DepSource[string] resolved;
|
||||
|
||||
// Root is always recipe.
|
||||
resolved[rootName] = DepSource.recipe;
|
||||
|
||||
// Walk the tree in order. The first time we encounter a dep name
|
||||
// (via some parent's constraint), resolve it using ALL accumulated
|
||||
// constraints from ALL parents.
|
||||
foreach (node; tree.nodes)
|
||||
{
|
||||
foreach (constraint; node.constraints)
|
||||
{
|
||||
string depName = constraint.name;
|
||||
|
||||
// Already resolved — skip.
|
||||
if (depName in resolved)
|
||||
continue;
|
||||
|
||||
// Retrieve the full constraint set for this dep.
|
||||
auto constraints = depName in allConstraints;
|
||||
assert(constraints !is null, "dep in tree must have constraints");
|
||||
|
||||
bool allSatisfied = true;
|
||||
foreach (c; *constraints)
|
||||
{
|
||||
auto result = binaryCheck(depName, c);
|
||||
|
||||
if (!result.satisfies)
|
||||
{
|
||||
if (result.exists)
|
||||
logInfo("%s: binary %s too old, building from recipe",
|
||||
depName, result.ver);
|
||||
else
|
||||
logInfo("%s: no binary available, building from recipe",
|
||||
depName);
|
||||
allSatisfied = false;
|
||||
break;
|
||||
}
|
||||
|
||||
logInfo("binary %s-%s satisfies %s",
|
||||
depName, result.ver, formatConstraint(c));
|
||||
}
|
||||
|
||||
if (allSatisfied)
|
||||
{
|
||||
resolved[depName] = DepSource.binary;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Recipe fallback — must exist in the index.
|
||||
if (!hasRecipe(depName, index))
|
||||
{
|
||||
throw new ResolveException(
|
||||
"dependency '" ~ depName
|
||||
~ "' not found in ZUUR (neither binary nor recipe)");
|
||||
}
|
||||
resolved[depName] = DepSource.recipe;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pass 3: Build output array in tree order ──
|
||||
// Include every distinct node (including root).
|
||||
ConstrainedNode[] result;
|
||||
bool[string] seen;
|
||||
|
||||
foreach (node; tree.nodes)
|
||||
{
|
||||
if (node.name in seen)
|
||||
continue;
|
||||
seen[node.name] = true;
|
||||
|
||||
ConstrainedNode cn;
|
||||
cn.name = node.name;
|
||||
// Use resolved map; fall back to recipe for safety.
|
||||
cn.source = resolved.get(node.name, DepSource.recipe);
|
||||
result ~= cn;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: build a DepConstraint with explicit fields.
|
||||
private @safe DepConstraint mkConstraint(string name,
|
||||
DepOp op = DepOp.none,
|
||||
string ver = "")
|
||||
{
|
||||
DepConstraint c;
|
||||
c.name = name;
|
||||
c.op = op;
|
||||
c.ver = ver;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Helper: build a simple two-node DepTree (dep → root).
|
||||
private @safe DepTree mkTree2(string depName, DepConstraint depConstraint,
|
||||
string rootName)
|
||||
{
|
||||
DepNode depNode;
|
||||
depNode.name = depName;
|
||||
|
||||
DepNode rootNode;
|
||||
rootNode.name = rootName;
|
||||
rootNode.constraints ~= depConstraint;
|
||||
|
||||
DepTree t;
|
||||
t.nodes = [depNode, rootNode]; // dep before root (topo order)
|
||||
return t;
|
||||
}
|
||||
|
||||
// Helper: build an index entry with a given name and pool.
|
||||
private @safe PackageIndex mkIndexEntry(string name, Pool pool)
|
||||
{
|
||||
PackageIndex e;
|
||||
e.name = name;
|
||||
e.pool = pool;
|
||||
return e;
|
||||
}
|
||||
|
||||
// ── Test (1): dep libfoo>=2.0, binary 2.1 → binary ──
|
||||
@safe unittest
|
||||
{
|
||||
auto tree = mkTree2("libfoo",
|
||||
mkConstraint("libfoo", DepOp.ge, "2.0"),
|
||||
"mypkg");
|
||||
|
||||
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
assert(name == "libfoo");
|
||||
BinaryCheckResult r;
|
||||
r.exists = true;
|
||||
r.ver = "2.1";
|
||||
r.satisfies = true;
|
||||
return r;
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 2);
|
||||
// libfoo first (dep), mypkg second (root)
|
||||
assert(result[0].name == "libfoo");
|
||||
assert(result[0].source == DepSource.binary,
|
||||
"libfoo-2.1 satisfies >=2.0 → should be binary");
|
||||
assert(result[1].name == "mypkg");
|
||||
assert(result[1].source == DepSource.recipe,
|
||||
"root is always recipe");
|
||||
}
|
||||
|
||||
// ── Test (2): dep libfoo>=2.0, binary 1.9, recipe in index → recipe ──
|
||||
@safe unittest
|
||||
{
|
||||
auto tree = mkTree2("libfoo",
|
||||
mkConstraint("libfoo", DepOp.ge, "2.0"),
|
||||
"mypkg");
|
||||
|
||||
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
BinaryCheckResult r;
|
||||
r.exists = true;
|
||||
r.ver = "1.9";
|
||||
r.satisfies = false; // 1.9 < 2.0
|
||||
return r;
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 2);
|
||||
assert(result[0].name == "libfoo");
|
||||
assert(result[0].source == DepSource.recipe,
|
||||
"binary too old, recipe in index → recipe");
|
||||
assert(result[1].source == DepSource.recipe);
|
||||
}
|
||||
|
||||
// ── Test (3): dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ──
|
||||
@safe unittest
|
||||
{
|
||||
auto tree = mkTree2("libfoo",
|
||||
mkConstraint("libfoo", DepOp.ge, "2.0"),
|
||||
"mypkg");
|
||||
|
||||
PackageIndex[] index; // empty — no recipe available
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
BinaryCheckResult r;
|
||||
r.exists = true;
|
||||
r.ver = "1.9";
|
||||
r.satisfies = false;
|
||||
return r;
|
||||
};
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
constrainDepTree(tree, index, binaryCheck);
|
||||
assert(false, "expected ResolveException");
|
||||
}
|
||||
catch (ResolveException e)
|
||||
{
|
||||
caught = true;
|
||||
assert(e.msg.indexOf("neither binary nor recipe") >= 0,
|
||||
"message should mention 'neither binary nor recipe', got: " ~ e.msg);
|
||||
assert(e.msg.indexOf("libfoo") >= 0,
|
||||
"message should name the missing dep, got: " ~ e.msg);
|
||||
}
|
||||
assert(caught, "should have thrown ResolveException");
|
||||
}
|
||||
|
||||
// ── Test (4): unconstrained dep with binary → binary ──
|
||||
@safe unittest
|
||||
{
|
||||
auto tree = mkTree2("libbar",
|
||||
mkConstraint("libbar", DepOp.none, ""),
|
||||
"mypkg");
|
||||
|
||||
PackageIndex[] index = [mkIndexEntry("libbar", Pool.recipes)];
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
BinaryCheckResult r;
|
||||
r.exists = true;
|
||||
r.ver = "3.0";
|
||||
r.satisfies = true; // unconstrained always satisfies
|
||||
return r;
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 2);
|
||||
assert(result[0].name == "libbar");
|
||||
assert(result[0].source == DepSource.binary,
|
||||
"unconstrained dep with binary → binary");
|
||||
}
|
||||
|
||||
// ── Test (5): unconstrained dep without binary but in index → recipe ──
|
||||
@safe unittest
|
||||
{
|
||||
auto tree = mkTree2("libbaz",
|
||||
mkConstraint("libbaz", DepOp.none, ""),
|
||||
"mypkg");
|
||||
|
||||
PackageIndex[] index = [mkIndexEntry("libbaz", Pool.both)];
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
BinaryCheckResult r;
|
||||
r.exists = false; // 404
|
||||
r.ver = "";
|
||||
r.satisfies = false;
|
||||
return r;
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 2);
|
||||
assert(result[0].name == "libbaz");
|
||||
assert(result[0].source == DepSource.recipe,
|
||||
"unconstrained dep no binary, in index → recipe");
|
||||
}
|
||||
|
||||
// ── Test (6): multiple constraints on same dep, one unsatisfied → recipe ──
|
||||
@safe unittest
|
||||
{
|
||||
// parentA deps=[libfoo>=1.0]
|
||||
// parentB deps=[libfoo>=2.0]
|
||||
// root deps=[parentA, parentB]
|
||||
// Binary version: 1.5
|
||||
// >=1.0 → satisfies, >=2.0 → !satisfies → libfoo goes recipe.
|
||||
|
||||
DepNode libfooNode;
|
||||
libfooNode.name = "libfoo";
|
||||
|
||||
DepNode parentANode;
|
||||
parentANode.name = "parentA";
|
||||
parentANode.constraints ~= mkConstraint("libfoo", DepOp.ge, "1.0");
|
||||
|
||||
DepNode parentBNode;
|
||||
parentBNode.name = "parentB";
|
||||
parentBNode.constraints ~= mkConstraint("libfoo", DepOp.ge, "2.0");
|
||||
|
||||
DepNode rootNode;
|
||||
rootNode.name = "mypkg";
|
||||
rootNode.constraints ~= mkConstraint("parentA", DepOp.none, "");
|
||||
rootNode.constraints ~= mkConstraint("parentB", DepOp.none, "");
|
||||
|
||||
DepTree tree;
|
||||
tree.nodes = [libfooNode, parentANode, parentBNode, rootNode];
|
||||
|
||||
PackageIndex[] index = [mkIndexEntry("libfoo", Pool.recipes)];
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
BinaryCheckResult r;
|
||||
r.exists = true;
|
||||
r.ver = "1.5";
|
||||
if (name == "libfoo" && c.op == DepOp.ge && c.ver == "2.0")
|
||||
r.satisfies = false;
|
||||
else
|
||||
r.satisfies = true;
|
||||
return r;
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
// libfoo must be recipe because >=2.0 fails.
|
||||
auto libfooEntry = result[0];
|
||||
assert(libfooEntry.name == "libfoo");
|
||||
assert(libfooEntry.source == DepSource.recipe,
|
||||
"one constraint unsatisfied → recipe");
|
||||
|
||||
// parentA and parentB: unconstrained, binary exists → binary.
|
||||
assert(result[1].name == "parentA");
|
||||
assert(result[1].source == DepSource.binary);
|
||||
assert(result[2].name == "parentB");
|
||||
assert(result[2].source == DepSource.binary);
|
||||
|
||||
// root → recipe.
|
||||
assert(result[3].name == "mypkg");
|
||||
assert(result[3].source == DepSource.recipe);
|
||||
}
|
||||
|
||||
// ── Test (7): root marked recipe ──
|
||||
@safe unittest
|
||||
{
|
||||
// Single-node tree: root with no deps.
|
||||
DepNode rootNode;
|
||||
rootNode.name = "solopkg";
|
||||
|
||||
DepTree tree;
|
||||
tree.nodes = [rootNode];
|
||||
|
||||
PackageIndex[] index;
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
assert(false, "binaryCheck should not be called for root with no deps");
|
||||
return BinaryCheckResult();
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 1);
|
||||
assert(result[0].name == "solopkg");
|
||||
assert(result[0].source == DepSource.recipe,
|
||||
"root is always recipe");
|
||||
}
|
||||
|
||||
// ── Test (8): empty tree → empty result ──
|
||||
@safe unittest
|
||||
{
|
||||
DepTree tree; // nodes.length == 0
|
||||
|
||||
PackageIndex[] index;
|
||||
|
||||
scope binaryCheck = delegate (string name, DepConstraint c) @safe {
|
||||
assert(false, "binaryCheck should never be called on empty tree");
|
||||
return BinaryCheckResult();
|
||||
};
|
||||
|
||||
auto result = constrainDepTree(tree, index, binaryCheck);
|
||||
|
||||
assert(result.length == 0, "empty tree → empty result");
|
||||
}
|
||||
Reference in New Issue
Block a user