feat(resolve): add version-constraint-aware dep resolution with binary fallback

This commit is contained in:
2026-08-08 17:53:45 -04:00
parent 406a87ca41
commit ba411e9fc4
4 changed files with 677 additions and 0 deletions
+510
View File
@@ -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");
}