From ba411e9fc427139e3b5afc4be863c1eec10a2ff2 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Sat, 8 Aug 2026 17:53:45 -0400 Subject: [PATCH] feat(resolve): add version-constraint-aware dep resolution with binary fallback --- .omo/evidence/task-12-tofu-core.log | 111 ++++++ .omo/notepads/tofu-core/decisions.md | 3 + .omo/notepads/tofu-core/learnings.md | 53 +++ src/tofu/resolve.d | 510 +++++++++++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 .omo/evidence/task-12-tofu-core.log create mode 100644 src/tofu/resolve.d diff --git a/.omo/evidence/task-12-tofu-core.log b/.omo/evidence/task-12-tofu-core.log new file mode 100644 index 0000000..7f646f0 --- /dev/null +++ b/.omo/evidence/task-12-tofu-core.log @@ -0,0 +1,111 @@ + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Generating test runner configuration 'tofu-test-application' for 'application' (executable). + Warning Excluding main source file src/main.d from test. + Starting Performing "unittest" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date. + Finished To force a rebuild of up-to-date targets, run again with --force + Running tofu-test-application +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +Warning: malformed TOML config at /tmp/tofu-test-config-bad-201829.toml: Invalid table key declaration (2:0) +Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1 +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:53:05] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:53:05] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:06] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 17:53:06] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:53:06] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 17:53:06] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 17:53:06] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' +11 modules passed unittests + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:37645//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:36355//index.lua + . fetching index http://127.0.0.1:52041//index.lua + . fetching index http://127.0.0.1:53177//index.lua + . fetching index http://127.0.0.1:55829//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:53801//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:44861//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:35139//index.lua + . fetching index http://127.0.0.1:41715//index.lua + ok index loaded: 1 packages + - binary libfoo-2.1 satisfies libfoo>=2.0 + - libfoo: binary 1.9 too old, building from recipe + - libfoo: binary 1.9 too old, building from recipe + - binary libbar-3.0 satisfies libbar (unconstrained) + - libbaz: no binary available, building from recipe + - binary libfoo-1.5 satisfies libfoo>=1.0 + - libfoo: binary 1.5 too old, building from recipe + - binary parentA-1.5 satisfies parentA (unconstrained) + - binary parentB-1.5 satisfies parentB (unconstrained) + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Starting Performing "debug" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Building tofu ~main: building configuration [application] + Linking tofu + Finished To force a rebuild of up-to-date targets, run again with --force diff --git a/.omo/notepads/tofu-core/decisions.md b/.omo/notepads/tofu-core/decisions.md index 5051fea..4c91c42 100644 --- a/.omo/notepads/tofu-core/decisions.md +++ b/.omo/notepads/tofu-core/decisions.md @@ -21,4 +21,7 @@ One exception class for the entire types module — no separate subclasses per e ### Task 4: Manual char scanning for dep spec parsing Rather than pulling in `std.regex`, the parser uses hand-written `isNameChar()` and `isWhite()` helpers with a simple position cursor. This keeps the module dependency-light (only `std.ascii`). The logic is a direct 1:1 port of the Lua reference patterns: `[A-Za-z0-9_.+-]` for names/versions, whitespace `[ \t]` for separators. +### Task 12: `delegate` keyword in test lambdas for delegate-typed parameters +D non-capturing lambdas infer as `function` pointers, which cannot implicitly convert to `delegate`-typed parameters. The explicit `delegate` keyword (`scope f = delegate (...) { ... };`) forces the correct type. This pattern is needed for resolution seam delegates that production code will instantiate with captures (e.g. closing over `Config cfg`). + --- diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index 2e8212a..a5e745a 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -463,3 +463,56 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o - `dub build` passes with `warningsAsErrors`. - `dub test` (with deps.d.skip) — 9 modules passed unittests. - Evidence logged to `.omo/evidence/task-9-tofu-core.log`. + +--- + +## Task 12 — `tofu.resolve` (version-constraint-aware dep resolution) + +### Architecture +- Module `tofu.resolve` depends on: `tofu.types` (DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult), `tofu.deps` (DepTree, DepNode), `tofu.log` (logInfo), `std.string` (indexOf). +- `constrainDepTree(tree, index, binaryCheck)` annotates a dep tree with source decisions: binary (pre-built zuur binary satisfied) vs recipe (build from source). +- The `binaryCheck` delegate is the testability seam — production wires `tofu.binary.checkBinaryVersion`; tests inject mocks. + +### Algorithm — three-pass resolution +1. **Pass 1 (aggregate)**: Walk all nodes, collect all `DepConstraint[]` per dep name into `allConstraints` AA. Same dep constrained by multiple parents → all constraints aggregated. +2. **Pass 2 (resolve)**: Walk nodes in topological order. First time a dep name is encountered, resolve it: + - Test ALL aggregated constraints via `binaryCheck(depName, constraint)`. + - If ANY constraint returns `!satisfies` → dep goes recipe (after verifying recipe exists in index). + - If ALL constraints return `satisfies` → dep goes binary. + - Root (last node) is always recipe (it's what the user asked to build from ZUUR recipes). + - Neither binary nor recipe in index → `ResolveException("dependency '' not found in ZUUR (neither binary nor recipe)")`. +3. **Pass 3 (output)**: Build `ConstrainedNode[]` array — one entry per unique node name in tree order, source from resolved map (fallback recipe). + +### Deduplication semantics +- Same dep appearing under multiple parents → resolved ONCE. All constraints tested; if ANY fails → recipe. This implements the "strictest wins" rule from the plan. +- Unconstrained dep (`DepOp.none`) with binary → binary (satisfies always true). Without binary → recipe (if in index). + +### Log format +- `"binary libfoo-2.1 satisfies libfoo>=2.0"` — binary satisfied +- `"libfoo: binary 1.9 too old, building from recipe"` — binary exists but too old +- `"libfoo: no binary available, building from recipe"` — binary 404/skip + +### `delegate` vs `function` in testability seams +- **CRITICAL gotcha**: Non-capturing D lambdas in `@safe unittest` blocks are inferred as `function` pointers with inferred attributes (`pure nothrow @nogc @safe`). A parameter typed as `delegate` cannot accept a `function` even if attributes match. +- **Fix**: Use the explicit `delegate` keyword: `scope binaryCheck = delegate (string name, DepConstraint c) @safe { ... };`. This forces delegate type regardless of captures. +- **Why delegate, not function?** The production caller (install command) must capture `cfg` to call `checkBinaryVersion(name, constraint, cfg)`. A function pointer cannot carry captured state. The `delegate` keyword in tests matches the production usage pattern. +- Contrast with `deps.d` where `resolveDepTree` uses `function` — that works because recipe fetching doesn't need captured state (the production caller uses module-level functions). + +### `std.string.indexOf` import +- `indexOf` is NOT in the default namespace. Must explicitly `import std.string : indexOf;` to use on string in assertions. Same pattern as `deps.d`. + +### Test cases (8/8 pass) +1. dep libfoo>=2.0, binary 2.1 → binary +2. dep libfoo>=2.0, binary 1.9, recipe in index → recipe +3. dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ("neither binary nor recipe") +4. unconstrained dep with binary → binary +5. unconstrained dep without binary but in index → recipe +6. multiple constraints on same dep, one unsatisfied → recipe (dedup + strictest wins) +7. root marked recipe (single-node tree) +8. empty tree → empty result + +### Build verified +- `dub build` passes with `warningsAsErrors`. +- `dub test` passes — all 11 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve) pass unittests. +- No D LSP server configured for `.d` files — diagnostics verified via compiler. +- Evidence logged to `.omo/evidence/task-12-tofu-core.log`. diff --git a/src/tofu/resolve.d b/src/tofu/resolve.d new file mode 100644 index 0000000..361faf5 --- /dev/null +++ b/src/tofu/resolve.d @@ -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"); +}