feat(deps): add dep tree builder with version constraint parsing
Port of ZETA lib/deps.lua depth-first resolution algorithm: - DepNode/DepTree structs for topologically-ordered dependency trees - resolveDepTree() with function-pointer seam for testability - Cycle detection with full chain message (e.g. 'A -> B -> A') - Memoization to skip already-resolved nodes - Parses recipe.deps string[] into DepConstraint[] via DepConstraint.parse 7 unittests: constraint parsing, linear chain, self-cycle, mutual cycle, missing dep propagation, diamond shared dep, leaf node.
This commit is contained in:
+334
@@ -0,0 +1,334 @@
|
||||
/// tofu.deps — Topological dependency tree builder with version-constraint
|
||||
/// parsing and cycle detection.
|
||||
///
|
||||
/// Port of ZETA `lib/deps.lua` resolution algorithm: depth-first walk,
|
||||
/// in-progress cycle detection with full chain message, and topological
|
||||
/// ordering (dependencies before dependents, target last).
|
||||
///
|
||||
/// The `getRecipe` function pointer is the testability seam — unit tests
|
||||
/// inject mock recipes without any network I/O.
|
||||
module tofu.deps;
|
||||
|
||||
import tofu.types;
|
||||
import std.string : indexOf;
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Exception for dependency resolution errors (cycles).
|
||||
class DepException : Exception
|
||||
{
|
||||
@safe this(string msg)
|
||||
{
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Data structures
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One node in the dependency tree. Each node corresponds to a package
|
||||
/// and stores its parsed dependency constraints (from `recipe.deps`).
|
||||
struct DepNode
|
||||
{
|
||||
string name = "";
|
||||
DepConstraint[] constraints;
|
||||
string recipePath = "";
|
||||
}
|
||||
|
||||
/// A topologically-ordered dependency tree. Nodes are arranged so that
|
||||
/// every dependency appears before the package that depends on it, with
|
||||
/// the target package last.
|
||||
struct DepTree
|
||||
{
|
||||
DepNode[] nodes;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Resolution
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the full dependency tree for `targetName` by calling
|
||||
/// `getRecipe` to obtain each package's `Recipe` struct.
|
||||
///
|
||||
/// Throws `DepException` on dependency cycles (including self-dependency).
|
||||
/// Other exceptions (e.g. missing package) propagate from `getRecipe`.
|
||||
///
|
||||
/// The `getRecipe` function pointer is the testability seam — unit tests
|
||||
/// inject mock recipes without performing network I/O.
|
||||
@safe
|
||||
DepTree resolveDepTree(string targetName,
|
||||
scope Recipe function(string) @safe getRecipe)
|
||||
{
|
||||
DepNode[] order;
|
||||
bool[string] done;
|
||||
bool[string] inProgress;
|
||||
|
||||
// ── Inner walk: depth-first, appends to `order` in topological order ──
|
||||
void walk(string name, ref string[] chain)
|
||||
{
|
||||
// Cycle detection — name is already being walked higher in the stack.
|
||||
if (auto _ = name in inProgress)
|
||||
{
|
||||
chain ~= name;
|
||||
string msg = "dependency cycle: ";
|
||||
for (size_t i = 0; i < chain.length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
msg ~= " -> ";
|
||||
msg ~= chain[i];
|
||||
}
|
||||
throw new DepException(msg);
|
||||
}
|
||||
|
||||
// Memoization — already resolved, skip.
|
||||
if (auto _ = name in done)
|
||||
return;
|
||||
|
||||
// Fetch the recipe via the delegate. If this throws (e.g. missing
|
||||
// package), the exception propagates — the caller handles it.
|
||||
Recipe recipe = getRecipe(name);
|
||||
|
||||
inProgress[name] = true;
|
||||
chain ~= name;
|
||||
|
||||
// Recurse into each dependency.
|
||||
foreach (depSpec; recipe.deps)
|
||||
{
|
||||
auto constraint = DepConstraint.parse(depSpec);
|
||||
walk(constraint.name, chain);
|
||||
}
|
||||
|
||||
// Pop from chain, clear in-progress flag, mark done, append to order.
|
||||
chain = chain[0 .. $ - 1];
|
||||
inProgress.remove(name);
|
||||
done[name] = true;
|
||||
|
||||
// Build the DepNode with parsed constraints from this recipe's deps.
|
||||
DepNode node;
|
||||
node.name = recipe.name;
|
||||
node.recipePath = "";
|
||||
foreach (depSpec; recipe.deps)
|
||||
node.constraints ~= DepConstraint.parse(depSpec);
|
||||
order ~= node;
|
||||
}
|
||||
|
||||
string[] chain;
|
||||
walk(targetName, chain);
|
||||
|
||||
return DepTree(order);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 1: A deps=[B, C>=1.0] — constraint parsing + ordering ──
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
switch (name)
|
||||
{
|
||||
case "A":
|
||||
r.deps = ["B", "C>=1.0"];
|
||||
break;
|
||||
case "B":
|
||||
break; // leaf
|
||||
case "C":
|
||||
break; // leaf
|
||||
default:
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
auto tree = resolveDepTree("A", getRecipe);
|
||||
assert(tree.nodes.length == 3);
|
||||
// Both B and C before A.
|
||||
assert((tree.nodes[0].name == "B" && tree.nodes[1].name == "C")
|
||||
|| (tree.nodes[0].name == "C" && tree.nodes[1].name == "B"));
|
||||
assert(tree.nodes[2].name == "A"); // target last
|
||||
|
||||
// A's constraints: B unconstrained, C with >= 1.0.
|
||||
auto aNode = tree.nodes[2];
|
||||
assert(aNode.constraints.length == 2);
|
||||
assert(aNode.constraints[0].name == "B");
|
||||
assert(aNode.constraints[0].op == DepOp.none);
|
||||
assert(aNode.constraints[0].ver == "");
|
||||
assert(aNode.constraints[1].name == "C");
|
||||
assert(aNode.constraints[1].op == DepOp.ge);
|
||||
assert(aNode.constraints[1].ver == "1.0");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 2: A deps=[B], B deps=[C] — linear chain ──
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
switch (name)
|
||||
{
|
||||
case "A": r.deps = ["B"]; break;
|
||||
case "B": r.deps = ["C"]; break;
|
||||
case "C": break; // leaf
|
||||
default:
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
auto tree = resolveDepTree("A", getRecipe);
|
||||
assert(tree.nodes.length == 3);
|
||||
assert(tree.nodes[0].name == "C");
|
||||
assert(tree.nodes[1].name == "B");
|
||||
assert(tree.nodes[2].name == "A");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 3: self-dep A deps=[A] → DepException ──
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
if (name == "A")
|
||||
r.deps = ["A"];
|
||||
else
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
return r;
|
||||
};
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
resolveDepTree("A", getRecipe);
|
||||
assert(false, "expected DepException");
|
||||
}
|
||||
catch (DepException e)
|
||||
{
|
||||
caught = true;
|
||||
// Must mention "A -> A" (self-cycle).
|
||||
assert(e.msg.indexOf("A -> A") >= 0, e.msg);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
assert(false, "expected DepException, got other exception");
|
||||
}
|
||||
assert(caught, "self-dep should throw DepException");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 4: cycle A deps=[B], B deps=[A] → DepException ──
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
switch (name)
|
||||
{
|
||||
case "A": r.deps = ["B"]; break;
|
||||
case "B": r.deps = ["A"]; break;
|
||||
default:
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
resolveDepTree("A", getRecipe);
|
||||
assert(false, "expected DepException");
|
||||
}
|
||||
catch (DepException e)
|
||||
{
|
||||
caught = true;
|
||||
// Must mention both A and B in the cycle message.
|
||||
assert(e.msg.indexOf("A") >= 0, e.msg);
|
||||
assert(e.msg.indexOf("B") >= 0, e.msg);
|
||||
// Accept either "A -> B -> A" or "B -> A -> B".
|
||||
assert((e.msg.indexOf("A -> B -> A") >= 0)
|
||||
|| (e.msg.indexOf("B -> A -> B") >= 0), e.msg);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
assert(false, "expected DepException, got other exception");
|
||||
}
|
||||
assert(caught, "cycle should throw DepException");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 5: missing dep — getRecipe throws → propagate ──
|
||||
auto getRecipe = (string name) {
|
||||
if (name == "A")
|
||||
{
|
||||
Recipe r;
|
||||
r.name = "A";
|
||||
r.deps = ["X"];
|
||||
return r;
|
||||
}
|
||||
throw new TypesException("not found: " ~ name);
|
||||
};
|
||||
|
||||
bool caught = false;
|
||||
try
|
||||
{
|
||||
resolveDepTree("A", getRecipe);
|
||||
assert(false, "expected TypesException");
|
||||
}
|
||||
catch (TypesException e)
|
||||
{
|
||||
caught = true;
|
||||
assert(e.msg.indexOf("not found: X") >= 0, e.msg);
|
||||
}
|
||||
assert(caught, "missing dep should propagate getRecipe throw");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 6: shared dep diamond — D appears ONCE ──
|
||||
// A deps=[B, C]
|
||||
// B deps=[D]
|
||||
// C deps=[D]
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
switch (name)
|
||||
{
|
||||
case "A": r.deps = ["B", "C"]; break;
|
||||
case "B": r.deps = ["D"]; break;
|
||||
case "C": r.deps = ["D"]; break;
|
||||
case "D": break; // leaf
|
||||
default:
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
auto tree = resolveDepTree("A", getRecipe);
|
||||
assert(tree.nodes.length == 4);
|
||||
assert(tree.nodes[0].name == "D"); // shared dep first
|
||||
assert(tree.nodes[1].name == "B");
|
||||
assert(tree.nodes[2].name == "C");
|
||||
assert(tree.nodes[3].name == "A"); // target last
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 7: empty deps (leaf) — single node ──
|
||||
auto getRecipe = (string name) {
|
||||
Recipe r;
|
||||
r.name = name;
|
||||
if (name != "leaf")
|
||||
throw new TypesException("unknown package: " ~ name);
|
||||
return r;
|
||||
};
|
||||
|
||||
auto tree = resolveDepTree("leaf", getRecipe);
|
||||
assert(tree.nodes.length == 1);
|
||||
assert(tree.nodes[0].name == "leaf");
|
||||
assert(tree.nodes[0].constraints.length == 0);
|
||||
}
|
||||
Reference in New Issue
Block a user