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:
2026-08-08 17:46:27 -04:00
parent b3cdde4d7c
commit 6a153ef1ac
3 changed files with 494 additions and 0 deletions
+334
View File
@@ -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);
}