Compare commits

..
32 Commits
Author SHA1 Message Date
huntedbytheirs 1cdffd26f7 fix(build): point generated package.lua urls at local build cache via file:// repo 2026-08-08 19:22:22 -04:00
huntedbytheirs 9963b8f2f5 fix(security): reject path traversal in build_script and validate default_jobs range 2026-08-08 19:00:00 -04:00
huntedbytheirs 9473b01a79 fix(info): handle missing deps field without unsigned indexOf wrap 2026-08-08 18:54:05 -04:00
huntedbytheirs fc979b650d test(e2e): add full smoketest covering install, search, upgrade, remove
Creates tests/e2e/smoketest.sh — a hermetic end-to-end test that:
- Sets up a mock ZUUR (python3 http.server serving local index/recipes)
- Creates fake zeta-makepkg and zeta scripts (no real Lua tools needed)
- Tests the full pipeline: search → install → upgrade → info → remove
- Verifies exit codes (0, 2 for not-found)
- Verifies installed.json state tracking
- Verifies binary installation/removal via ZETA_ROOT

BUG FOUND (documented in problems.md, not fixed):
- info.d scanDepsArray stores ptrdiff_t indexOf() result in size_t,
  causing ArrayIndexError when recipe lacks a deps field.
  Workaround: recipe includes deps = {}.

Evidence: .omo/evidence/task-27-tofu-core.log — 16/16 checks pass.
2026-08-08 18:52:19 -04:00
huntedbytheirs 70ab40abb9 fix(errors): create cache dir before writing lock file 2026-08-08 18:44:39 -04:00
huntedbytheirs bd26e7f428 feat(upgrade): add -Syu upgrade for tofu-installed recipe packages
- src/tofu/commands/upgrade.d: full 7-step upgrade pipeline
  - Fetches index, lists installed state, compares versions
  - Shows upgrade plan with confirmation prompt
  - REUSES installCommand per outdated package (force=true, noconfirm=true)
  - Continue-on-failure for multi-package upgrades
  - Binary-only packages excluded (pool=binary → skip)
  - 6 unittests: no-installed, all-uptodate, outdated-upgraded,
    removed-from-index, multi-with-failure, confirmation-denied
- src/main.d: wire upgrade stub → upgradeCommand(pa, cfg)
- Evidence: dub test (23/23 pass) + dub build clean
2026-08-08 18:43:21 -04:00
huntedbytheirs 4d8af8f13d feat(install): add -S install with --dry-run and --noconfirm flags
Create full install pipeline (plan task 21):
- src/tofu/recipeparse.d: light .recipe Lua parser (parseRecipeFile)
- src/tofu/commands/install.d: installCommand with delegate seams
- 8 unit tests covering: happy path, not-found, dep cycle, build
  failure, install failure, --dry-run, --noconfirm, user abort
- All 21 modules pass dub test, dub build passes
- Evidence: .omo/evidence/task-21-tofu-core.log
2026-08-08 18:38:22 -04:00
huntedbytheirs 018640b27f feat(info): add -Si package info display 2026-08-08 18:30:36 -04:00
huntedbytheirs 7164cd7c04 feat(remove): add -R remove via zeta
- Add runRemove() to tofu.install (zeta subprocess module)
  - Same pipeProcess/tee/rolling-buffer pattern as runLocalProvide
  - Uses -Remove instead of -LocalProvide
  - Appends --force flag when force=true
- Create tofu.commands.remove with removeCommand()
  - Checks tofu-installed via state.d (warns if not, still proceeds)
  - Confirm prompt (Remove <name>? [y/N]) unless --noconfirm
  - Detects 'still required by' in zeta error → suggests --force
  - Cleans up install record on success
- Wire remove case in main.d dispatch
- 6 unittests with fake zeta scripts
2026-08-08 18:29:54 -04:00
huntedbytheirs 066700b540 feat(ui): add spinner, progress output, and summary table 2026-08-08 18:27:52 -04:00
huntedbytheirs de77fb1733 feat(search): add -Ss ZUUR index search
Implement tofu.commands.search module with searchCommand():
- Case-insensitive substring match on name, summary, version
- Sort results alphabetically by name
- Output format: zuur/<pool> <name> <ver>\n    <summary>
- Exit codes: 0 (success), 1 (no match), 6 (network error)
- Testable via injectable indexFetcher delegate (7 unit tests)
- Wire search dispatch in main.d (replaces stub)
2026-08-08 18:26:41 -04:00
huntedbytheirs 854e7155f9 fix(errors): add actionable error messages for all 16 failure paths 2026-08-08 18:21:17 -04:00
huntedbytheirs 47cfdee424 feat(cli): add yay/paru-style command parsing 2026-08-08 18:19:05 -04:00
huntedbytheirs 44024ca37d feat(state): track tofu-installed packages for upgrade checks 2026-08-08 18:17:24 -04:00
huntedbytheirs 88047eb4ca feat(install): orchestrate install via single root-package LocalProvide call 2026-08-08 18:12:42 -04:00
huntedbytheirs 8f9a3c85a4 feat(build): orchestrate sequential topological builds with fail-fast
Add buildAll() to src/tofu/build.d — sequential builder with:
- Empty plan → 'nothing to build' no-op
- Recipe existence pre-check → fail-fast on missing
- Skip-if-exists: don't rebuild when output present (unless --force)
- Separator line: U+2500 box chars '──── building <name> (i/n) ────'
- Fail-fast: halt immediately on first BuildException

6 unittests: all succeed, first fails, empty plan, skip existing,
force rebuild, missing recipe.
2026-08-08 18:09:06 -04:00
huntedbytheirs c43c1a78d9 feat(install): invoke zeta -LocalProvide with ZETA_LOCAL_PACKAGES and ZETA_REPO env 2026-08-08 18:05:27 -04:00
huntedbytheirs da19ac98b1 feat(build): invoke zeta-makepkg with conditional --force
- Add tofu.build module with runMakepkg() public API
- BuildException for missing binary, non-zero exit, missing output
- Real-time stdout/stderr streaming with last-20-lines stderr capture
- Conditional --force flag based on force parameter
- zetaToolchainPath resolution from Config
- 7 unittests using fake zeta-makepkg shell scripts
- pipeProcess + Redirect.stderr for tee approach
2026-08-08 18:05:07 -04:00
huntedbytheirs 7928b4d5f2 feat(resolve): generate build plan from constrained dep tree
Add generateBuildPlan() to tofu.resolve — filters constrained dep tree
to recipe-only packages, verifies cached recipes exist, and produces
an ordered BuildPlan ready for the build orchestrator (task 15).

- Skips binary-satisfied deps (Zeta handles those)
- Preserves topological order from constrained nodes (deps-first)
- Missing recipe files: re-fetch via injectable delegate seam
  (production wires tofu.fetch.fetchRecipe; tests inject mocks)
- Empty plan (all binary) → returns empty BuildPlan + log info
- Root always recipe — always included in plan
- 6 unittests: filter binary, fetch seam, missing no-seam,
  all-binary empty, root with binary dep, cache hit no-fetch
- dub test + dub build pass with warningsAsErrors
2026-08-08 17:58:25 -04:00
huntedbytheirs ba411e9fc4 feat(resolve): add version-constraint-aware dep resolution with binary fallback 2026-08-08 17:53:45 -04:00
huntedbytheirs 406a87ca41 chore: gitignore test runner artifacts 2026-08-08 17:47:29 -04:00
huntedbytheirs 6a153ef1ac 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.
2026-08-08 17:46:27 -04:00
huntedbytheirs b3cdde4d7c feat(cache): add recipe cache with index-version-based staleness check 2026-08-08 17:45:14 -04:00
huntedbytheirs d7d0d2ec6d feat(fetch): download ZUUR recipe directories to cache
Implements tofu.fetch module with fetchRecipe() that downloads
ZUUR recipe directories using a known-file strategy (no directory
listing assumed). Downloads .recipe (required), package.lua (optional),
build.sh (optional), and custom build_script references.

Key features:
- Light recipe scanning for build_system/build_script discovery
- 404 on .recipe → 'package not found' user-friendly error
- 404 on optional files → logged and skipped
- 404 on referenced build_script → real error
- Partial file cleanup on failure
- 5 unittests using python3 http.server for multi-file scenarios

8/8 modules pass, dub build succeeds with warnings-as-errors.
2026-08-08 17:38:41 -04:00
huntedbytheirs b367f21d29 feat(binary): check zuur/binary package.lua version against constraints 2026-08-08 17:38:21 -04:00
huntedbytheirs c876821cda feat(index): fetch and sandbox-parse ZUUR index.lua
- Module tofu.index: download index via tofu.http.get, sandbox parse via Lua subprocess
- Whitelist sandbox (ported from ZETA lib/sandbox.lua): index code has zero I/O/exec access
- Lua 5.1/5.2+ compatible: setfenv detection with fallback to load() env param
- JSON escaping: handles quotes, backslashes, control characters in string values
- Defensive parsing: skips entries with empty names or invalid pool values
- 10 unittest blocks: happy path, malicious os.execute/io.open blocked, lua not found, bad JSON, empty index, missing name skip, invalid pool skip, syntax error, string escaping
- Evidence: 7 modules pass unittests, dub build passes with warnings-as-errors
2026-08-08 17:37:59 -04:00
huntedbytheirs 777de734f8 feat(http): add synchronous HTTP client with std.net.curl 2026-08-08 17:30:34 -04:00
huntedbytheirs c545879115 feat(version): port rpm-style version comparison from ZETA 2026-08-08 17:22:06 -04:00
huntedbytheirs 348f0e3bac feat(types): add core data structures
PackageIndex, Recipe, DepConstraint, BuildPlan, BuildResult, CacheManifest, BinaryCheckResult structs. Pool/BuildSystem/DepOp/Source enums. DepConstraint.parse() ported from ZETA vercmp.lua. TypesException for parsing errors. All @safe, all strings default to empty string.
2026-08-08 17:17:20 -04:00
huntedbytheirs 3fb03b23d7 feat(config): add env var and XDG TOML config loading
- Config struct with zuurUrl, cacheDir, zetaToolchainPath, zetaPath, defaultJobs
- Priority: env vars > TOML config file > hardcoded defaults
- Env vars: TOFU_ZUUR_URL, TOFU_CACHE_DIR, TOFU_ZETA_TOOLCHAIN_PATH,
  TOFU_ZETA_PATH, TOFU_DEFAULT_JOBS, TOFU_CONFIG
- TOML [core] section parsing via toml package v1.0.0
- URL helpers: recipesUrl, binaryManifestUrl, indexUrl
- Cache path helpers: recipesCacheDir, builtDir, builtPackagesDir
- Malformed TOML → stderr warning + fallback to defaults
- 14 unittest blocks covering all config scenarios
- All tests pass: dub test → 3 modules passed unittests
2026-08-08 17:17:04 -04:00
huntedbytheirs c331416623 feat(log): add colored NO_COLOR-aware logging 2026-08-08 17:16:50 -04:00
huntedbytheirs b2d1df72f9 chore: scaffold dub project with TOML dep 2026-08-08 17:10:04 -04:00
59 changed files with 17893 additions and 1 deletions
+8
View File
@@ -17,10 +17,18 @@
# DUB # DUB
.dub .dub
.dub/
docs.json docs.json
__dummy.html __dummy.html
docs/ docs/
# dub test runner artifacts
tofu-test-application
test_*
# tofu binary
/tofu
# Code coverage # Code coverage
*.lst *.lst
+531
View File
@@ -0,0 +1,531 @@
================================================================================
F2 TOFU-CORE CODE QUALITY AUDIT — FINAL VERDICT
================================================================================
Date: 2026-08-08
Reviewer: Sisyphus-Junior (independent agent)
Target: tofu-core Dlang codebase (24 .d files, 11,018 LOC)
Branch: current HEAD
Build: dmd 2.112 (debug, warningsAsErrors), ldc2 (release)
================================================================================
VERDICT: REJECTED
================================================================================
Reason: 1 BLOCKER (C10 — path traversal in fetch.d), 1 MAJOR (C5 — unsafe
cast(int) in config.d), 2 MINOR findings.
See per-check table below for details.
================================================================================
PER-CHECK SUMMARY TABLE
───────────────────────
Check | Name | Result | Severity (if fail)
───────┼─────────────────────────┼──────────┼───────────────────
C1 | dub build (warnings=err)| PASS | —
C2 | @safe/@system audit | PASS | —
C3 | Hardcoded paths | PASS | —
C4 | Exception handling | PASS | —
C5 | cast() audit | FAIL | MAJOR (1), MINOR (1)
C6 | Error message quality | PASS* | MINOR (2 non-actionable)
C7 | Unbounded memory growth | PASS | —
C8 | Concurrency (spinner) | PASS | —
C9 | Module structure (<1KL) | PASS | —
C10 | Security (sandbox+path) | FAIL | BLOCKER (1)
───────────────────────────────────────────────────────────────────────────────
================================================================================
DETAILED EVIDENCE PER CHECK
================================================================================
─── C1: dub build with warnings-as-errors ────────────────────────────────────
Build 1: dmd debug (default)
Command: dub build
Result: PASS
Evidence:
Warning: only DUB-level deprecation about "warningsAsErrors" buildOption
(DUB recommends "buildRequirements" instead — cosmetic, not a
code defect). Zero compiler warnings, zero errors.
Output:
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished
Build 2: ldc2 release (-b release --compiler=ldc2)
Result: PASS
Evidence:
Starting Performing "release" build using ldc2 for x86_64.
Building toml 1.0.0: building configuration [library]
Building tofu ~main: building configuration [application]
Linking tofu
Zero warnings, zero errors.
Build 3: dmd --force (clean rebuild)
Command: dub build --force
Result: PASS
Only linker messages, zero compiler warnings:
Building toml 1.0.0: building configuration [library]
Building tofu ~main: building configuration [application]
Linking tofu
Sub-verdict: PASS
─── C2: @safe audit ──────────────────────────────────────────────────────────
Check: grep -rn '@system' src/
Result: PASS — zero @system function annotations anywhere in the codebase
Evidence:
All 5 grep hits are documentation comments explaining WHY a particular
section uses @trusted (e.g. "stdin/stdout are @system in dmd 2.112").
No actual @system annotation on any function.
$ grep -rn '@system' src/
src/tofu/log.d:49:/// `@trusted`: `stdout` itself is `@system` to access in dmd 2.112.
src/tofu/http.d:5:/// `@safe`; the `@system` curl internals are isolated behind `@trusted`
src/tofu/http.d:74:// @trusted helpers — all @system curl calls are isolated here
src/tofu/state.d:36:// ─── @trusted wrappers (fs + json — @system in Phobos) ──────
src/tofu/commands/upgrade.d:36:// ─── @trusted I/O wrappers (stdout/stdin are @system in DMD 2.112) ───
Annotations count:
- @safe functions/blocks: 242
- @trusted functions/blocks: 28
- Ratio: ~8.6:1 safe-to-trusted
- @system blocks (`@system:`): ZERO
Public API coverage:
All public API functions are either @safe or @trusted with documented
rationale in module-level doc comments. Filesystem, process, and socket
operations are isolated behind @trusted helpers with explicit comments.
Sub-verdict: PASS
─── C3: Hardcoded paths audit ────────────────────────────────────────────────
Check: grep -rn '"/tmp/\|~/\|"/usr/\|home/' src/ --include='*.d'
Result: PASS — all hits are in test code or documentation
Evidence:
src/tofu/config.d:117: // doc comment: "~/.config/tofu/config.toml"
src/tofu/config.d:130: cfg.cacheDir = expandTilde("~/.cache/tofu"); ← DEFAULT, env-overridable
src/tofu/config.d:279: test assertion — assert(cfg.cacheDir == expandTilde("~/.cache/tofu"))
src/tofu/config.d:300-302: test TOML fixture string content
src/tofu/config.d:308-310: test assertions on TOML fixture
src/tofu/config.d:356-362: test assertions with env override
src/tofu/config.d:424-431: test assertions with env override
src/tofu/build.d:142,181: error messages mentioning config file path (informational)
src/tofu/build.d:500: test assertion — assert(pkgNameFromPath("/tmp/...") == "build")
src/tofu/build.d:585: test fixture — cfg.cacheDir = "/tmp/dummy"
src/tofu/errors.d:287: comment — "On a fresh system ~/.cache/tofu may not exist yet."
Analysis:
- All hardcoded paths are in test assertions, test fixture strings, or
informational error messages.
- The config module (config.d:130) is the sole owner of path defaults
via `expandTilde("~/.cache/tofu")` — overridable by TOML or env var.
- No production code path uses hardcoded absolute paths.
Sub-verdict: PASS
─── C4: Exception handling ───────────────────────────────────────────────────
Check 4a: main.d has try/catch around dispatch
Result: PASS
Evidence:
src/main.d lines 101-127:
try {
final switch (pa.cmd) {
case Command.help: ... return 0;
case Command.install: return installCommand(...);
case Command.search: return searchCommand(...);
case Command.upgrade: return upgradeCommand(...);
case Command.remove_: return removeCommand(...);
case Command.info: return infoCommand(...);
}
} catch (Exception e) {
int ec = exitCodeFor(e);
logError("%s", e.msg);
return ec;
}
Note: catches `Exception`, not `Throwable` — correct D idiom (Errors
like OutOfMemoryError should NOT be caught). PASS.
Lock management: `scope(exit) releaseLock(cacheDir)` at line 81,
`scope(failure) releaseLock(cacheDir)` at line 82 — correct.
Check 4b: grep 'catch.*Throwable'
Result: PASS — all occurrences in unittest cleanup only
Evidence:
src/tofu/http.d:230: catch (Throwable) {} ← oneShotResponder test util
src/tofu/index.d:403: catch (Throwable) {} ← oneShotResponder test util
src/tofu/binary.d:252: catch (Throwable) {} ← oneShotResponder test util
src/tofu/fetch.d:368: catch (Throwable) ← waitForPort test util
src/tofu/fetch.d:381: catch (Throwable) {} ← killServer test util
src/tofu/fetch.d:412: catch (Throwable) {} ← removeDir test util
All 6 occurrences are inside `version(unittest)` blocks — test helpers
where silently discarding errors during cleanup is acceptable (one-shot
TCP responders, wait loops, server teardown). No production code path
swallows Throwable.
Sub-verdict: PASS
─── C5: cast() audit ─────────────────────────────────────────────────────────
Check: grep -rn 'cast(' src/
Result: FAIL — one MAJOR finding, one MINOR finding
Findings:
[MAJOR] src/tofu/config.d:227 — unsafe cast(int) tJobs
──────────────────────────────────────────────────────────
Line: cfg.defaultJobs = cast(int) tJobs;
Context: tJobs is `long.min` sentinel or a TOML integer (long).
`cfg.defaultJobs` is `int` (32-bit signed).
Issue: `cast(int)` silently truncates large values.
e.g. TOML `default_jobs = 5000000000` wraps to negative int.
Impact: Low (absurd job counts not realistic), but violates
defensive coding. The parsePositiveInt in cli.d correctly
validates via `to!int` (which throws on overflow); the
config path should similarly validate.
Fix: Replace `cast(int) tJobs` with `to!int(tJobs)` or clamp
the value. (NOT fixing — reporting only.)
[MINOR] src/tofu/commands/install.d:367 — cast(PackageIndex[])[] in test
──────────────────────────────────────────────────────────
Line: delegate () @safe { return cast(PackageIndex[])[]; }
Context: Test delegate to inject an empty index for testing
"package not found" (exit code 2).
Issue: Raw cast of `[]` literal. Idiom would be
`(PackageIndex[]).init` or `new PackageIndex[0]`.
Impact: Test-only, zero runtime consequence.
Severity: MINOR
PASS items (all in @trusted or test context):
- errors.d:80-139: cast(TofuError/HttpException/...) — safe downcast
- errors.d:216: cast(pid_t) — safe integer cast in POSIX-specific code
- http.d:98: cast(string) data — in @trusted getImpl, ubyte[]→string
- http.d:223,242: cast()/cast(shared) listener — test-only TCP server
- vercmp.d:113: cast(string) buf — in @trusted lambda, char[]→string
- index.d:278: cast() entries — in @trusted block, extracting parsed.array
- index.d:396,416: cast()/cast(shared) listener — test-only TCP server
- binary.d:245,265: cast()/cast(shared) listener — test-only TCP server
- fetch.d:350: cast(InternetAddress) — test-only findFreePort
- commands/info.d:83,151: cast(string) — in @trusted lambdas
Sub-verdict: FAIL (MAJOR: config.d:227)
─── C6: Error message quality ────────────────────────────────────────────────
Check: spot-check 5 throw sites across modules — each must name WHAT
failed and suggest HOW to fix.
Site 1: src/tofu/build.d:141-144
Message: "zeta-makepkg not found. Install zeta-toolchain or set
TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml"
WHAT: zeta-makepkg not found ✓
HOW: install zeta-toolchain OR set config path ✓
Verdict: PASS — fully actionable
Site 2: src/tofu/fetch.d:277-279
Message: "build script '%s' not found for package '%s'"
WHAT: which script, which package ✓
HOW: no suggestion — user doesn't know HOW to resolve
Verdict: MINOR — names WHAT clearly but lacks a suggestion
(e.g. "check the recipe's build_script field")
Site 3: src/tofu/http.d:113-114
Message: "cannot reach ZUUR at <url>: timeout after 120s"
WHAT: which URL timed out ✓
HOW: no suggestion — user doesn't know HOW to fix network issues
Verdict: MINOR — names WHAT clearly but no suggestion
(e.g. "check your network connection or TOFU_ZUUR_URL")
Site 4: src/tofu/index.d:223-224
Message: "ZUUR index is invalid: lua command not found on PATH"
WHAT: lua not found ✓
HOW: implies "install lua" ✓
Verdict: PASS — actionable
Site 5: src/tofu/cli.d:213-214
Message: "unknown option: -Z (run 'tofu --help')"
WHAT: which option is unknown ✓
HOW: run --help for valid options ✓
Verdict: PASS — fully actionable
Summary: 3 of 5 fully actionable, 2 lack HOW guidance. Minor room for
improvement but not a rejection-level issue.
Sub-verdict: PASS (with 2 MINOR notes)
─── C7: Unbounded memory growth in hot paths ─────────────────────────────────
Check 7a: http.d onReceive string accumulator
Evidence: http.d:96-99
http.onReceive = (ubyte[] data) {
content ~= cast(string) data;
return data.length;
};
Analysis: The `get()` function accumulates the full response body into a
string. Used for fetching the ZUUR index (index.lua) — typically
a few KB. Also used for fetching binary package.lua manifests
(a few hundred bytes). Acceptable for these use cases.
The `downloadFile()` function writes to disk, not memory.
Verdict: PASS
Check 7b: stderr ring buffer bound
Evidence: build.d:46-63,75
private final class StderrRing {
string[] _lines;
size_t cap;
...
void add(string line) {
_lines ~= line;
if (_lines.length > cap)
_lines = _lines[1 .. $]; // drop oldest, keep cap
}
}
auto ring = new StderrRing(20); // ← confirmed: 20-line bound
Analysis: Ring buffer initialized at 20 lines (line 75). `add()` drops
oldest line when exceeding capacity. Reader thread joins before
`ring.get()` is called, ensuring happens-before.
Verdict: PASS
Check 7c: spinner thread join
Evidence: ui.d:95-106 — `_thread.join()` wrapped in try/catch
Verdict: PASS (deferred to C8)
Sub-verdict: PASS
─── C8: Concurrency — ui.d spinner thread ────────────────────────────────────
Check: spinner clean stop — no join hang, shared bool flag
Evidence: ui.d:31-119
shared bool _running; // line 35 — shared flag
...
void threadFn() {
while (_running) { // line 50 — checked every iteration
... // 100ms sleep per iteration
}
}
void stop() {
_running = false; // line 93 — signal to stop
if (_thread !is null) {
try {
_thread.join(); // line 99 — join, max wait ~100ms
} catch (Exception) {
// double-join or thread failure — harmless
}
_thread = null; // prevent double-join
}
}
Analysis:
- `_running` is `shared bool` — correct for cross-thread communication
- Thread loop checks `_running` every 100ms (Thread.sleep at line 55)
- `stop()` sets the flag, then joins — max join delay ~100ms
- Double-join prevented by `_thread = null` after join
- Exception during join caught and discarded (benign)
- No hang risk — flag is simple bool, no mutex contention
- Non-TTY mode skips thread entirely (lines 89-90)
Sub-verdict: PASS
─── C9: Module structure ─────────────────────────────────────────────────────
Check: wc -l src/tofu/*.d src/tofu/commands/*.d
Result: PASS — all modules under 1000 lines
Lines Module
───── ──────────────────────
851 src/tofu/resolve.d ← largest
708 src/tofu/install.d
695 src/tofu/build.d
694 src/tofu/index.d
644 src/tofu/fetch.d
600 src/tofu/errors.d
554 src/tofu/commands/install.d
545 src/tofu/commands/upgrade.d
541 src/tofu/types.d
472 src/tofu/recipeparse.d
462 src/tofu/commands/info.d
435 src/tofu/config.d
435 src/tofu/cache.d
390 src/tofu/binary.d
376 src/tofu/commands/search.d
366 src/tofu/state.d
363 src/tofu/cli.d
356 src/tofu/http.d
346 src/tofu/vercmp.d
334 src/tofu/deps.d
330 src/tofu/commands/remove.d
275 src/tofu/ui.d
242 src/tofu/log.d
4 src/tofu/package.d ← module re-export only
─────
11018 TOTAL
Largest module (resolve.d, 851 lines) is well under the 1000-line
ceiling. No file requires an override justification.
Sub-verdict: PASS
─── C10: Security ────────────────────────────────────────────────────────────
Check 10a: index.d Lua sandbox whitelist
Result: PASS
Evidence:
- Whitelist defined at index.d:70-91 (Lua sandboxLuaScript constant)
- Stripped globals: io, os, require, dofile, loadfile, loadstring,
package, debug
- Test 2 (line 471): os.execute("rm -rf /") → IndexException caught,
sentinel file survives → sandbox works
- Test 3 (line 514): io.open("/etc/shadow") → IndexException caught
- ZETA lib/sandbox.lua port, Lua 5.1/5.2+ compatible
- Confirmed: hunkered-down whitelist, NOT a blacklist
Check 10b: fetch.d path traversal
Result: FAIL — BLOCKER
Evidence:
├─ src/tofu/fetch.d:253-258:
│
│ auto buildScriptPath = extractBuildScript(recipeContent);
│ if (buildScriptPath.length > 0) {
│ logDetail("custom build system: fetching %s", buildScriptPath);
│ auto bsUrl = cfg.recipesUrl(name) ~ "/" ~ buildScriptPath;
│ auto bsDest = cacheDir ~ "/" ~ buildScriptPath; // ← UNSANITIZED
│
│ () @trusted {
│ auto bsDir = bsDest.dirName;
│ if (bsDir.length > 0 && !exists(bsDir))
│ mkdirRecurse(bsDir);
│ }();
│ ...
│ downloadFile(bsUrl, bsDest); // writes to escaped path
│ }
│
└─ PROBLEM: No sanitization of `buildScriptPath`.
ATTACK VECTOR:
1. Malicious recipe in ZUUR includes:
build_system = "custom"
build_script = "../../../.bashrc"
2. extractBuildScript returns "../../../.bashrc"
3. bsDest = "~/.cache/tofu/../../../.bashrc"
→ resolves to "$HOME/.bashrc"
4. downloadFile creates $HOME/.bashrc.part, then renames to $HOME/.bashrc
5. Result: user's .bashrc is overwritten with attacker-controlled content
Confirmations:
- Zero occurrences of ".." sanitization anywhere in src/ (confirmed
via grep for indexOf/canFind/contains/startsWith of "..")
- No expandTilde, buildPath, normalizePath, or absolutePath applied
to buildScriptPath or bsDest in the build_script code path
- mkdirRecurse and exists() resolve ".." through the OS kernel
Severity: BLOCKER
The .recipe file content comes from the remote ZUUR repository.
A malicious maintainer (or compromised repository) can write
arbitrary files anywhere the user has write access.
Required fix (not implemented — reporting only):
- Reject buildScriptPath containing ".." before constructing bsDest
- OR resolve bsDest to absolute canonical path and verify it starts
with cacheDir
Sub-verdict: FAIL (BLOCKER: path traversal in fetch.d)
================================================================================
FINDINGS SUMMARY TABLE
================================================================================
# | Check | Severity | Location | Description
───┼───────┼──────────┼────────────────────┼─────────────────────────────────
1 | C10 | BLOCKER | fetch.d:253-258 | Path traversal: buildScriptPath
| | | | from .recipe used unsanitized in
| | | | bsDest, can escape cache dir via
| | | | "../" in build_script field
───┼───────┼──────────┼────────────────────┼─────────────────────────────────
2 | C5 | MAJOR | config.d:227 | cast(int) tJobs silently truncates
| | | | large TOML default_jobs values
───┼───────┼──────────┼────────────────────┼─────────────────────────────────
3 | C6 | MINOR | fetch.d:277-279 | Error message lacks HOW guidance
| | | | for missing build script
4 | C6 | MINOR | http.d:113-114 | Error message lacks HOW guidance
| | | | for timeout/network errors
5 | C5 | MINOR | install.d:367 | cast(PackageIndex[])[] in test —
| | | | raw cast of empty array literal
───┴───────┴──────────┴────────────────────┴─────────────────────────────────
================================================================================
PASS COUNTS
================================================================================
8 of 10 checks PASS: C1, C2, C3, C4, C6*, C7, C8, C9
1 of 10 checks FAIL (non-blocker): C5 (MAJOR + MINOR)
1 of 10 checks FAIL (blocker): C10 (BLOCKER)
──
*C6 PASS but with 2 MINOR non-actionable error message notes
================================================================================
OVERALL VERDICT: REJECTED
================================================================================
The codebase is well-structured, compiles cleanly, handles concurrency
correctly, and has no hardcoded paths or unsafe exception swallowing.
The Lua sandbox is properly implemented. However, the path traversal
vulnerability in fetch.d (C10) is a BLOCKER that could allow a malicious
ZUUR recipe to write files outside the cache directory — this MUST be
fixed before approval.
Recommended fix (not applied — audit only):
In fetch.d, after line 254, add:
if (buildScriptPath.indexOf("..") >= 0)
throw new FetchException(
"build_script path contains '..': " ~ buildScriptPath);
This is a one-line fix. Re-audit of C10 after the fix is applied should
be trivial.
================================================================================
================================================================================
F2 FIX APPLIED — build_script path traversal + default_jobs truncation
================================================================================
Date: 2026-08-08
FINDING 1 (BLOCKER, fetch.d:253-258) — RESOLVED
src/tofu/fetch.d: added private @safe validateBuildScriptPath(string)
- rejects ".." segments, leading '/' (absolute), and '\' (backslash)
with: throw new FetchException("build_script path is unsafe: <path>")
- wired into fetchRecipe step 4 BEFORE building bsUrl/bsDest
- defense in depth: after joining, throws if
!bsDest.startsWith(cacheDir ~ "/")
- valid relative paths unchanged (build.sh, scripts/build.sh pass)
FINDING 2 (MAJOR, config.d:227) — RESOLVED
src/tofu/config.d: default_jobs from TOML now range-validated (1..1024)
- tJobs < 1 || tJobs > 1024 -> stderr warning
"Warning: invalid default_jobs <n> in config, using default 1"
and cfg.defaultJobs = 1 (no silent cast(int) truncation)
- else cfg.defaultJobs = cast(int) tJobs
TESTS ADDED
fetch.d: direct validateBuildScriptPath unittests (evil paths rejected,
valid paths pass) + integration Test 6: recipe with
build_script="../../evil.sh" -> FetchException, nothing escapes cache
config.d: default_jobs = 5000000000 -> 1, 1024 -> 1024, 2048 -> 1,
-3 -> 1
VERIFICATION
dub build : PASS (warningsAsErrors, no warnings)
dub test : 23 modules passed unittests
smoketest : PASS 16 checks passed
+27
View File
@@ -0,0 +1,27 @@
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
Warning: malformed TOML config at /tmp/tofu-test-config-bad-190867.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
7 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
+119
View File
@@ -0,0 +1,119 @@
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
core.exception.AssertError@src/tofu/binary.d(342): Expected HttpException for 500
----------------
??:? _d_unittest_msg [0x55b998ee71dc]
src/tofu/binary.d:342 @safe void tofu.binary.__unittest_L332_C7() [0x55b998e79181]
??:? void tofu.binary.__modtest() [0x55b998ea78cf]
??:? int core.runtime.runModuleUnitTests().__foreachbody_L603_C5(object.ModuleInfo*) [0x55b998f281be]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)).__lambda_L2519_C13(immutable(object.ModuleInfo*)) [0x55b998eddf2f]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))).__foreachbody_L585_C5(ref rt.sections_elf_shared.DSO) [0x55b998eec87f]
??:? int rt.sections_elf_shared.DSO.opApply(scope int delegate(ref rt.sections_elf_shared.DSO)) [0x55b998eeca81]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))) [0x55b998eec80d]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)) [0x55b998eddf01]
??:? runModuleUnitTests [0x55b998f27ff3]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).runAll() [0x55b998eeb6e0]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).tryExec(scope void delegate()) [0x55b998eeb66d]
??:? _d_run_main2 [0x55b998eeb5e3]
??:? _d_run_main [0x55b998eeb3eb]
/usr/include/dlang/dmd/core/internal/entrypoint.d:29 main [0x55b998e6881d]
??:? [0x7f2d10227d0d]
??:? __libc_start_main [0x7f2d10227e4a]
??:? _start [0x55b998e67c24]
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-197721.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:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:45:45] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:45:45] "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'
1/10 modules FAILED 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 ...
==> 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:33793//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:37595//index.lua
 . fetching index http://127.0.0.1:52879//index.lua
 . fetching index http://127.0.0.1:48777//index.lua
 . fetching index http://127.0.0.1:50123//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:60371//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:60073//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:34765//index.lua
 . fetching index http://127.0.0.1:47551//index.lua
 ok index loaded: 1 packages
Error Program exited with code 1
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+111
View File
@@ -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
+152
View File
@@ -0,0 +1,152 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/build.d(126,33): Error: `;` expected
import std.process : Config as ProcConfig;
^
src/tofu/build.d(243,59): Error: conflicting attribute `@safe`
string exitCode = "0", string argsFile = "") @safe {
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
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-204774.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:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:57:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:57:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:57:40] "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:33945//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:33079//index.lua
 . fetching index http://127.0.0.1:39107//index.lua
 . fetching index http://127.0.0.1:58239//index.lua
 . fetching index http://127.0.0.1:52265//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:55027//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:54741//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:46869//index.lua
 . fetching index http://127.0.0.1:58081//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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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
+58
View File
@@ -0,0 +1,58 @@
=== tofu build task 14 — evidence log ===
=== dub test ===
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Building tofu ~main: building configuration [tofu-test-application]
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
=== dub build ===
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Building tofu ~main: building configuration [application]
Linking tofu
Finished To force a rebuild of up-to-date targets, run again with --force
=== file listing ===
429 src/tofu/build.d
=== task-14 fix: --repo points at local build cache (file://) ===
ROOT CAUSE:
- src/tofu/build.d runMakepkg passed a HARDCODED
"--repo https://files.spectoria.dev/zuur/binary" to zeta-makepkg.
- zeta-makepkg baked that URL into every generated package.lua as
url = "https://files.spectoria.dev/zuur/binary/<name>-<ver>.tar.gz".
- zeta -LocalProvide then fetched the tarball from the REMOTE fileserver
(404 — doesn't exist) instead of the local build cache where the
tarball actually lives at <cache>/built/packages/<name>/.
FIX (verified live):
- runMakepkg now passes "--repo", "file://" ~ outputDir, so generated
manifests carry url = "file://<outputDir>/packages/<name>/<name>-<ver>.tar.gz".
- Zeta's fetch.get handles file:// via plain file copy
(references/ZETA/lib/fetch.lua:108-110). LocalProvide installs work offline.
- Manually confirmed end-to-end: GNU Make 4.4.1 built + installed + runs.
FILES CHANGED:
- src/tofu/build.d only (runMakepkg doc + --repo arg + new unittest 14)
- install.d ZETA_REPO untouched (remote binary pool for BINARY deps — correct)
- fetch.d, config.d untouched
VERIFICATION:
- dub build (warningsAsErrors): PASS (Finished, Linking tofu)
- dub test: 23 modules passed unittests
- bash tests/e2e/smoketest.sh: 16/16 PASS (fake zeta-makepkg ignores --repo,
parses only --output — unaffected)
- New unittest (14) asserts fake zeta-makepkg receives "--repo file://<outDir>"
and that the hardcoded https URL is NOT passed.
EVIDENCE OUTPUT (trimmed):
dub build 2>&1 | tail -2:
Linking tofu
Finished To force a rebuild of up-to-date targets, run again with --force
dub test 2>&1 | grep "modules passed":
23 modules passed unittests
bash tests/e2e/smoketest.sh 2>&1 | tail -2:
PASS: 16 checks passed
All checks passed.
+148
View File
@@ -0,0 +1,148 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-214411/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-214411.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 18:08:36] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:08:36] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:08:36] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:08:36] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:08:36] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:08:36] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:49413//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:38411//index.lua
 . fetching index http://127.0.0.1:43727//index.lua
 . fetching index http://127.0.0.1:33325//index.lua
 . fetching index http://127.0.0.1:45567//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:43115//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:36939//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:47607//index.lua
 . fetching index http://127.0.0.1:48661//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
- already installed — skipping
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+129
View File
@@ -0,0 +1,129 @@
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
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonewarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-211516.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 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:04:14] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:04:14] "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'
 . 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:47181//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:53573//index.lua
 . fetching index http://127.0.0.1:58917//index.lua
 . fetching index http://127.0.0.1:50501//index.lua
 . fetching index http://127.0.0.1:53705//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:58781//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48757//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:53589//index.lua
 . fetching index http://127.0.0.1:59755//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
- already installed — skipping
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+152
View File
@@ -0,0 +1,152 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-216783/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-216783.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 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:12:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:12:13] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:50721//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:47569//index.lua
 . fetching index http://127.0.0.1:42797//index.lua
 . fetching index http://127.0.0.1:42795//index.lua
 . fetching index http://127.0.0.1:51365//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:51389//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:42841//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:59485//index.lua
 . fetching index http://127.0.0.1:35061//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall13 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+260
View File
@@ -0,0 +1,260 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/state.d(203,53): Error: conflicting attribute `@trusted`
private @safe Config makeTestConfig(string suffix) @trusted {
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/errors.d(31,12): Error: unable to read module `errno`
import core.sys.posix.errno : ESRCH;
^
src/tofu/errors.d(31,12): Expected 'core/sys/posix/errno.d' or 'core/sys/posix/errno/package.d' in one of the following import paths:
import path[0] = src/
import path[1] = ../../.dub/cache/tofu/~main/code/tofu-test-application-unittest-qC6iqORPwrCDXediNbesdA/
import path[2] = ../../.dub/packages/toml/1.0.0/toml/src/
import path[3] = /usr/include/dlang/dmd
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/cli.d(261,25): Error: no property `indexOf` for `e.msg` of type `string`
assert(e.msg.indexOf(needle) >= 0,
^
src/tofu/state.d(85,21): Error: `@safe` function `tofu.state.loadState` cannot call `@system` function `std.json.JSONValue.array`
foreach (entry; root.array) {
^
/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here
@property ref inout(JSONValue[]) array() scope return inout pure @system
^
src/tofu/state.d(344,12): Error: `@safe` function `tofu.state.__unittest_L327_C7` cannot call `@system` function `std.json.JSONValue.array`
assert(parsed.array.length == 2);
^
/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here
@property ref inout(JSONValue[]) array() scope return inout pure @system
^
src/tofu/state.d(347,21): Error: `@safe` function `tofu.state.__unittest_L327_C7` cannot call `@system` function `std.json.JSONValue.array`
foreach (entry; parsed.array) {
^
/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here
@property ref inout(JSONValue[]) array() scope return inout pure @system
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/state.d(44,68): Error: return value `v.array()` of type `const(JSONValue[])` does not match return type `JSONValue[]`, and cannot be implicitly converted
private @trusted JSONValue[] fArray(const JSONValue v) { return v.array; }
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-221718/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-221718.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 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:39] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:39] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:59437//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:56161//index.lua
 . fetching index http://127.0.0.1:52523//index.lua
 . fetching index http://127.0.0.1:50341//index.lua
 . fetching index http://127.0.0.1:32959//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:58617//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:51607//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:42319//index.lua
 . fetching index http://127.0.0.1:38171//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-221718/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-221718/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
14 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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]
src/main.d(26,8): Error: unable to read module `cli`
import tofu.cli : Command, parseArgs, helpText;
^
src/main.d(26,8): Expected 'tofu/cli.d' or 'tofu/cli/package.d' in one of the following import paths:
import path[0] = src/
import path[1] = ../../.dub/packages/toml/1.0.0/toml/src/
import path[2] = /usr/include/dlang/dmd
Error /usr/bin/dmd failed with exit code 1.
+363
View File
@@ -0,0 +1,363 @@
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
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-221389/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-221389.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:16:30] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:43823//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:41319//index.lua
 . fetching index http://127.0.0.1:52719//index.lua
 . fetching index http://127.0.0.1:42909//index.lua
 . fetching index http://127.0.0.1:46941//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:56281//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48155//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:53239//index.lua
 . fetching index http://127.0.0.1:51665//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-221389/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-221389/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
16 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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]
src/main.d(26,8): Error: module `tofu.cli` import `helpText` not found
import tofu.cli : Command, parseArgs, helpText;
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-222376/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-222376.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:17] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:17:17] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:17:17] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:17:17] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:17:17] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:56427//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:46879//index.lua
 . fetching index http://127.0.0.1:53329//index.lua
 . fetching index http://127.0.0.1:46323//index.lua
 . fetching index http://127.0.0.1:59185//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:50481//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:35515//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:36507//index.lua
 . fetching index http://127.0.0.1:44385//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-222376/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-222376/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
16 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
=== BUILD ===
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]
src/main.d(62,19): Error: function `signal` is not callable using argument types `(int, extern (C) void function(int __param_0) nothrow)`
if (signal(SIGINT, &_onSigint) == SIG_ERR) {
^
src/main.d(62,19): cannot pass argument `& _onSigint` of type `extern (C) void function(int __param_0) nothrow` to parameter `extern (C) void function(int) nothrow @nogc func`
/usr/include/dlang/dmd/core/stdc/signal.d(75,9): `core.stdc.signal.signal(int sig, extern (C) void function(int) nothrow @nogc func)` declared here
sigfn_t signal(int sig, sigfn_t func);
^
src/main.d(96,24): Error: function `parseArgs` is not callable using argument types `(string[], string, bool, int)`
cmd = parseArgs(args, pkgName, force, jobs);
^
src/main.d(96,24): expected 1 argument(s), not 4
src/tofu/cli.d(101,12): `tofu.cli.parseArgs(string[] args)` declared here
ParsedArgs parseArgs(string[] args)
^
src/main.d(105,14): Error: no property `Help` for type `Command`. Did you mean `Command.help` ?
case Command.Help:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
src/main.d(119,14): Error: no property `Install` for type `Command`. Did you mean `Command.install` ?
case Command.Install:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
src/main.d(123,14): Error: no property `Search` for type `Command`. Did you mean `Command.search` ?
case Command.Search:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
src/main.d(127,14): Error: no property `Upgrade` for type `Command`. Did you mean `Command.upgrade` ?
case Command.Upgrade:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
src/main.d(131,14): Error: no property `Remove` for type `Command`
case Command.Remove:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
src/main.d(135,14): Error: no property `Info` for type `Command`. Did you mean `Command.info` ?
case Command.Info:
^
src/tofu/cli.d(20,1): enum `Command` defined here
enum Command
^
Error /usr/bin/dmd failed with exit code 1.
+34
View File
@@ -0,0 +1,34 @@
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
Warning: malformed TOML config at /tmp/tofu-test-config-bad-174212.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
3 modules passed unittests
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+198
View File
@@ -0,0 +1,198 @@
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
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
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-230309/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-230309.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:25:33] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:35267//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:36739//index.lua
 . fetching index http://127.0.0.1:34899//index.lua
 . fetching index http://127.0.0.1:55143//index.lua
 . fetching index http://127.0.0.1:40821//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:50055//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:45123//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:41077//index.lua
 . fetching index http://127.0.0.1:34935//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
18 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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]
src/tofu/ui.d(98,43): Error: function `core.thread.osthread.Thread.join(bool rethrow = true)` is not callable using argument types `(Duration)`
() @trusted { _thread.join(dur!("msecs")(2000)); }();
^
src/tofu/ui.d(98,43): cannot pass argument `dur(2000L)` of type `Duration` to parameter `bool rethrow = true`
Error /usr/bin/dmd failed with exit code 1.
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
Running tofu -Ss neovim
std.file.FileException@std/file.d(840): /home/specter/.cache/tofu/.lock: No such file or directory
----------------
??:? @trusted bool std.file.cenforce!(bool).cenforce(bool, scope const(char)[], scope const(char)*, immutable(char)[], ulong) [0x555b5bf91721]
??:? @trusted void std.file.writeImpl(scope const(char)[], scope const(char)*, scope const(void)[], bool) [0x555b5bf90f3f]
/usr/include/dlang/dmd/std/file.d:745 @safe void std.file.write!(immutable(char)[]).write(immutable(char)[], const(void[])) [0x555b5bf2da9f]
src/tofu/errors.d:177 @trusted void tofu.errors.writeLockFile(immutable(char)[], immutable(char)[]) [0x555b5bf52c8e]
src/tofu/errors.d:317 @safe bool tofu.errors.acquireLock(immutable(char)[]) [0x555b5bf5313a]
src/main.d:72 _Dmain [0x555b5befedd7]
Error Program exited with code 1
+277
View File
@@ -0,0 +1,277 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/commands/install.d(262,26): Error: `buildPath` matches conflicting symbols:
auto path = buildPath(tempDir, "tofu-instcmd-" ~ suffix ~ "-" ~ thisProcessID.to!string);
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
immutable(C)[] buildPath(C)(const(C)[][] paths...)
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
src/tofu/commands/install.d(283,26): Error: `buildPath` matches conflicting symbols:
auto path = buildPath(dir, "fake-makepkg");
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
immutable(C)[] buildPath(C)(const(C)[][] paths...)
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
src/tofu/commands/install.d(303,26): Error: `buildPath` matches conflicting symbols:
auto path = buildPath(dir, "fake-zeta");
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
immutable(C)[] buildPath(C)(const(C)[][] paths...)
^
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
Error /usr/bin/dmd failed with exit code 1.
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
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-245807/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
error package 'nonexistent' not found in ZUUR
error package 'ghost' not found in ZUUR
error dependency cycle: A -> B -> A
error build failed for bfp: build failed for bfp:
(no stderr output)
error build failed: bfp — build failed for bfp:
(no stderr output)
error install phase error: install failed for ifp:
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
neovim
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
neovim
version: 0.9.5
summary: Text editor
pool: both
build system: cmake
deps: libluv>=1.30, msgpack-c
url: https://github.com/neovim/neovim
neovim
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
installed: 0.9.5
status: up to date
ripgrep
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 13.0.0
status: outdated (zuur has 14.1.0)
firefox
version: 120.0
summary: Web browser
pool: binary
firefox
version: 120.0
summary: Web browser
pool: binary
ripgrep
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 15.0.0
status: newer than zuur
==> generating build plan
- + happypkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): happypkg
==> ──── building happypkg (1/1) ────
 ok happypkg
 ok installed happypkg with 0 dependencies
 ok installed 1 package(s)
==> generating build plan
- + bfp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): bfp
==> ──── building bfp (1/1) ────
==> generating build plan
- + ifp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): ifp
==> ──── building ifp (1/1) ────
 ok ifp
==> generating build plan
- + drp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): drp
- dry run — nothing built
==> generating build plan
- + ncp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): ncp
==> ──── building ncp (1/1) ────
 ok ncp
 ok installed ncp with 0 dependencies
 ok installed 1 package(s)
==> generating build plan
- + abp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): abp
Proceed? [y/N] Warning: malformed TOML config at /tmp/tofu-test-config-bad-245807.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:37:13] "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'
- aborted by user
==> 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:49713//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:46903//index.lua
 . fetching index http://127.0.0.1:55641//index.lua
 . fetching index http://127.0.0.1:35097//index.lua
 . fetching index http://127.0.0.1:60737//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:36947//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:42949//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:35765//index.lua
 . fetching index http://127.0.0.1:35763//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
21 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+297
View File
@@ -0,0 +1,297 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-249455/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
error package 'nonexistent' not found in ZUUR
error package 'ghost' not found in ZUUR
error dependency cycle: A -> B -> A
error build failed for bfp: build failed for bfp:
(no stderr output)
error build failed: bfp — build failed for bfp:
(no stderr output)
error install phase error: install failed for ifp:
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
neovim
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
neovim
version: 0.9.5
summary: Text editor
pool: both
build system: cmake
deps: libluv>=1.30, msgpack-c
url: https://github.com/neovim/neovim
neovim
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
installed: 0.9.5
status: up to date
ripgrep
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 13.0.0
status: outdated (zuur has 14.1.0)
firefox
version: 120.0
summary: Web browser
pool: binary
firefox
version: 120.0
summary: Web browser
pool: binary
ripgrep
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 15.0.0
status: newer than zuur
==> generating build plan
- + happypkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): happypkg
==> ──── building happypkg (1/1) ────
 ok happypkg
 ok installed happypkg with 0 dependencies
 ok installed 1 package(s)
==> generating build plan
- + bfp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): bfp
==> ──── building bfp (1/1) ────
==> generating build plan
- + ifp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): ifp
==> ──── building ifp (1/1) ────
 ok ifp
==> generating build plan
- + drp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): drp
- dry run — nothing built
==> generating build plan
- + ncp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): ncp
==> ──── building ncp (1/1) ────
 ok ncp
 ok installed ncp with 0 dependencies
 ok installed 1 package(s)
==> generating build plan
- + abp (recipe)
 ok build plan: 1 packages
- will build 1 package(s): abp
Proceed? [y/N] warn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway
- aborted by user
 ok removed testpkg
removed ok ok removed foreignpkg
error cannot remove libfoo: still required by libbar (use --force to override)error cannot remove libfoo (use --force to override)
 ok removed testpkg
removed without prompt ok removed testpkg
Remove testpkg? [y/N] warn package 'ghost' no longer in ZUUR — skipping
- aborted by user
- nothing to do
 . package 'foo' is up to date (2.0)
- nothing to do
- will upgrade 1 package(s): oldpkg: 1.0 → 2.0
- upgrading oldpkg (1.0 → 2.0)...
==> generating build plan
- + oldpkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): oldpkg
==> ──── building oldpkg (1/1) ────
 ok oldpkg
 ok installed oldpkg with 0 dependencies
 ok installed 1 package(s)
 ok 1 package(s) upgraded
- will upgrade 1 package(s): realpkg: 1.0 → 2.0
- upgrading realpkg (1.0 → 2.0)...
==> generating build plan
- + realpkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): realpkg
==> ──── building realpkg (1/1) ────
 ok realpkg
 ok installed realpkg with 0 dependencies
 ok installed 1 package(s)
 ok 1 package(s) upgraded, 1 no longer in ZUUR
- will upgrade 2 package(s): goodpkg: 1.0 → 2.0, badpkg: 1.0 → 2.0
- upgrading goodpkg (1.0 → 2.0)...
==> generating build plan
- + goodpkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): goodpkg
==> ──── building goodpkg (1/1) ────
 ok goodpkg
 ok installed goodpkg with 0 dependencies
 ok installed 1 package(s)
- upgrading badpkg (1.0 → 2.0)...
==> generating build plan
- + badpkg (recipe)
 ok build plan: 1 packages
- will build 1 package(s): badpkg
==> ──── building badpkg (1/1) ────
 ok badpkg
install failederror install phase error: install failed for badpkg: install failed
error upgrade failed for badpkg
warn 1 upgrade(s) failed
 ok 1 package(s) upgraded
- will upgrade 1 package(s): abortpkg: 1.0 → 2.0
Proceed? [y/N] Warning: malformed TOML config at /tmp/tofu-test-config-bad-249455.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:15] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:42:16] "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'
- aborted by user
==> 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:43045//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:54983//index.lua
 . fetching index http://127.0.0.1:49387//index.lua
 . fetching index http://127.0.0.1:41617//index.lua
 . fetching index http://127.0.0.1:35363//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:60717//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:40975//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:41091//index.lua
 . fetching index http://127.0.0.1:33219//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
23 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+162
View File
@@ -0,0 +1,162 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-235652/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
warn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
 ok removed testpkg
removed ok ok removed foreignpkg
error cannot remove libfoo: still required by libbar (use --force to override)error cannot remove libfoo (use --force to override)
 ok removed testpkg
removed without prompt ok removed testpkg
Remove testpkg? [y/N] Warning: malformed TOML config at /tmp/tofu-test-config-bad-235652.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:28:20] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:28:20] "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'
- aborted by user
==> 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:48507//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:37359//index.lua
 . fetching index http://127.0.0.1:33211//index.lua
 . fetching index http://127.0.0.1:60681//index.lua
 . fetching index http://127.0.0.1:49755//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:55251//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:53781//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:48381//index.lua
 . fetching index http://127.0.0.1:47133//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-235652/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
20 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+60
View File
@@ -0,0 +1,60 @@
=== Task 24 — tofu.commands.info (dub test) ===
$(date)
$ dub test 2>&1
...
21 modules passed unittests
Info test output:
neovim
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
neovim (cached recipe)
version: 0.9.5
summary: Text editor
pool: both
build system: cmake
deps: libluv>=1.30, msgpack-c
url: https://github.com/neovim/neovim
neovim (installed up to date)
version: 0.9.5
summary: Text editor
pool: both
- recipe not cached — run 'tofu -S neovim' to fetch
installed: 0.9.5
status: up to date
ripgrep (outdated)
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 13.0.0
status: outdated (zuur has 14.1.0)
firefox (binary only)
version: 120.0
summary: Web browser
pool: binary
ripgrep (newer than zuur)
version: 14.1.0
summary: Fast grep
pool: recipes
- recipe not cached — run 'tofu -S ripgrep' to fetch
installed: 15.0.0
status: newer than zuur
=== Task 24 — tofu.commands.info (dub build) ===
$(date)
$ dub build 2>&1
...
Linking tofu
Finished
All 8 unittests pass. Build clean with warningsAsErrors.
+328
View File
@@ -0,0 +1,328 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/ui.d(98,43): Error: function `core.thread.osthread.Thread.join(bool rethrow = true)` is not callable using argument types `(Duration)`
() @trusted { _thread.join(dur!("msecs")(2000)); }();
^
src/tofu/ui.d(98,43): cannot pass argument `dur(2000L)` of type `Duration` to parameter `bool rethrow = true`
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/commands/info.d(325,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(325,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(335,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("nonexistent", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(335,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(356,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(356,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(371,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(371,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(386,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("ripgrep", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(386,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(397,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("firefox", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(397,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(412,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("firefox", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(412,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(425,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("ripgrep", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(425,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/install.d(112,27): Error: taking the address of local variable `entry` is not allowed in a `@safe` function
foundEntry = &entry;
^
src/tofu/commands/install.d(231,34): Error: `@safe` function `tofu.commands.install.installCommand` cannot call `@system` function `std.stdio.readln!string.readln`
response = readln().strip();
^
/usr/include/dlang/dmd/std/stdio.d(5242,20): which calls `makeGlobal`
@property ref File makeGlobal(StdFileHandle _iob)()
^
/usr/include/dlang/dmd/std/stdio.d(5244,25): and using `__gshared` instead of `shared` makes it fail to infer `@safe`
__gshared File.Impl impl;
^
/usr/include/dlang/dmd/std/stdio.d(4499,3): `std.stdio.readln!string.readln` is declared here
S readln(S = string)(dchar terminator = '\n')
^
src/tofu/commands/install.d(363,10): Error: `write` matches conflicting symbols:
write(path, content);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/install.d(371,12): Error: module `std.string` import `octal` not found
import std.string : toStringz, octal;
^
src/tofu/commands/install.d(390,10): Error: `write` matches conflicting symbols:
write(path, script);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/install.d(401,12): Error: module `std.string` import `octal` not found
import std.string : toStringz, octal;
^
src/tofu/commands/install.d(405,10): Error: `write` matches conflicting symbols:
write(path, script);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/remove.d(49,9): Error: `@safe` function `tofu.commands.remove.removeCommand` cannot call `@system` function `std.stdio.makeGlobal!"core.stdc.stdio.stdout".makeGlobal`
stdout.flush();
^
/usr/include/dlang/dmd/std/stdio.d(5244,25): and using `__gshared` instead of `shared` makes it fail to infer `@safe`
__gshared File.Impl impl;
^
/usr/include/dlang/dmd/std/stdio.d(5242,20): `std.stdio.makeGlobal!"core.stdc.stdio.stdout".makeGlobal` is declared here
@property ref File makeGlobal(StdFileHandle _iob)()
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-232842/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-232842.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:41] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:41] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:41] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:41] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:41] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:38591//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:52363//index.lua
 . fetching index http://127.0.0.1:42821//index.lua
 . fetching index http://127.0.0.1:43871//index.lua
 . fetching index http://127.0.0.1:44243//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:41801//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48459//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:57985//index.lua
 . fetching index http://127.0.0.1:55199//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-232842/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-232842/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
19 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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]
src/tofu/commands/remove.d(11,8): Error: module `tofu.install` import `runRemove` not found
import tofu.install : runRemove, InstallException;
^
Error /usr/bin/dmd failed with exit code 1.
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]
src/main.d(25,8): Error: unable to read module `info`
import tofu.commands.info : infoCommand;
^
src/main.d(25,8): Expected 'tofu/commands/info.d' or 'tofu/commands/info/package.d' in one of the following import paths:
import path[0] = src/
import path[1] = ../../.dub/packages/toml/1.0.0/toml/src/
import path[2] = /usr/include/dlang/dmd
Error /usr/bin/dmd failed with exit code 1.
+183
View File
@@ -0,0 +1,183 @@
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
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-224364/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-224364.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:19:27] "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'
 . 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
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> 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:57805//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:34467//index.lua
 . fetching index http://127.0.0.1:45521//index.lua
 . fetching index http://127.0.0.1:42301//index.lua
 . fetching index http://127.0.0.1:55651//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:42907//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48255//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:52369//index.lua
 . fetching index http://127.0.0.1:53325//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-224364/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-224364/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
16 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- 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)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
23 modules passed unittests
=== Re-verification after crash fix ===
--- rm -rf ~/.cache/tofu && ./tofu --help ---
tofu — ZUUR package manager
Usage: tofu <command> [arg] [flags]
Commands:
-S <pkg> Install package from ZUUR recipes
-Ss <q> Search ZUUR
-Syu Upgrade installed packages
-R <pkg> Remove package
-Si <pkg> Show package info
-h, --help Show this help
Flags:
--noconfirm Skip confirmation prompts
--dry-run Show what would happen without doing it
--force Overwrite existing builds
-j<N> Parallel build jobs
EXIT: 0
--- ./tofu (no args) ---
error no command given (run 'tofu --help')
EXIT: 0
--- dub test result ---
23 modules passed unittests
+68
View File
@@ -0,0 +1,68 @@
=== tofu smoketest ===
step 0: build tofu binary... PASS
step 1: create temp workspace and hello binary... PASS
step 2: create hello.recipe... PASS
step 3: create package.lua... PASS
step 4: create fake zeta-makepkg and zeta... PASS
step 5: create mock ZUUR layout... PASS
step 6: start mock ZUUR http server... PASS
ZUUR_URL=http://127.0.0.1:27644
CACHE_DIR=/tmp/tmp.707gC0rrPK/cache
step 7: tofu -Ss hello (search)... PASS
step 8: tofu -S hello --noconfirm (install)...  . fetching index http://127.0.0.1:27644/index.lua
 ok index loaded: 1 packages
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
 . downloading build.sh
- no build.sh for hello (optional build script)
 ok recipe hello downloaded
==> generating build plan
- + hello (recipe)
 ok build plan: 1 packages
- will build 1 package(s): hello
==> ──── building hello (1/1) ────
 ok hello
installing hello-1.0 ok installed hello with 0 dependencies
 ok installed 1 package(s)
installed.json check... PASS
binary in root check... PASS
step 9: tofu -Syu --noconfirm (upgrade — nothing to do)... PASS
step 10: tofu -Si hello (info)... PASS
step 11: tofu -R hello --noconfirm (remove)... removing hello ok removed hello
binary removed check... PASS
installed.json cleared check... PASS
step 12: tofu -S nonexistent --noconfirm (expect exit 2)...  . fetching index http://127.0.0.1:27644/index.lua
 ok index loaded: 1 packages
PASS
step 13: cleanup... PASS
=== smoketest complete ===
PASS: 16 checks passed
All checks passed.
=== FIX (task 27): info.d unsigned indexOf wrap ===
Bug: src/tofu/commands/info.d:93 — `size_t pos = content.indexOf("deps")`.
indexOf returns ptrdiff_t (-1 for not found); unsigned size_t wraps -1 to
SIZE_MAX, making `if (pos < 0)` a no-op → ArrayIndexError on recipes
without a deps field (uncaught D Error, crashed -Si).
Fix applied:
1. scanDepsArray: size_t pos → ptrdiff_t pos (+ explanatory comment).
2. Added regression unittest (Test 4): cached recipe WITHOUT deps field
(name/ver/summary/build_system only) → infoCommand returns 0.
Recipe written via existing writeRecipe helper, mirrors Test 3 pattern.
Audit of same pattern elsewhere (checked, NOT bugs):
- info.d:36 auto kp = ...indexOf(key) → auto infers ptrdiff_t ✓
- recipeparse.d:52,112 auto idx = indexOf(...) → ptrdiff_t ✓
- fetch.d:82 auto idx = indexOf(...) → ptrdiff_t ✓
- search.d / remove.d / install.d → inline >= 0 / == -1 ✓
No other unsigned indexOf assignment found.
Verification:
dub build → Linking tofu (pass)
dub test → 23 modules passed unittests
smoketest → PASS: 16 checks passed, All checks passed.
+34
View File
@@ -0,0 +1,34 @@
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
Warning: malformed TOML config at /tmp/tofu-test-config-bad-174602.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
3 modules passed unittests
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+160
View File
@@ -0,0 +1,160 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/config.d(125,9): Error: found `private` instead of statement
private @trusted void warn() {
^
src/tofu/config.d(132,1): Error: unmatched closing brace
}
^
src/tofu/types.d(147,13): Error: variable name expected after type `string`, not `version`
string version = "";
^
src/tofu/types.d(147,13): `version` is a keyword, perhaps append `_` to make it an identifier
src/tofu/types.d(147,21): Error: declaration expected, not `=`
string version = "";
^
src/tofu/types.d(159,16): Error: variable name expected after type `string`, not `version`
string version = "";
^
src/tofu/types.d(159,16): `version` is a keyword, perhaps append `_` to make it an identifier
src/tofu/types.d(159,29): Error: declaration expected, not `=`
string version = "";
^
src/tofu/types.d(185,12): Error: variable name expected after type `string`, not `version`
string version = "";
^
src/tofu/types.d(185,12): `version` is a keyword, perhaps append `_` to make it an identifier
src/tofu/types.d(185,20): Error: declaration expected, not `=`
string version = "";
^
src/tofu/types.d(268,16): Error: identifier or `new` expected following `.`, not `version`
result.version = spec[verStart .. verEnd];
^
src/tofu/types.d(326,12): Error: variable name expected after type `string`, not `version`
string version = "";
^
src/tofu/types.d(326,12): `version` is a keyword, perhaps append `_` to make it an identifier
src/tofu/types.d(326,22): Error: declaration expected, not `=`
string version = "";
^
src/tofu/types.d(366,12): Error: variable name expected after type `string`, not `version`
string version = "";
^
src/tofu/types.d(366,12): `version` is a keyword, perhaps append `_` to make it an identifier
src/tofu/types.d(366,22): Error: declaration expected, not `=`
string version = "";
^
src/tofu/types.d(405,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "");
^
src/tofu/types.d(411,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "");
^
src/tofu/types.d(422,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "2.0");
^
src/tofu/types.d(428,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "2.0");
^
src/tofu/types.d(434,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "2.0");
^
src/tofu/types.d(440,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "2.0");
^
src/tofu/types.d(446,18): Error: identifier or `new` expected following `.`, not `version`
assert(d.version == "2.0");
^
error limit (20) reached, use `-verrors=0` to show all
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/config.d(126,20): Error: function `fprintf` is not callable using argument types `(File, string, immutable(char)*, immutable(char)*, int)`
fprintf(stderr, "Warning: invalid %s '%s', using default %d\n",
^
src/tofu/config.d(126,20): cannot pass argument `makeGlobal()` of type `File` to parameter `shared(_IO_FILE)* stream`
/usr/include/dlang/dmd/core/stdc/stdio.d(1347,13): `core.stdc.stdio.fprintf(shared(_IO_FILE)* stream, scope const(char*) format, scope const ...)` declared here
int fprintf(FILE* stream, scope const char* format, scope const ...);
^
src/tofu/config.d(182,32): Error: `@safe` function `tofu.config.load` cannot call `@system` function `toml.toml.parseTOML`
doc = parseTOML(content);
^
../../.dub/packages/toml/1.0.0/toml/src/toml/toml.d(431,14): `toml.toml.parseTOML` is declared here
TOMLDocument parseTOML(string data, TOMLOptions options=TOMLOptions.none) {
^
src/tofu/config.d(187,28): Error: function `fprintf` is not callable using argument types `(File, string, immutable(char)*, immutable(char)*)`
fprintf(stderr, "Warning: malformed TOML config at %s: %s\n",
^
src/tofu/config.d(187,28): cannot pass argument `makeGlobal()` of type `File` to parameter `shared(_IO_FILE)* stream`
/usr/include/dlang/dmd/core/stdc/stdio.d(1347,13): `core.stdc.stdio.fprintf(shared(_IO_FILE)* stream, scope const(char*) format, scope const ...)` declared here
int fprintf(FILE* stream, scope const char* format, scope const ...);
^
src/tofu/config.d(194,28): Error: function `fprintf` is not callable using argument types `(File, string, immutable(char)*)`
fprintf(stderr, "Warning: unreadable TOML config at %s, "
^
src/tofu/config.d(194,28): cannot pass argument `makeGlobal()` of type `File` to parameter `shared(_IO_FILE)* stream`
/usr/include/dlang/dmd/core/stdc/stdio.d(1347,13): `core.stdc.stdio.fprintf(shared(_IO_FILE)* stream, scope const(char*) format, scope const ...)` declared here
int fprintf(FILE* stream, scope const char* format, scope const ...);
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
Warning: malformed TOML config at /tmp/tofu-test-config-bad-173904.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
3 modules passed unittests
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+35
View File
@@ -0,0 +1,35 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
Warning: malformed TOML config at /tmp/tofu-test-config-bad-178345.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
4 modules passed unittests
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+35
View File
@@ -0,0 +1,35 @@
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
Warning: malformed TOML config at /tmp/tofu-test-config-bad-183825.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
5 modules passed unittests
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
+151
View File
@@ -0,0 +1,151 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/fetch.d(165,39): Error: `Config` matches conflicting symbols:
@safe string fetchRecipe(string name, Config cfg)
^
src/tofu/config.d(29,1): struct `tofu.config.Config`
struct Config {
^
/usr/include/dlang/dmd/std/process.d(2192,1): struct `std.process.Config`
struct Config
^
src/tofu/fetch.d(410,19): Error: `Config` matches conflicting symbols:
private @safe Config testConfig(string baseUrl, string cacheDir)
^
src/tofu/config.d(29,1): struct `tofu.config.Config`
struct Config {
^
/usr/include/dlang/dmd/std/process.d(2192,1): struct `std.process.Config`
struct Config
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/fetch.d(344,37): Error: no property `port` for `(cast(Socket)s).localAddress()` of type `std.socket.Address`
auto port = s.localAddress().port;
^
/usr/include/dlang/dmd/std/socket.d(1249,10): class `Address` defined here
abstract class Address
^
src/tofu/fetch.d(375,25): Error: undefined identifier `Pid`, did you mean variable `pid`?
try { kill(cast(Pid) pid, SIGTERM); } catch (Throwable) {}
^
src/tofu/fetch.d(439,31): Error: no property `pid` for `spawnProcess(((const const(char[][6]) __arrayliteral_on_s...` of type `std.process.Pid`
workDir: serveDir).pid;
^
/usr/include/dlang/dmd/std/process.d(2347,7): class `Pid` defined here
final class Pid
^
src/tofu/fetch.d(480,31): Error: no property `pid` for `spawnProcess(((const const(char[][6]) __arrayliteral_on_s...` of type `std.process.Pid`
workDir: serveDir).pid;
^
/usr/include/dlang/dmd/std/process.d(2347,7): class `Pid` defined here
final class Pid
^
src/tofu/fetch.d(526,31): Error: no property `pid` for `spawnProcess(((const const(char[][6]) __arrayliteral_on_s...` of type `std.process.Pid`
workDir: serveDir).pid;
^
/usr/include/dlang/dmd/std/process.d(2347,7): class `Pid` defined here
final class Pid
^
src/tofu/fetch.d(562,31): Error: no property `pid` for `spawnProcess(((const const(char[][6]) __arrayliteral_on_s...` of type `std.process.Pid`
workDir: serveDir).pid;
^
/usr/include/dlang/dmd/std/process.d(2347,7): class `Pid` defined here
final class Pid
^
src/tofu/fetch.d(597,31): Error: no property `pid` for `spawnProcess(((const const(char[][6]) __arrayliteral_on_s...` of type `std.process.Pid`
workDir: serveDir).pid;
^
/usr/include/dlang/dmd/std/process.d(2347,7): class `Pid` defined here
final class Pid
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
Warning: malformed TOML config at /tmp/tofu-test-config-bad-189983.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn skipping index entry with empty name
warn skipping index entry with empty name
warn skipping index entry 'badpool': invalid pool 'bad_pool_value'
7 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 index http://127.0.0.1:40689//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:58297//index.lua
 . fetching index http://127.0.0.1:36381//index.lua
 . fetching index http://127.0.0.1:56071//index.lua
 . fetching index http://127.0.0.1:51747//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:39983//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:39303//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:47269//index.lua
 . fetching index http://127.0.0.1:39257//index.lua
 ok index loaded: 1 packages
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
+101
View File
@@ -0,0 +1,101 @@
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
Warning: malformed TOML config at /tmp/tofu-test-config-bad-191318.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:38:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:38:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:38:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:38:05] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:38:05] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:38:05] "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'
8 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:45641//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:48821//index.lua
 . fetching index http://127.0.0.1:60693//index.lua
 . fetching index http://127.0.0.1:35357//index.lua
 . fetching index http://127.0.0.1:41973//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:50313//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:54449//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:38951//index.lua
 . fetching index http://127.0.0.1:40753//index.lua
 ok index loaded: 1 packages
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
+322
View File
@@ -0,0 +1,322 @@
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/deps.d(149,31): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
auto tree = resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(149,31): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(184,31): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
auto tree = resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(184,31): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(207,23): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(207,23): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(214,21): Error: no property `indexOf` for `e.msg` of type `string`
assert(e.msg.indexOf("A -> A") >= 0, e.msg);
^
src/tofu/deps.d(242,23): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(242,23): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(249,21): Error: no property `indexOf` for `e.msg` of type `string`
assert(e.msg.indexOf("A") >= 0, e.msg);
^
src/tofu/deps.d(250,21): Error: no property `indexOf` for `e.msg` of type `string`
assert(e.msg.indexOf("B") >= 0, e.msg);
^
src/tofu/deps.d(252,22): Error: no property `indexOf` for `e.msg` of type `string`
assert((e.msg.indexOf("A -> B -> A") >= 0)
^
src/tofu/deps.d(253,22): Error: no property `indexOf` for `e.msg` of type `string`
|| (e.msg.indexOf("B -> A -> B") >= 0), e.msg);
^
src/tofu/deps.d(279,23): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(279,23): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(285,21): Error: no property `indexOf` for `e.msg` of type `string`
assert(e.msg.indexOf("not found: X") >= 0, e.msg);
^
src/tofu/deps.d(311,31): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
auto tree = resolveDepTree("A", getRecipe);
^
src/tofu/deps.d(311,31): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
src/tofu/deps.d(330,31): Error: function `resolveDepTree` is not callable using argument types `(string, Recipe function(string name) @safe)`
auto tree = resolveDepTree("leaf", getRecipe);
^
src/tofu/deps.d(330,31): cannot pass argument `getRecipe` of type `Recipe function(string name) @safe` to parameter `scope Recipe delegate(string) @safe getRecipe`
src/tofu/deps.d(61,9): `tofu.deps.resolveDepTree(string targetName, scope Recipe delegate(string) @safe getRecipe)` declared here
DepTree resolveDepTree(string targetName,
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
warn corrupted cache for mypkg: JSONValue is not an object
core.exception.AssertError@src/tofu/cache.d(263): same version should not be stale
----------------
??:? _d_unittest_msg [0x561231e593a4]
src/tofu/cache.d:264 @safe void tofu.cache.__unittest_L257_C7() [0x561231e1c4ab]
??:? void tofu.cache.__modtest() [0x561231e1ece4]
??:? int core.runtime.runModuleUnitTests().__foreachbody_L603_C5(object.ModuleInfo*) [0x561231e9a36a]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)).__lambda_L2519_C13(immutable(object.ModuleInfo*)) [0x561231e500f7]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))).__foreachbody_L585_C5(ref rt.sections_elf_shared.DSO) [0x561231e5ea2b]
??:? int rt.sections_elf_shared.DSO.opApply(scope int delegate(ref rt.sections_elf_shared.DSO)) [0x561231e5ec2d]
??:? int rt.minfo.moduleinfos_apply(scope int delegate(immutable(object.ModuleInfo*))) [0x561231e5e9b9]
??:? int object.ModuleInfo.opApply(scope int delegate(object.ModuleInfo*)) [0x561231e500c9]
??:? runModuleUnitTests [0x561231e9a19f]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).runAll() [0x561231e5d88c]
??:? void rt.dmain2._d_run_main2(char[][], ulong, extern (C) int function(char[][])*).tryExec(scope void delegate()) [0x561231e5d819]
??:? _d_run_main2 [0x561231e5d78f]
??:? _d_run_main [0x561231e5d597]
/usr/include/dlang/dmd/core/internal/entrypoint.d:29 main [0x561231dde81d]
??:? [0x7fa221827d0d]
??:? __libc_start_main [0x7fa221827e4a]
??:? _start [0x561231dddc24]
Warning: malformed TOML config at /tmp/tofu-test-config-bad-195341.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:43:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:43:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:43:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:43:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:43:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:43:40] "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'
1/9 modules FAILED 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:33233//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:56885//index.lua
 . fetching index http://127.0.0.1:36983//index.lua
 . fetching index http://127.0.0.1:51213//index.lua
 . fetching index http://127.0.0.1:60193//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:42147//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:50625//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:40363//index.lua
 . fetching index http://127.0.0.1:39457//index.lua
 ok index loaded: 1 packages
Error Program exited with code 1
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.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/cache.d(98,9): Error: heredoc rest of line should be blank
q"EOS{"name":"%s","ver":"%s","fetchedAt":%d}EOS",
^
src/tofu/cache.d(98,9): Error: unterminated delimited string constant starting at src/tofu/cache.d(98,10)
q"EOS{"name":"%s","ver":"%s","fetchedAt":%d}EOS",
^
src/tofu/cache.d(435,1): Error: found `End of File` when expecting `)`
src/tofu/cache.d(435,1): Error: semicolon expected following auto declaration, not `End of File`
src/tofu/cache.d(435,1): Error: matching `}` expected following compound statement, not `End of File`
src/tofu/cache.d(82,1): unmatched `{`
{
^
Error /usr/bin/dmd failed with exit code 1.
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.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
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-196105.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:44:23] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:23] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:23] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:23] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:44:23] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:24] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:44:24] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 17:44:24] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:44:24] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 17:44:24] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 17:44:24] "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'
9 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:34091//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:40227//index.lua
 . fetching index http://127.0.0.1:49149//index.lua
 . fetching index http://127.0.0.1:50065//index.lua
 . fetching index http://127.0.0.1:37743//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:47299//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:40257//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:39005//index.lua
 . fetching index http://127.0.0.1:36019//index.lua
 ok index loaded: 1 packages
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.
Up-to-date tofu ~main: target for configuration [application] is up to date.
Finished To force a rebuild of up-to-date targets, run again with --force
+124
View File
@@ -0,0 +1,124 @@
# Decisions — tofu-core
Architectural choices and rationales discovered during work on this plan.
_Auto-scaffolded by /start-work. Append new entries below - never overwrite._
---
### Task 4: `version` → `ver` rename
`version` is a reserved keyword in D (conditional compilation). All struct fields bearing this name are renamed to `ver` — PackageIndex.ver, Recipe.ver, DepConstraint.ver, CacheManifest.ver, BinaryCheckResult.ver. This keeps the API readable while avoiding the keyword conflict. Downstream modules referencing these structs must use `.ver` for the version field.
### Task 4: DepConstraint uses typed `DepOp` enum, not string op
The Lua reference stores `op` as a string (`nil`, `">="`, `"=="`, etc.). In D we use a typed `DepOp` enum (`ge, le, eq, ne, gt, lt, none`) for type safety and exhaustive switching. The `parse` factory handles string-to-enum conversion at parse time.
### Task 4: BuildPlan is a plain container — no sorting
The plan specifies that `order()` returns entries in deps-first order *guaranteed by the caller*. The struct itself is just a container with `add()` and `order()` — no topological sort or dependency resolution. Sorting logic belongs in a later module (`tofu.resolver` or similar).
### Task 4: Single exception type `TypesException`
One exception class for the entire types module — no separate subclasses per error category. The parse failures (bad dep spec, invalid pool) all throw `TypesException` with a descriptive message. Callers catch `TypesException` for all type-parsing errors.
### 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`).
---
### Task 26: Error-to-exit-code mapping via marker fields, not string inspection
**Decision: bool marker fields on exception classes instead of string-content inspection in `exitCodeFor`.**
The plan initially suggested checking `e.msg.indexOf("not found")` to distinguish exit code 2 (package not found) from exit code 6 (network error) for `FetchException`. Instead, each exception class carries a boolean marker:
- `BuildException.toolMissing` — distinguishes tool-not-found (exit 7) from build failure (exit 4).
- `InstallException.toolMissing` — distinguishes tool-not-found (exit 7) from install failure (exit 5).
- `FetchException.notFound` — distinguishes package-not-found (exit 2) from network error (exit 6).
**Rationale**: String inspection is fragile — error messages may change, use different formatting, or get truncated. A dedicated field communicates intent unambiguously and survives message refactoring.
### Task 26: `TofuError` base class for explicit-exit exceptions
Rather than a separate `throwExit(int, string)` helper, `TofuError : Exception` with an `int exitCode` field lets callers throw the exception and have `exitCodeFor` read the code directly. This keeps the exit-mapping logic centralized (always go through `exitCodeFor`) while allowing callers to set explicit codes when they know them (e.g. in command stubs).
### Task 26: Lock file at `<cacheDir>/.lock` with PID-liveness check
The lock uses `kill(pid, 0)` (POSIX signal 0) to test whether the locking PID is still alive. A dead PID means the lock is stale and can be safely removed. This avoids the need for a separate lock-daemon or file-lock (flock/fcntl).
**Conservative assumption**: If `kill(pid, 0)` fails with anything other than ESRCH (e.g. EPERM), the PID is assumed alive. This errs on the side of safety — false-positive "another process" is better than concurrent writes.
### Task 26: `not implemented yet` stubs in dispatch for commands 20–24
Commands 20–24 (search, install, upgrade, remove, info) are separate tasks running in parallel. Since D cannot conditionally import modules at compile time, main.d dispatches via `final switch` on the `Command` enum with all cases present. For commands whose modules don't exist yet, a `logError("command '<x>' not implemented yet")` + `return 1` stub is used. These stubs are documented and will be replaced when tasks 20–24 land.
### Task 26: `write(2, ...)` POSIX syscall for signal handler, not `stderr.rawWrite`
DMD 2.112's `core.sys.posix.signal.signal` requires the handler to be `@nogc`. `std.stdio.File.rawWrite` is NOT `@nogc` (File is a GC-managed class). Instead, the handler uses the raw POSIX `write(2, msg.ptr, msg.length)` syscall from `core.sys.posix.unistd`, which is a direct C call and fully `@nogc`.
Similarly, `_exit(130)` from `core.sys.posix.unistd` is used instead of `core.stdc.stdlib.exit` — `_exit` does NOT run atexit handlers or flush stdio buffers, making it safe in a signal-handler context.
---
### Task 21: Module-level Config for `resolveDepTree` function pointer seam
`resolveDepTree` uses `Recipe function(string) @safe` (function pointer, not delegate) because the production caller (`installCommand`) needs to capture `Config cfg`. D function pointers cannot carry state, so a module-level `_installCfg` variable bridges the gap.
**Pattern**: Set `_installCfg = cfg` before calling `resolveDepTree`, clear via `scope(exit) _installCfg = Config.init;`. The `_recipeForDeps` function pointer reads `_installCfg` for cache-path access and recipe fetching.
**Rationale**: Tofu is single-threaded (lock-file based). Module-level state is safe within a single command run. The `scope(exit)` ensures cleanup on all exit paths (return, exception, goto).
### Task 21: Light recipe parser in `tofu.recipeparse` — double-quote only
The `extractKeyValue` scanner (ported from `tofu.fetch`) only recognizes double-quoted string values (`"value"`). Single-quoted values (`'value'`) are silently ignored, producing empty fields.
**Decision**: Document this limitation rather than adding single-quote support. ZUUR `.recipe` files use double quotes per Lua convention. The parser is explicitly documented as a LIGHT scanner, not a full Lua parser.
### Task 21: Confirmation prompt via `readln()` — `@trusted` wrapper
`std.stdio.stdin`, `stdout`, and `readln()` are all `@system` in DMD 2.112. The confirmation prompt wraps stdout write/flush and stdin read in `@trusted` helpers (`trustedReadLine()`), keeping the public `installCommand` `@safe`.
**Rationale**: Same pattern used in all tofu modules (`tofu.log`, `tofu.build`, `tofu.install`). No new `@trusted` philosophy — just the standard I/O seam.
### Task 21: Fake makepkg/zeta scripts for unit tests
Rather than adding build/install delegate seams to `installCommand`, the tests reuse the fake-script pattern from `build.d` and `install.d`: create executable bash scripts in temp directories, point `cfg.zetaToolchainPath` and `cfg.zetaPath` at them.
**Rationale**: Keeps `installCommand`'s API surface minimal (only index/binary seams). The fake scripts exercise the full production code path through `buildAll` and `installAll`, providing higher-fidelity integration tests.
---
### Task 22: Upgrade REUSES installCommand — no duplication
The upgrade command calls `installCommand(pkgName, flags, cfg, ...)` for each outdated package rather than replicating the install pipeline (fetch → resolve → build → install → record). This avoids code duplication and ensures upgrades benefit from all fixes/enhancements to the install path.
**Rationale**: DRY principle. The install pipeline is the single source of truth for package installation. Upgrade is "install with a newer version" — same semantics, same code path.
### Task 22: force=true override for upgrade builds
When upgrade calls `installCommand`, it passes a copy of `ParsedArgs` with `force=true`. This ensures stale build output from the previous version is overwritten — ZETA's `-ReProvide` handles the overwrite at install time, but the build step must also rebuild even if `package.lua` already exists in the built cache.
**Rationale**: Without force, `buildAll`'s skip-if-exists optimization would see the old version's `package.lua` and skip the rebuild. Upgrades MUST rebuild.
### Task 22: noconfirm=true on per-package flags in upgrade
Each `installCommand` call within upgrade receives `noconfirm=true`. The upgrade plan already showed the overall list and obtained user confirmation; individual package installs should not re-prompt.
**Rationale**: Double-confirmation is poor UX. The upgrade's "Proceed? [y/N]" covers all packages. If the user wants per-package confirms, they can upgrade individually with `tofu -S <pkg>`.
### Task 22: Continue-on-failure for multi-package upgrades
Unlike `installCommand` which returns immediately on failure, `upgradeCommand` collects failures and continues with remaining packages. This ensures one broken package doesn't block updates to all others.
**Rationale**: Arch/pacman convention (`-Syu` continues on errors). Users expect upgrades to be best-effort. Failed packages are reported in the summary with a non-zero exit code.
### Task 22: Binary-only packages excluded from upgrade
Packages with `pool == Pool.binary` in the ZUUR index are skipped by upgrade with `logDetail`. Binary packages are managed by ZETA directly; tofu only tracks and upgrades recipe-built packages.
**Rationale**: Tofu's `installed.json` state only tracks recipe packages. Upgrading binary packages would require a different mechanism (e.g. querying ZETA's local database), which is outside tofu's scope.
### Task 22: Closure adapter for delegate type mismatch
`upgradeCommand`'s indexFetcher is `PackageIndex[] delegate(Config)` (with Config param), but `installCommand` expects `PackageIndex[] delegate()` (no-arg closure). The adapter is a zero-arg closure `delegate PackageIndex[]() @safe { return index; }` that captures the already-fetched index array.
**Rationale**: Avoids fetching the index once per package during upgrade. The single fetch at upgrade level is reused for all installCommand calls.
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
# Problems — tofu-core
Unresolved blockers and technical debt discovered during work on this plan.
_Auto-scaffolded by /start-work. Append new entries below - never overwrite._
---
## BUG FOUND (task 27 smoketest): info.d scanDepsArray — unsigned type stores signed indexOf result
**Severity:** High (crash on any recipe without `deps` field)
**Location:** `src/tofu/commands/info.d:93`
**Root cause:**
`size_t pos = content.indexOf("deps")` — indexOf returns ptrdiff_t (-1 for not found), storing it in size_t (unsigned) wraps -1 to SIZE_MAX. The guard `pos < 0` is always false for unsigned types. When deps is absent, `content[pos - 1]` accesses far out of bounds → ArrayIndexError.
**Why it escaped unit tests:** All 8 info.d unittests use recipes containing a `deps` field. The crash only triggers when `deps` is completely absent.
**Impact:** `tofu -Si` crashes on recipes without `deps` with an uncaught ArrayIndexError (D Error, not Exception — bypasses catch blocks).
**Fix (to be dispatched):** Change `size_t pos` to `ptrdiff_t pos` at line 93.
**Workaround in smoketest:** Recipe includes `deps = {}` to avoid triggering this bug.
---
## RESOLVED (task 27)
**Fixed:** `scanDepsArray` in `src/tofu/commands/info.d` — `size_t pos` → `ptrdiff_t pos`
so the `pos < 0` guard fires on `indexOf` returning -1. Added a regression unittest
(recipe without deps → infoCommand returns 0). Audit of the same pattern across
`info.d` (line 36), `recipeparse.d` (52, 112), `fetch.d` (82), `search.d`, `remove.d`,
`install.d` found no other unsigned indexOf assignment (`auto` infers `ptrdiff_t`
everywhere else).
Verified: `dub build` ✓, `dub test` — 23 modules passed ✓, smoketest — 16 checks PASS ✓.
## RESOLVED (F2 review, 2026-08-08)
**Fixed:** Both F2 findings.
1. **BLOCKER — path traversal in fetch.d (`build_script`)**: extracted
`validateBuildScriptPath(string)` (private @safe) — rejects `..`
segments, leading `/`, and `\` with `FetchException("build_script path
is unsafe: <path>")`. Wired into `fetchRecipe` step 4 before building
`bsUrl`/`bsDest`, plus defense-in-depth containment check
(`bsDest.startsWith(cacheDir ~ "/")`). Added direct unit tests and an
integration test (recipe with `build_script = "../../evil.sh"` →
FetchException, no file escapes the cache).
2. **MAJOR — `cast(int)` truncation in config.d `default_jobs`**:
range-validate before casting. `tJobs < 1 || tJobs > 1024` → stderr
warning + fallback to 1. Added tests for 5000000000→1, 1024→1024,
2048→1, -3→1.
Verified: `dub build` ✓, `dub test` — 23 modules passed ✓,
smoketest — 16 checks PASS ✓.
+22 -1
View File
@@ -1,3 +1,24 @@
# tofu # tofu
A package manager for the ZereneOS Unofficial User Repository A package manager for the ZereneOS Unofficial User Repository
## What it is
A Dlang wrapper around zeta-toolchain and ZETA for the ZUUR recipe repository, in the style of yay/paru for the ZereneOS ecosystem.
## Requirements
- dub
- a D compiler (dmd or ldc2)
- lua
- curl
- zeta-toolchain
- Zeta
## Build
```
dub build
```
Produces the `./tofu` binary.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "tofu",
"description": "A package manager for the ZereneOS Unofficial User Repository",
"authors": ["huntedbytheirs"],
"license": "BSD-3-Clause",
"targetType": "executable",
"targetName": "tofu",
"dependencies": {
"toml": "~>1.0.0"
},
"buildOptions": ["warningsAsErrors"]
}
+130
View File
@@ -0,0 +1,130 @@
/// tofu — package manager for the ZereneOS Unofficial User Repository.
///
/// Entry point: parses args, acquires the global lock, dispatches to command
/// implementations, and catches ALL exceptions with actionable messages and
/// correct exit codes.
///
/// Exit codes (per plan):
/// 0 success | 1 generic | 2 pkg not found | 3 dep resolution | 4 build
/// failure | 5 install failure | 6 network | 7 config | 130 SIGINT
///
/// Implementation note:
/// Tasks 20–24 (command modules) may not exist yet in parallel execution.
/// This module dispatches via a `switch` on `Command`. Commands whose
/// modules don't exist yet print "command '<x>' not implemented yet" and
/// exit 1 — these stubs are replaced when tasks 20–24 land. Only `help`
/// is fully implemented here.
module main;
import tofu.errors; // exitCodeFor, acquireLock, releaseLock, LockException
import tofu.config; // Config, load
import tofu.cli; // Command, ParsedArgs, parseArgs, helpText, CliException
import tofu.log; // logError, logInfo
import tofu.commands.install; // installCommand
import tofu.commands.search; // searchCommand
import tofu.commands.info : infoCommand;
import tofu.commands.remove; // removeCommand
import tofu.commands.upgrade : upgradeCommand;
import std.stdio; // writeln, stderr
// ────────────────────────────────────────────────────────────
// SIGINT handler
// ────────────────────────────────────────────────────────────
version (Posix) {
import core.sys.posix.signal : signal, SIGINT, SIG_ERR;
import core.sys.posix.unistd : write, _exit;
__gshared bool g_interrupted;
/// Signal handler for SIGINT. Sets the global flag, writes a bare message
/// to stderr via the `write(2)` syscall (fully async-signal-safe), then calls
/// `_exit(130)` immediately (no atexit / stdio flush).
/// The @nogc attribute is required by DMD's `core.sys.posix.signal` wrapper.
extern (C) void _onSigint(int) nothrow @nogc
{
g_interrupted = true;
// write(2, ...) — POSIX syscall, no allocation, no GC, async-signal-safe.
const char[] msg = "error interrupted\n";
write(2 /* STDERR_FILENO */, msg.ptr, msg.length);
_exit(130);
}
}
// ────────────────────────────────────────────────────────────
// Main
// ────────────────────────────────────────────────────────────
int main(string[] args)
{
// ── 1. Register SIGINT handler (must happen early) ─────────────
version (Posix) {
if (signal(SIGINT, &_onSigint) == SIG_ERR) {
stderr.writeln("error failed to register SIGINT handler");
}
}
// ── 2. Load configuration ─────────────────────────────────────
// Config::load() never throws — falls back to defaults on errors.
auto cfg = load(null);
// ── 3. Acquire global lock (prevents concurrent tofu runs) ────
// Wrapped in try/catch: LockException has a fixed, actionable
// message and should not be remapped through exitCodeFor.
try {
acquireLock(cfg.cacheDir);
} catch (LockException e) {
logError("%s", e.msg);
return 1;
}
scope (exit) releaseLock(cfg.cacheDir);
scope (failure) releaseLock(cfg.cacheDir);
// ── 4. Check for premature SIGINT flag ────────────────────────
version (Posix) {
if (g_interrupted)
return 130;
}
// ── 5. Parse command-line args ─────────────────────────────────
// parseArgs expects argv[1..$] (no program name).
ParsedArgs pa;
try {
pa = parseArgs(args[1 .. $]);
} catch (CliException e) {
logError("%s", e.msg);
return 1;
}
// ── 6. Dispatch ────────────────────────────────────────────────
try {
final switch (pa.cmd) {
case Command.help:
writeln(tofu.cli.helpText);
return 0;
case Command.install:
return installCommand(pa.arg, pa, cfg);
case Command.search:
return searchCommand(pa.arg, cfg);
case Command.upgrade:
return upgradeCommand(pa, cfg);
case Command.remove_:
return removeCommand(pa.arg, pa, cfg);
case Command.info:
return infoCommand(pa.arg, pa, cfg);
}
} catch (Exception e) {
// Any uncaught exception during dispatch — map to exit code
int ec = exitCodeFor(e);
logError("%s", e.msg);
return ec;
}
return 0;
}
+390
View File
@@ -0,0 +1,390 @@
/// tofu.binary — Check zuur/binary package.lua versions against dependency constraints.
///
/// Fetches the package.lua manifest from the zuur binary repository via tofu.http,
/// extracts the version field with a light-parse scan, and checks it against a
/// dependency constraint using tofu.vercmp.satisfies.
///
/// Memoization: Results are cached per package name at module level for the
/// lifetime of one command run. Tests must not rely on cross-test state;
/// each test case uses a fresh or distinct package name.
module tofu.binary;
import tofu.types; // BinaryCheckResult, DepConstraint, DepOp
import tofu.http; // get, HttpException
import tofu.config; // Config, binaryManifestUrl
import tofu.vercmp; // satisfies
import tofu.log; // logDetail, logInfo
import std.algorithm.searching : canFind;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown when the version field cannot be determined from a
/// binary package's manifest.
class BinaryException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Module-level memoization cache
// ────────────────────────────────────────────────────────────
/// Cache of manifest fetch results keyed by package name.
/// Shared across all calls within a single command run.
/// Tests must NOT rely on cross-test state.
private BinaryCheckResult[string] _versionCache;
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
/// Light-parse the version field from a Lua package.lua manifest.
/// Uses a simple scan for `version = "..."` — package.lua manifests
/// are flat key-value tables; a full Lua parser is overkill for
/// version extraction.
private @safe string extractVersion(string content, string name)
{
size_t idx = 0;
// Find "version" keyword with word-boundary checks
while (idx + 7 <= content.length)
{
if (content[idx .. idx + 7] == "version")
{
// Validate preceding char (word boundary)
bool validBefore = (idx == 0);
if (!validBefore)
{
char c = content[idx - 1];
validBefore = (c == ' ' || c == '\t' || c == '\n' || c == '\r'
|| c == '{' || c == ',' || c == ';');
}
// Validate following char (word boundary)
bool validAfter = (idx + 7 >= content.length);
if (!validAfter)
{
char c = content[idx + 7];
validAfter = (c == ' ' || c == '\t' || c == '=');
}
if (validBefore && validAfter)
break;
}
idx++;
}
if (idx + 7 > content.length)
{
throw new BinaryException(
"cannot determine version for binary package '" ~ name ~ "'");
}
idx += 7; // skip "version"
// Skip whitespace before '='
while (idx < content.length && (content[idx] == ' ' || content[idx] == '\t'))
idx++;
// Expect '='
if (idx >= content.length || content[idx] != '=')
{
throw new BinaryException(
"cannot determine version for binary package '" ~ name ~ "'");
}
idx++;
// Skip whitespace after '='
while (idx < content.length && (content[idx] == ' ' || content[idx] == '\t'))
idx++;
// Expect opening quote
if (idx >= content.length || content[idx] != '"')
{
throw new BinaryException(
"cannot determine version for binary package '" ~ name ~ "'");
}
idx++;
// Read version value until closing quote
size_t verStart = idx;
while (idx < content.length && content[idx] != '"')
idx++;
if (idx >= content.length)
{
throw new BinaryException(
"cannot determine version for binary package '" ~ name ~ "'");
}
auto ver = content[verStart .. idx];
if (ver.length == 0)
{
throw new BinaryException(
"cannot determine version for binary package '" ~ name ~ "'");
}
return ver;
}
/// Format a dep constraint for human-readable log messages.
private @safe string constraintToString(DepConstraint c)
{
final switch (c.op)
{
case DepOp.none:
return c.name;
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
// ────────────────────────────────────────────────────────────
/// Check a binary package's version against a dependency constraint.
///
/// Flow:
/// 1. Check module-level cache for previously fetched manifest.
/// 2. Fetch `cfg.binaryManifestUrl(name)` via `tofu.http.get`.
/// 3. On HTTP 404 → return BinaryCheckResult(exists:false) —
/// treat as recipe-only, no binary available.
/// 4. On any other HttpException → rethrow (network/timeout errors
/// must propagate, not be silently swallowed).
/// 5. Parse the package.lua to extract `version` via light regex scan.
/// If version not found → throw BinaryException.
/// 6. Check `tofu.vercmp.satisfies(version, constraint)`.
/// 7. Cache raw result (exists + ver) for subsequent calls; satisfaction
/// is recomputed against each caller's constraint on cache hit.
///
/// Returns:
/// BinaryCheckResult with exists, version string, and satisfaction flag.
@safe BinaryCheckResult checkBinaryVersion(string name, DepConstraint constraint,
Config cfg)
{
// 1. Check cache — recompute satisfaction against caller's constraint
if (auto cached = name in _versionCache)
{
bool satisfied = cached.exists && satisfies(cached.ver, constraint);
return BinaryCheckResult(cached.exists, cached.ver, satisfied);
}
logDetail("checking binary %s ...", name);
// 2. Fetch the package.lua manifest
string manifestContent;
try
{
manifestContent = get(cfg.binaryManifestUrl(name));
}
catch (HttpException e)
{
// 3. HTTP 404 → binary doesn't exist (recipe-only)
if (e.msg.canFind("404"))
{
auto result = BinaryCheckResult(false, "", false);
_versionCache[name] = result;
return result;
}
// 4. Any other HTTP error → rethrow (network problems must propagate)
throw e;
}
// 5. Extract version from manifest (local name 'ver' — 'version' is a D keyword)
auto ver = extractVersion(manifestContent, name);
// 6. Check satisfaction
bool satisfied = satisfies(ver, constraint);
// 7. Log result
if (satisfied)
logInfo("binary %s-%s satisfies %s", name, ver, constraintToString(constraint));
else
logInfo("binary %s-%s does not satisfy %s", name, ver, constraintToString(constraint));
// 8. Cache and return
auto result = BinaryCheckResult(true, ver, satisfied);
_versionCache[name] = result;
return result;
}
// ────────────────────────────────────────────────────────────
// Unittests — local one-shot HTTP server (same pattern as http.d)
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.socket;
import std.concurrency;
import std.string;
import std.format;
/// Spawn a one-shot TCP server that sends `response` to the
/// first connecting client then exits.
private static void oneShotResponder(shared TcpSocket listener,
string response) @trusted
{
try
{
auto sock = (cast() listener).accept();
// drain the request
ubyte[8192] buf = void;
sock.receive(buf[]);
sock.send(cast(immutable(ubyte)[]) response);
sock.close();
}
catch (Throwable) {}
}
/// Bind a listener on an ephemeral port, spawn a responder,
/// and return the URL.
private static auto bindAndSpawn(string response) @trusted
{
auto listener = new TcpSocket();
listener.bind(new InternetAddress("127.0.0.1",
InternetAddress.PORT_ANY));
listener.listen(1);
auto port = listener.localAddress().toPortString();
auto url = "http://127.0.0.1:" ~ port ~ "/";
spawn(&oneShotResponder, cast(shared) listener, response);
return url;
}
/// Build a minimal HTTP response string.
private static string httpResponse(int code, string reason, string body) @trusted
{
return format(
"HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s",
code, reason, body.length, body);
}
/// Create a Config that points zuurUrl at the given base URL.
private @safe Config testConfig(string baseUrl)
{
Config cfg;
cfg.zuurUrl = baseUrl;
cfg.cacheDir = "";
return cfg;
}
}
// ── Test (1): binary exists, version 2.1.0, constraint ge 2.0 → satisfies ──
@safe unittest
{
auto manifest = `return { name = "firefox", version = "2.1.0", summary = "Browser" }`;
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
auto cfg = testConfig(baseUrl);
auto constraint = DepConstraint.parse("firefox>=2.0");
auto result = checkBinaryVersion("firefox", constraint, cfg);
assert(result.exists, "binary should exist");
assert(result.ver == "2.1.0", "version should be 2.1.0, got: " ~ result.ver);
assert(result.satisfies, "2.1.0 should satisfy >=2.0");
}
// ── Test (2): binary exists, version 1.9, constraint ge 2.0 → !satisfies ──
@safe unittest
{
auto manifest = `return { name = "libfoo", version = "1.9", summary = "Library" }`;
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
auto cfg = testConfig(baseUrl);
auto constraint = DepConstraint.parse("libfoo>=2.0");
auto result = checkBinaryVersion("libfoo", constraint, cfg);
assert(result.exists, "binary should exist");
assert(result.ver == "1.9", "version should be 1.9, got: " ~ result.ver);
assert(!result.satisfies, "1.9 should NOT satisfy >=2.0");
}
// ── Test (3): 404 → exists:false, ver:"", satisfies:false ──
@safe unittest
{
auto baseUrl = bindAndSpawn(httpResponse(404, "Not Found", "gone"));
auto cfg = testConfig(baseUrl);
auto constraint = DepConstraint.parse("noexist>=1.0");
auto result = checkBinaryVersion("noexist", constraint, cfg);
assert(!result.exists, "binary should not exist for 404");
assert(result.ver == "", "version should be empty for 404");
assert(!result.satisfies, "non-existent binary should not satisfy");
}
// ── Test (4): 500 error → rethrows HttpException (not swallowed) ──
@safe unittest
{
auto baseUrl = bindAndSpawn(httpResponse(500, "Internal Server Error", "boom"));
auto cfg = testConfig(baseUrl);
bool caught = false;
try
{
auto constraint = DepConstraint.parse("brokenpkg>=1.0");
checkBinaryVersion("brokenpkg", constraint, cfg);
assert(false, "Expected HttpException for 500");
}
catch (HttpException e)
{
caught = true;
assert(e.msg.canFind("500"),
"Message should contain '500', got: " ~ e.msg);
}
assert(caught, "Should have thrown HttpException");
}
// ── Test (5): manifest without version field → BinaryException ──
@safe unittest
{
auto manifest = `return { name = "badpkg", summary = "no version field" }`;
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
auto cfg = testConfig(baseUrl);
bool caught = false;
try
{
auto constraint = DepConstraint.parse("badpkg>=1.0");
checkBinaryVersion("badpkg", constraint, cfg);
assert(false, "Expected BinaryException");
}
catch (BinaryException e)
{
caught = true;
assert(e.msg.canFind("cannot determine version"),
"Message should indicate missing version, got: " ~ e.msg);
}
assert(caught, "Should have thrown BinaryException");
}
// ── Test (6): version "2.1.0" with unconstrained dep (op none) → satisfies ──
@safe unittest
{
auto manifest = `return { name = "testpkg", version = "2.1.0", summary = "Test" }`;
auto baseUrl = bindAndSpawn(httpResponse(200, "OK", manifest));
auto cfg = testConfig(baseUrl);
auto constraint = DepConstraint.parse("testpkg");
assert(constraint.op == DepOp.none, "constraint should be unconstrained");
auto result = checkBinaryVersion("testpkg", constraint, cfg);
assert(result.exists, "binary should exist");
assert(result.ver == "2.1.0", "version should be 2.1.0, got: " ~ result.ver);
assert(result.satisfies, "unconstrained dep should always satisfy");
}
+734
View File
@@ -0,0 +1,734 @@
/// tofu build — invoke zeta-makepkg as a subprocess with conditional --force.
///
/// Streams stdout/stderr to terminal in real time. Captures the last 20 lines
/// of stderr for error reporting (tee approach: piped stderr → forward + buffer).
module tofu.build;
import tofu.config : Config;
import tofu.types : BuildPlan, BuildResult, BuildFailure, Source;
import tofu.log : logStep, logOk, logError, logInfo;
import std.process : pipeProcess, Redirect, ProcessException, Pid, wait;
import std.file : exists, mkdirRecurse;
import std.path : baseName;
import std.string : indexOf, strip, splitLines, join, toStringz;
import std.conv : to, octal;
import std.stdio : stderr;
import std.exception : basicExceptionCtors;
import core.thread : Thread;
version (unittest) {
import std.file : write, tempDir, remove, rmdirRecurse, readText;
import std.path : buildPath;
import std.string : format;
import core.sys.posix.sys.stat : chmod;
}
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown on build failures: missing binary, non-zero exit, missing output.
class BuildException : Exception {
mixin basicExceptionCtors;
/// True when the error is "zeta-makepkg not found" (→ exit code 7,
/// not 4). Set by the two throw sites below.
bool toolMissing = false;
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow {
super(message, file, line);
}
}
// ─── Private helpers ─────────────────────────────────────────────────────────
/// Minimal ring buffer for stderr capture (up to `cap` lines).
/// Callers MUST ensure happens-before via Thread.join() before calling get().
private final class StderrRing {
string[] _lines;
size_t cap;
this(size_t n) @safe nothrow {
cap = n;
}
void add(string line) @trusted nothrow {
_lines ~= line;
if (_lines.length > cap)
_lines = _lines[1 .. $]; // drop oldest
}
string[] get() @trusted nothrow {
return _lines.dup;
}
}
/// Run zeta-makepkg through pipeProcess, tee stderr, return exit code + last 20
/// stderr lines in `last20`. stdout inherits the parent terminal.
///
/// The reader thread runs `stderrFile.byLine` and both buffers the line and
/// writes it to the real `stderr`. `join()` guarantees the buffer is filled
/// before this function returns.
private @trusted
int execMakepkg(const string[] cmd, out string[] last20) {
auto pp = pipeProcess(cmd, Redirect.stderr);
auto ring = new StderrRing(20);
// Forward-and-capture thread — reads piped stderr line by line.
auto reader = new Thread(delegate () {
try {
auto f = pp.stderr;
foreach (line; f.byLine()) {
// byLine returns char[] with terminator; idup for safe storage
string s = line.idup;
ring.add(s);
// Write to parent stderr — tee
stderr.write(line);
}
} catch (Exception) {
// Pipe closed / broken — safe to ignore.
}
});
reader.start();
auto code = wait(pp.pid);
reader.join();
last20 = ring.get();
return code;
}
/// Derive package name from recipe filename: strip directory and .recipe suffix.
/// "hello.recipe" → "hello", "/path/to/pkg.recipe" → "pkg".
private @safe string pkgNameFromPath(string recipePath) {
auto f = baseName(recipePath);
auto pos = f.indexOf(".recipe");
if (pos > 0)
return f[0 .. pos];
// No .recipe suffix — use whole filename (minus any other extension)
auto dot = f.indexOf('.');
if (dot > 0)
return f[0 .. dot];
return f;
}
// ─── Public API ──────────────────────────────────────────────────────────────
/// Invoke zeta-makepkg to build a recipe into `outputDir`.
///
/// The `--repo` flag is passed as `file://<outputDir>` (NOT a hardcoded
/// remote URL): zeta-makepkg bakes this into every generated package.lua
/// as `url = "file://<outputDir>/packages/<name>/<name>-<ver>.tar.gz"`.
/// Because the tarball is produced into that very directory, Zeta's
/// fetch.get can install straight from the local build cache via plain
/// file copy — installing with `zeta -LocalProvide` works offline and
/// never 404s against a remote fileserver. If a user later publishes a
/// package to ZUUR, the manifest is regenerated with the real repo URL
/// at publish time; tofu's local builds always point at the cache.
///
/// Parameters:
/// recipePath = path to the .recipe file
/// outputDir = directory where packages/<name>/package.lua is produced
/// jobs = value for -j<N> flag (parallel build jobs)
/// force = if true, pass --force (overwrite existing package)
/// cfg = tofu config (used for zetaToolchainPath resolution)
///
/// Returns:
/// Absolute path to the produced package.lua on exit code 0.
///
/// Throws:
/// BuildException when the binary is missing, the build exits non-zero,
/// or the expected package.lua is not found after a successful exit.
string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
Config cfg) @safe {
// ── 1. Resolve zeta-makepkg binary ───────────────────────────────────────
string zetaMakepkg;
if (cfg.zetaToolchainPath.length > 0) {
zetaMakepkg = cfg.zetaToolchainPath;
if (!() @trusted { return exists(zetaMakepkg); }()) {
auto be = new BuildException(
"zeta-makepkg not found. Install zeta-toolchain or set "
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
be.toolMissing = true;
throw be;
}
} else {
zetaMakepkg = "zeta-makepkg";
}
// ── 2. Ensure output directory exists ────────────────────────────────────
() @trusted { mkdirRecurse(outputDir); }();
// ── 3. Derive package name ───────────────────────────────────────────────
string pkgName = pkgNameFromPath(recipePath);
// ── 4. Build command-line arguments ─────────────────────────────────────
string[] cmd = [
zetaMakepkg,
recipePath,
"--output", outputDir,
"-j" ~ to!string(jobs),
"--no-index",
"--repo", "file://" ~ outputDir,
];
if (force)
cmd ~= "--force";
// ── 5. Execute ──────────────────────────────────────────────────────────
string[] last20Lines;
int exitCode;
try {
() @trusted {
exitCode = execMakepkg(cmd, last20Lines);
}();
} catch (ProcessException e) {
// Binary not found on PATH (or exec failed)
if (cfg.zetaToolchainPath.length == 0
&& e.msg.indexOf("not found") >= 0) {
auto be = new BuildException(
"zeta-makepkg not found. Install zeta-toolchain or set "
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
be.toolMissing = true;
throw be;
}
throw new BuildException(
"failed to execute zeta-makepkg: " ~ e.msg);
}
// ── 6. Check result ─────────────────────────────────────────────────────
if (exitCode != 0) {
string errDetail;
if (last20Lines.length > 0)
errDetail = last20Lines.join("\n");
else
errDetail = "(no stderr output)";
throw new BuildException(
"build failed for " ~ pkgName ~ ":\n" ~ errDetail);
}
// ── 7. Verify package.lua exists ─────────────────────────────────────────
auto pkgPath = outputDir ~ "/packages/" ~ pkgName ~ "/package.lua";
if (!() @trusted { return exists(pkgPath); }())
throw new BuildException(
"zeta-makepkg reported success but package.lua not found at "
~ pkgPath);
return pkgPath;
}
/// Build all packages in plan order (deps-first). Sequential, fail-fast.
///
/// Parameters:
/// plan = ordered BuildPlan (caller ensures deps-first topological order)
/// cfg = tofu configuration
/// force = if true, pass --force to zeta-makepkg (overwrite existing)
///
/// Returns:
/// BuildResult with succeeded/failed lists. On first failure, returns
/// immediately — remaining packages are NOT attempted.
@safe
BuildResult buildAll(BuildPlan plan, Config cfg, bool force = false) {
BuildResult result;
auto entries = plan.order();
// Empty plan → no-op
if (entries.length == 0) {
logInfo("nothing to build");
return result;
}
size_t total = entries.length;
size_t idx = 0;
foreach (entry; entries) {
idx++;
// Pre-check: recipe file must exist
if (!() @trusted { return exists(entry.recipePath); }()) {
auto reason = "recipe not found at " ~ entry.recipePath;
logError("build failed for %s: %s", entry.name, reason);
result.failed ~= BuildFailure(entry.name, reason);
return result;
}
// Separator
logStep("──── building %s (%d/%d) ────", entry.name, idx, total);
// Skip-if-exists: check for pre-built output
auto pkgPath = cfg.builtDir() ~ "/packages/" ~ entry.name ~ "/package.lua";
if (!force && () @trusted { return exists(pkgPath); }()) {
logInfo("%s already built, skipping", entry.name);
result.succeeded ~= entry.name;
continue;
}
// Execute build
try {
runMakepkg(entry.recipePath, cfg.builtDir(), cfg.defaultJobs, force, cfg);
result.succeeded ~= entry.name;
logOk("%s", entry.name);
} catch (BuildException e) {
logError("build failed for %s: %s", entry.name, e.msg);
result.failed ~= BuildFailure(entry.name, e.msg);
return result; // halt immediately
}
}
return result;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
version (unittest) {
import std.process : thisProcessID;
/// Write text to a file (trusted).
private @trusted void sWrite(string path, string content) {
write(path, content);
}
/// Remove a directory tree, ignoring errors.
private @trusted void sRmdirRecurse(string path) {
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a unique temp directory path for a test and create it.
private @trusted string testTempDir(string suffix) {
auto path = buildPath(tempDir, "tofu-test-build-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
/// Create a fake zeta-makepkg shell script that:
/// - parses --output <dir> from its args
/// - derives the package name from the first argument (recipe path)
/// - creates packages/<name>/package.lua
/// - echoes to stderr for output testing
/// - writes all args to ARGS_FILE if set (for --force assertion tests)
/// - exits with code from EXIT_CODE if set (default 0)
private string makeFakeMakepkg(string dir, string name,
string exitCode = "0", string argsFile = "") @trusted {
auto path = buildPath(dir, name);
string script = "#!/bin/bash\nset -e\n";
script ~= `RECIPE_PATH="$1"
RECIPE_FILE=$(basename "$RECIPE_PATH")
PKG_NAME="${RECIPE_FILE%.recipe}"
`;
if (argsFile.length > 0) {
// Args are saved in the unconditional block below (ORIGINAL_ARGS).
}
script ~= `
# Parse --output
OUTPUT_DIR="."
ORIGINAL_ARGS="$@"
while [[ $# -gt 0 ]]; do
case "$1" in
--output) OUTPUT_DIR="$2"; shift 2;;
*) shift;;
esac
done
`;
if (argsFile.length > 0) {
script ~= "echo \"$ORIGINAL_ARGS\" > '" ~ argsFile ~ "'\n";
}
script ~= `
# Echo some stderr output (simulate build log)
echo "zeta-makepkg: building ${PKG_NAME}..." >&2
echo "zeta-makepkg: configure phase" >&2
echo "zeta-makepkg: build phase" >&2
echo "zeta-makepkg: install phase" >&2
# Create output
mkdir -p "${OUTPUT_DIR}/packages/${PKG_NAME}"
echo "return { version = '1.0.0' }" > "${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua"
exit ` ~ exitCode ~ "\n";
write(path, script);
chmod(toStringz(path), octal!755);
return path;
}
// ── Test (1): valid recipe → returns package.lua path, exit 0 ──────────
@safe unittest {
auto tmp = testTempDir("valid");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.zuurUrl = "https://files.spectoria.dev/zuur";
auto result = runMakepkg(
buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(result == buildPath(outDir, "packages", "hello", "package.lua"),
"Expected packages/hello/package.lua, got: " ~ result);
}
// ── Test (2): fake script exits 1 with stderr → BuildException ─────────
@safe unittest {
auto tmp = testTempDir("fail");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-fail", "1");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException");
} catch (BuildException e) {
assert(e.msg.indexOf("build failed for hello") >= 0,
"Expected 'build failed for hello', got: " ~ e.msg);
}
}
// ── Test (3): force=true → fake script receives --force ─────────────────
@safe unittest {
auto tmp = testTempDir("force-true");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-force", "0", argsFile);
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, true, cfg);
auto rawArgs = () @trusted {
try { return readText(argsFile); } catch (Exception) { return ""; }
}();
assert(rawArgs.indexOf("--force") >= 0,
"Expected --force in args, got: " ~ rawArgs);
}
// ── Test (4): force=false → fake script receives no --force ─────────────
@safe unittest {
auto tmp = testTempDir("force-false");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-noforce", "0", argsFile);
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
auto rawArgs = () @trusted {
try { return readText(argsFile); } catch (Exception) { return ""; }
}();
assert(rawArgs.indexOf("--force") < 0,
"Expected no --force in args, got: " ~ rawArgs);
}
// ── Test (5): zeta-makepkg not found (nonexistent path) ─────────────────
@safe unittest {
auto tmp = testTempDir("notfound");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto outDir = buildPath(tmp, "output");
() @trusted { mkdirRecurse(outDir); }();
Config cfg;
cfg.zetaToolchainPath = buildPath(tmp, "nonexistent-binary");
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException for missing binary");
} catch (BuildException e) {
assert(e.msg.indexOf("zeta-makepkg not found") >= 0,
"Expected 'zeta-makepkg not found', got: " ~ e.msg);
}
}
// ── Test (6): exits 0 but doesn't create package.lua → BuildException ───
@safe unittest {
auto tmp = testTempDir("missing-pkg");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
// Create a fake that exits 0 but creates no package.lua
auto fakePath = buildPath(tmp, "fake-nopkg");
string noPkgScript = "#!/bin/bash\necho 'done' >&2\nexit 0\n";
() @trusted {
write(fakePath, noPkgScript);
chmod(toStringz(fakePath), octal!755);
}();
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakePath;
try {
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
assert(false, "Expected BuildException for missing package.lua");
} catch (BuildException e) {
assert(e.msg.indexOf("package.lua not found") >= 0,
"Expected 'package.lua not found', got: " ~ e.msg);
}
}
// ── Test (7): pkgNameFromPath edge cases ────────────────────────────────
@safe unittest {
assert(pkgNameFromPath("hello.recipe") == "hello");
assert(pkgNameFromPath("/path/to/pkg.recipe") == "pkg");
assert(pkgNameFromPath("a.b.recipe") == "a.b"); // last .recipe
assert(pkgNameFromPath("norecipe") == "norecipe"); // no dot at all
assert(pkgNameFromPath("/tmp/.config/build.sh") == "build");
}
// ── Test (8): buildAll — plan [depA, depB, target] all valid → all 3 succeed ─
@safe unittest {
auto tmp = testTempDir("buildall-valid");
scope (exit) sRmdirRecurse(tmp);
// Create recipes for all 3
foreach (n; ["depA", "depB", "target"]) {
sWrite(buildPath(tmp, n ~ ".recipe"),
"return { name = '" ~ n ~ "', version = '1.0.0' }");
}
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
// Build plan — deps-first order (caller guarantees this)
BuildPlan plan;
plan.add("depA", buildPath(tmp, "depA.recipe"), Source.recipe);
plan.add("depB", buildPath(tmp, "depB.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "target.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 3,
format("Expected 3 succeeded, got %d: %s",
result.succeeded.length, result.succeeded));
assert(result.succeeded[0] == "depA");
assert(result.succeeded[1] == "depB");
assert(result.succeeded[2] == "target");
assert(result.failed.length == 0);
// Verify outputs were created
foreach (n; ["depA", "depB", "target"]) {
auto pkg = () @trusted {
return exists(
buildPath(tmp, "built", "packages", n, "package.lua"));
}();
assert(pkg, "Expected package.lua for " ~ n);
}
}
// ── Test (9): buildAll — first recipe fails → rest skipped ──────────────
@safe unittest {
auto tmp = testTempDir("buildall-failfast");
scope (exit) sRmdirRecurse(tmp);
foreach (n; ["depA", "depB", "target"]) {
sWrite(buildPath(tmp, n ~ ".recipe"),
"return { name = '" ~ n ~ "', version = '1.0.0' }");
}
// Fake that always exits 1 (fails every invocation)
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-fail", "1");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("depA", buildPath(tmp, "depA.recipe"), Source.recipe);
plan.add("depB", buildPath(tmp, "depB.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "target.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 0,
"Expected 0 succeeded, got: " ~ result.succeeded.to!string);
assert(result.failed.length == 1,
"Expected 1 failed (only depA), got " ~ result.failed.length.to!string);
assert(result.failed[0].name == "depA",
"Expected depA to fail, got: " ~ result.failed[0].name);
assert(result.failed[0].reason.indexOf("build failed for depA") >= 0,
"Expected 'build failed for depA' in reason, got: "
~ result.failed[0].reason);
}
// ── Test (10): buildAll — empty plan → "nothing to build" ────────────────
@safe unittest {
Config cfg;
cfg.cacheDir = "/tmp/dummy";
BuildPlan plan; // empty
auto result = buildAll(plan, cfg);
assert(result.succeeded.length == 0);
assert(result.failed.length == 0);
}
// ── Test (11): buildAll — existing output + force=false → skipped ────────
@safe unittest {
auto tmp = testTempDir("buildall-skip");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "pkg.recipe"),
"return { name = 'pkg', version = '1.0.0' }");
// Pre-create the output
auto pkgLuaDir = buildPath(tmp, "built", "packages", "pkg");
() @trusted { mkdirRecurse(pkgLuaDir); }();
sWrite(buildPath(pkgLuaDir, "package.lua"),
"return { version = '1.0.0' }");
// Fake with args file as invocation counter
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg", "0", argsFile);
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("pkg", buildPath(tmp, "pkg.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, false);
assert(result.succeeded.length == 1);
assert(result.succeeded[0] == "pkg");
assert(result.failed.length == 0);
// Fake script must NOT have been invoked
auto argsExist = () @trusted {
try { return exists(argsFile); } catch (Exception) { return false; }
}();
assert(!argsExist, "Fake makepkg was invoked but should have been skipped");
}
// ── Test (12): buildAll — existing output + force=true → rebuilt ─────────
@safe unittest {
auto tmp = testTempDir("buildall-force");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "pkg.recipe"),
"return { name = 'pkg', version = '1.0.0' }");
// Pre-create the output
auto pkgLuaDir = buildPath(tmp, "built", "packages", "pkg");
() @trusted { mkdirRecurse(pkgLuaDir); }();
sWrite(buildPath(pkgLuaDir, "package.lua"),
"return { version = '1.0.0' }");
// Fake with args file as invocation counter
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg", "0", argsFile);
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("pkg", buildPath(tmp, "pkg.recipe"), Source.recipe);
auto result = buildAll(plan, cfg, true);
assert(result.succeeded.length == 1);
assert(result.succeeded[0] == "pkg");
assert(result.failed.length == 0);
// Fake script MUST have been invoked
auto argsExist = () @trusted {
try { return exists(argsFile); } catch (Exception) { return false; }
}();
assert(argsExist, "Fake makepkg was NOT invoked but should have been (force=true)");
}
// ── Test (13): buildAll — missing recipe file → fail-fast ────────────────
@safe unittest {
auto tmp = testTempDir("buildall-norecipe");
scope (exit) sRmdirRecurse(tmp);
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
cfg.cacheDir = tmp;
BuildPlan plan;
plan.add("noexist", buildPath(tmp, "nonexistent.recipe"), Source.recipe);
plan.add("target", buildPath(tmp, "will-not-build.recipe"), Source.recipe);
auto result = buildAll(plan, cfg);
assert(result.succeeded.length == 0);
assert(result.failed.length == 1,
"Expected 1 failed, got " ~ result.failed.length.to!string);
assert(result.failed[0].name == "noexist");
assert(result.failed[0].reason.indexOf("recipe not found") >= 0,
"Expected 'recipe not found' in reason, got: "
~ result.failed[0].reason);
}
// ── Test (14): fake zeta-makepkg receives --repo file://<outputDir> ─────
@safe unittest {
auto tmp = testTempDir("repo-flag");
scope (exit) sRmdirRecurse(tmp);
sWrite(buildPath(tmp, "hello.recipe"),
"return { name = 'hello', version = '1.0.0' }");
auto argsFile = buildPath(tmp, "args.txt");
auto fakeBin = makeFakeMakepkg(tmp, "fake-makepkg-repo", "0", argsFile);
auto outDir = buildPath(tmp, "output");
Config cfg;
cfg.zetaToolchainPath = fakeBin;
runMakepkg(buildPath(tmp, "hello.recipe"), outDir, 4, false, cfg);
auto rawArgs = () @trusted {
try { return readText(argsFile); } catch (Exception) { return ""; }
}();
auto expectedRepo = "file://" ~ outDir;
assert(rawArgs.indexOf("--repo") >= 0,
"Expected --repo flag in args, got: " ~ rawArgs);
assert(rawArgs.indexOf(expectedRepo) >= 0,
"Expected --repo " ~ expectedRepo ~ " in args, got: " ~ rawArgs);
assert(rawArgs.indexOf("https://files.spectoria.dev/zuur/binary") < 0,
"Hardcoded remote repo URL must not be passed, got: " ~ rawArgs);
}
}
+435
View File
@@ -0,0 +1,435 @@
/// tofu.cache — Recipe cache with index-version-based invalidation.
///
/// Cached recipes get a small JSON manifest (`.tofu-cache.json`) recording
/// the index version that was current at fetch time. Callers compare the
/// stored version against the current index version to decide whether the
/// recipe needs re-fetching (stale).
///
/// All public API is `@safe`. Filesystem and JSON parsing operations are
/// isolated in small `@trusted` helpers following the project convention
/// established in `tofu.config`, `tofu.http`, and `tofu.fetch`.
///
/// Atomic writes: the manifest is written to a `.tmp` file then renamed,
/// preventing interrupted writes from producing a corrupted manifest.
///
/// Directory removal: `rmdirRecurse` (std.file, available since D 2.104)
/// is used for recursive directory cleanup. On DMD 2.112 this is present.
module tofu.cache;
import tofu.types : CacheManifest;
import tofu.config : Config, load;
import tofu.vercmp : compare;
import tofu.log : logWarn;
import std.file;
import std.path : buildPath;
import std.json : JSONValue, JSONType, parseJSON;
import std.datetime : Clock;
import std.conv : to;
import std.format : format;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown on fatal cache errors. Corrupted caches are handled
/// gracefully by returning stale=true — this exception is reserved
/// for truly unrecoverable cases.
class CacheException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Trusted filesystem / JSON helpers
// ────────────────────────────────────────────────────────────
@trusted
bool fExists(string p) { return exists(p); }
@trusted
void fMkdirRecurse(string p) { mkdirRecurse(p); }
@trusted
void fRemove(string p) { remove(p); }
@trusted
void fRmdirRecurse(string p) { rmdirRecurse(p); }
@trusted
void fRename(string from, string to) { rename(from, to); }
@trusted
void fWrite(string p, string c) { write(p, c); }
@trusted
string fReadText(string p) { return readText(p); }
@trusted
JSONValue fParseJSON(string c) { return parseJSON(c); }
// ────────────────────────────────────────────────────────────
// cacheRecipe — write (or update) the recipe cache manifest
// ────────────────────────────────────────────────────────────
/// Store a cache manifest for `name` recording the current `indexVersion`.
///
/// The manifest is written atomically: first to a `.tmp` file, then
/// renamed to `.tofu-cache.json`. This prevents corrupted manifests
/// from interrupted writes.
///
/// Params:
/// name = package name
/// indexVersion = version string from the current index (e.g. "2.1.0")
/// cfg = resolved configuration providing cache-directory paths
void cacheRecipe(string name, string indexVersion, Config cfg) @safe
{
auto dir = cfg.recipesCacheDir(name);
auto manifestFile = buildPath(dir, ".tofu-cache.json");
auto tmpFile = manifestFile ~ ".tmp";
if (!fExists(dir))
fMkdirRecurse(dir);
long fetchedAt = Clock.currTime().toUnixTime();
// Build a JSON object manually via formatted string rather than
// relying on std.json serializers (which have API variations across
// Phobos versions). Three-field object: name, ver, fetchedAt.
// Package names and version strings are simple identifiers that
// do not require JSON escaping.
auto jsonStr = format(
q"EOS
{"name":"%s","ver":"%s","fetchedAt":%d}
EOS", name, indexVersion, fetchedAt);
fWrite(tmpFile, jsonStr);
if (fExists(manifestFile))
fRemove(manifestFile);
fRename(tmpFile, manifestFile);
}
// ────────────────────────────────────────────────────────────
// isRecipeStale — check whether the recipe needs re-fetching
// ────────────────────────────────────────────────────────────
/// Returns `true` when the recipe cache for `name` is missing,
/// corrupted, or its stored index version differs from `indexVersion`.
///
/// Version comparison uses `tofu.vercmp.compare` (RPM-style semantic
/// comparison) rather than exact string matching. Never throws —
/// read failures and parse errors are logged via `logWarn` and
/// treated as stale.
///
/// Params:
/// name = package name
/// indexVersion = current index version to compare against
/// cfg = resolved configuration
///
/// Returns:
/// `true` if the recipe should be re-fetched, `false` if the cache
/// is current.
bool isRecipeStale(string name, string indexVersion, Config cfg) @safe
{
auto manifestFile = buildPath(
cfg.recipesCacheDir(name), ".tofu-cache.json");
if (!fExists(manifestFile))
return true;
string content;
try
{
content = fReadText(manifestFile);
}
catch (Exception e)
{
logWarn("cannot read cache for %s: %s", name, e.msg);
return true;
}
string cachedVer;
try
{
auto jv = fParseJSON(content);
cachedVer = jv["ver"].str;
}
catch (Exception e)
{
logWarn("corrupted cache for %s: %s", name, e.msg);
return true;
}
return compare(cachedVer, indexVersion) != 0;
}
// ────────────────────────────────────────────────────────────
// Cleanup helpers
// ────────────────────────────────────────────────────────────
/// Remove the recipe cache directory for `name` (and all cached files).
///
/// Safe to call when the directory does not exist — silently returns.
void cleanRecipeCache(string name, Config cfg) @safe
{
auto dir = cfg.recipesCacheDir(name);
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clean recipe cache for %s: %s",
name, e.msg);
}
}
}
/// Remove the entire built-packages directory tree.
///
/// Safe to call when the directory does not exist — silently returns.
void clearBuildCache(Config cfg) @safe
{
auto dir = cfg.builtDir();
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clear build cache: %s", e.msg);
}
}
}
/// Remove ALL recipe caches (`<cacheDir>/recipes/`).
///
/// Useful for `-Scc`-style full clean operations.
void clearRecipeCacheAll(Config cfg) @safe
{
auto dir = buildPath(cfg.cacheDir, "recipes");
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clear all recipe caches: %s", e.msg);
}
}
}
// ════════════════════════════════════════════════════════════
// Unittests — pure file ops, no network required
// ════════════════════════════════════════════════════════════
version (unittest)
{
import std.process : thisProcessID;
/// Build a Config pointing at a unique temp directory for test isolation.
/// Each call returns a Config with a distinct cache dir, preventing
/// tests from stepping on each other.
@safe Config makeTestConfig(string suffix)
{
auto dir = buildPath(
tempDir, "tofu-test-cache-" ~ thisProcessID.to!string ~ "-" ~ suffix);
const string[string] env = ["TOFU_CACHE_DIR": dir];
return load(null, env);
}
/// Recursively remove a test directory, ignoring errors.
@trusted void cleanupTestDir(string dir)
{
try
{
if (exists(dir))
rmdirRecurse(dir);
}
catch (Exception) {}
}
}
// ── Test (1): cacheRecipe then isRecipeStale(same ver) → false ─────
@safe unittest
{
auto cfg = makeTestConfig("t1");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "2.1.0", cfg);
assert(!isRecipeStale("mypkg", "2.1.0", cfg),
"same version should not be stale");
}
// ── Test (2): isRecipeStale(different ver) → true ──────────────────
@safe unittest
{
auto cfg = makeTestConfig("t2");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "1.0", cfg);
assert(isRecipeStale("mypkg", "2.0", cfg),
"different version should be stale");
}
// ── Test (3): missing cache → isRecipeStale → true ─────────────────
@safe unittest
{
auto cfg = makeTestConfig("t3");
scope (exit) cleanupTestDir(cfg.cacheDir);
assert(isRecipeStale("nopkg", "1.0", cfg),
"missing cache should be stale");
}
// ── Test (4): cleanRecipeCache removes dir ─────────────────────────
@safe unittest
{
auto cfg = makeTestConfig("t4");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "1.0", cfg);
auto dir = cfg.recipesCacheDir("mypkg");
// Verify the directory exists after caching
assert(fExists(dir), "cache dir should exist after cacheRecipe");
cleanRecipeCache("mypkg", cfg);
// After cleanRecipeCache, the dir should be gone
assert(!fExists(dir), "cache dir should be removed after cleanRecipeCache");
}
// ── Test (5): clearBuildCache removes built tree ───────────────────
@safe unittest
{
auto cfg = makeTestConfig("t5");
scope (exit) cleanupTestDir(cfg.cacheDir);
auto builtDir = cfg.builtDir();
fMkdirRecurse(builtDir);
// Touch a file so the dir is non-empty
fWrite(buildPath(builtDir, "sentinel"), "x");
assert(fExists(builtDir), "built dir should exist before clear");
clearBuildCache(cfg);
assert(!fExists(builtDir),
"built dir should be removed after clearBuildCache");
}
// ── Test (6): cache file is valid JSON (read back + parse) ─────────
@safe unittest
{
auto cfg = makeTestConfig("t6");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("firefox", "1.0", cfg);
auto manifestFile = buildPath(
cfg.recipesCacheDir("firefox"), ".tofu-cache.json");
assert(fExists(manifestFile), "manifest file should exist");
string content = fReadText(manifestFile);
JSONValue jv = fParseJSON(content);
assert(jv["name"].str == "firefox", "name field mismatch");
assert(jv["ver"].str == "1.0", "ver field mismatch");
assert(jv["fetchedAt"].integer > 0, "fetchedAt should be positive timestamp");
}
// ── Test (7): vercmp-based — semantic equality means NOT stale ─────
@safe unittest
{
auto cfg = makeTestConfig("t7");
scope (exit) cleanupTestDir(cfg.cacheDir);
// Same version string → not stale
cacheRecipe("pkg", "1.0.0", cfg);
assert(!isRecipeStale("pkg", "1.0.0", cfg),
"identical version strings should not be stale");
// Leading zeros are ignored per vercmp: "001" == "1"
cacheRecipe("pkg2", "01.05", cfg);
assert(!isRecipeStale("pkg2", "1.5", cfg),
"leading-zero variants should not be stale per vercmp");
// Different versions ARE stale
cacheRecipe("pkg3", "1.0", cfg);
assert(isRecipeStale("pkg3", "2.0", cfg),
"actually different versions should be stale");
}
// ── Test (8): corrupted cache JSON → isRecipeStale returns true ────
// (never throws, logs a warning)
@safe unittest
{
auto cfg = makeTestConfig("t8");
scope (exit) cleanupTestDir(cfg.cacheDir);
auto dir = cfg.recipesCacheDir("pkg");
if (!fExists(dir))
fMkdirRecurse(dir);
// Write deliberately broken JSON
auto manifestFile = buildPath(dir, ".tofu-cache.json");
fWrite(manifestFile, "this is not json {{{");
// Should not throw — returns true (stale) and logs a warning
bool stale;
try
{
stale = isRecipeStale("pkg", "1.0", cfg);
}
catch (Exception e)
{
assert(false, "isRecipeStale must never throw: " ~ e.msg);
}
assert(stale, "corrupted cache should be considered stale");
}
// ── Extra: clearRecipeCacheAll removes the entire recipes/ tree ────
@safe unittest
{
auto cfg = makeTestConfig("t9");
scope (exit) cleanupTestDir(cfg.cacheDir);
// Cache two different packages
cacheRecipe("pkg-a", "1.0", cfg);
cacheRecipe("pkg-b", "2.0", cfg);
auto recipesDir = buildPath(cfg.cacheDir, "recipes");
assert(fExists(recipesDir), "recipes dir should exist");
clearRecipeCacheAll(cfg);
assert(!fExists(recipesDir),
"recipes dir should be removed after clearRecipeCacheAll");
}
// ── Extra: isRecipeStale with vercmp numeric ordering ──────────────
@safe unittest
{
auto cfg = makeTestConfig("t10");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("pkg", "1.9", cfg);
// vercmp: 1.10 > 1.9, so cached 1.9 != index 1.10 → stale
assert(isRecipeStale("pkg", "1.10", cfg),
"1.9 cached vs 1.10 index should be stale per vercmp");
}
+363
View File
@@ -0,0 +1,363 @@
/// tofu.cli — yay-/paru-style command-line parsing.
///
/// Recognises arch-style operation tokens (`-S`, `-Ss`, `-Syu`, `-R`, `-Si`)
/// plus `-h` / `--help`. Flags (`--noconfirm`, `--dry-run`, `--force`,
/// `-j<N>`) may appear anywhere in the argument vector.
///
/// Manual parsing — no framework dependency. Does NOT combine short flags
/// beyond the documented exact forms (e.g. `-Syu` is one token, not
/// `-S -y -u`).
///
/// Wired by tasks 20–24 (the individual command implementations).
module tofu.cli;
import std.conv : to, ConvException;
import std.string : startsWith, indexOf;
// ─── Types ────────────────────────────────────────────────────────────────────
/// Operation the user requested.
enum Command
{
install, /// -S <pkg>
search, /// -Ss <query>
upgrade, /// -Syu
remove_, /// -R <pkg> (suffixed to avoid keyword clash)
info, /// -Si <pkg>
help, /// -h / --help
}
/// Parsed command-line state returned by `parseArgs`.
struct ParsedArgs
{
Command cmd;
string arg; /// positional argument (package name / search query)
bool noconfirm;
bool dryRun;
bool force;
int jobs = 1;
}
// ─── Exception ────────────────────────────────────────────────────────────────
/// Thrown when argument parsing fails.
class CliException : Exception
{
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow
{
super(message, file, line);
}
}
// ─── Usage text ───────────────────────────────────────────────────────────────
/// Full help text displayed by the `help` command and on parse failures.
const string helpText =
"tofu — ZUUR package manager\n" ~
"Usage: tofu <command> [arg] [flags]\n" ~
"Commands:\n" ~
" -S <pkg> Install package from ZUUR recipes\n" ~
" -Ss <q> Search ZUUR\n" ~
" -Syu Upgrade installed packages\n" ~
" -R <pkg> Remove package\n" ~
" -Si <pkg> Show package info\n" ~
" -h, --help Show this help\n" ~
"Flags:\n" ~
" --noconfirm Skip confirmation prompts\n" ~
" --dry-run Show what would happen without doing it\n" ~
" --force Overwrite existing builds\n" ~
" -j<N> Parallel build jobs\n";
// ─── Helpers ──────────────────────────────────────────────────────────────────
/// Parse a positive integer from `s`. Throws `CliException` if the string
/// is not a valid integer or is ≤ 0.
private int parsePositiveInt(string s) @safe
{
int n;
try
{
n = to!int(s);
}
catch (ConvException)
{
throw new CliException("invalid jobs count: " ~ s ~ " (must be a positive integer)");
}
if (n < 1)
throw new CliException("invalid jobs count: " ~ s ~ " (must be positive, got " ~ s ~ ")");
return n;
}
// ─── Public API ───────────────────────────────────────────────────────────────
@safe:
/// Parse `args` (typically `main`'s argument vector sans argv[0]) into a
/// `ParsedArgs` struct.
///
/// Throws `CliException` on any malformed input. The exception message
/// always includes a usage hint.
ParsedArgs parseArgs(string[] args)
{
Command cmd;
bool cmdSet = false;
string arg;
bool noconfirm = false;
bool dryRun = false;
bool force = false;
int jobs = 1;
/// Whether the chosen command expects a positional argument.
bool cmdTakesArg = false;
/// Number of positional args already consumed (max 1).
int positionalCount = 0;
size_t i = 0;
while (i < args.length)
{
string a = args[i];
// ── flags (can appear anywhere) ──────────────────────────────────
if (a == "--noconfirm")
{
noconfirm = true;
++i;
continue;
}
if (a == "--dry-run")
{
dryRun = true;
++i;
continue;
}
if (a == "--force")
{
force = true;
++i;
continue;
}
if (a == "--help" || a == "-h")
{
cmd = Command.help;
cmdSet = true;
cmdTakesArg = false;
++i;
continue;
}
// ── -j flag (can appear anywhere) ────────────────────────────────
if (a.startsWith("-j"))
{
if (a.length > 2)
{
// -j<N> form
jobs = parsePositiveInt(a[2 .. $]);
}
else
{
// -j <N> form — consume next token
++i;
if (i >= args.length)
throw new CliException(
"missing jobs count after -j (run 'tofu --help')");
jobs = parsePositiveInt(args[i]);
}
++i;
continue;
}
// ── command tokens (only the first one wins) ─────────────────────
if (!cmdSet)
{
if (a == "-Syu")
{
cmd = Command.upgrade;
cmdSet = true;
cmdTakesArg = false;
++i;
continue;
}
if (a == "-Ss")
{
cmd = Command.search;
cmdSet = true;
cmdTakesArg = true;
++i;
continue;
}
if (a == "-Si")
{
cmd = Command.info;
cmdSet = true;
cmdTakesArg = true;
++i;
continue;
}
if (a == "-S")
{
cmd = Command.install;
cmdSet = true;
cmdTakesArg = true;
++i;
continue;
}
if (a == "-R")
{
cmd = Command.remove_;
cmdSet = true;
cmdTakesArg = true;
++i;
continue;
}
// Unknown option before a command was chosen.
throw new CliException(
"unknown option: " ~ a ~ " (run 'tofu --help')");
}
// ── positional argument (command already chosen) ─────────────────
if (cmdTakesArg)
{
if (positionalCount > 0)
throw new CliException(
"too many arguments — expected exactly one (run 'tofu --help')");
arg = a;
++positionalCount;
}
else
{
throw new CliException(
"unexpected argument: " ~ a ~ " (run 'tofu --help')");
}
++i;
}
// ── post-scan checks ─────────────────────────────────────────────────────
if (!cmdSet)
throw new CliException("no command given (run 'tofu --help')");
if (cmdTakesArg && arg.length == 0)
throw new CliException(
"command requires an argument (run 'tofu --help')");
return ParsedArgs(cmd, arg, noconfirm, dryRun, force, jobs);
}
// ─── Unittests ────────────────────────────────────────────────────────────────
@safe:
version (unittest)
{
/// Helper: assert that `parseArgs` throws with a message containing `needle`.
private void assertThrows(string[] args, string needle) @safe
{
try
{
parseArgs(args);
assert(false, "expected CliException containing '" ~ needle ~ "'");
}
catch (CliException e)
{
assert(e.msg.indexOf(needle) >= 0,
"expected message containing '" ~ needle ~ "', got: " ~ e.msg);
}
}
}
/// (1) -S neovim → install, arg=neovim
unittest
{
auto p = parseArgs(["-S", "neovim"]);
assert(p.cmd == Command.install);
assert(p.arg == "neovim");
assert(!p.noconfirm);
assert(!p.dryRun);
assert(!p.force);
assert(p.jobs == 1);
}
/// (2) -Ss editor → search, arg=editor
unittest
{
auto p = parseArgs(["-Ss", "editor"]);
assert(p.cmd == Command.search);
assert(p.arg == "editor");
}
/// (3) -Syu → upgrade, arg=""
unittest
{
auto p = parseArgs(["-Syu"]);
assert(p.cmd == Command.upgrade);
assert(p.arg == "");
}
/// (4) -R neovim → remove
unittest
{
auto p = parseArgs(["-R", "neovim"]);
assert(p.cmd == Command.remove_);
assert(p.arg == "neovim");
}
/// (5) -Si neovim → info
unittest
{
auto p = parseArgs(["-Si", "neovim"]);
assert(p.cmd == Command.info);
assert(p.arg == "neovim");
}
/// (6) --help and -h → help
unittest
{
auto p1 = parseArgs(["--help"]);
assert(p1.cmd == Command.help);
assert(p1.arg == "");
auto p2 = parseArgs(["-h"]);
assert(p2.cmd == Command.help);
assert(p2.arg == "");
}
/// (7) empty → CliException
unittest
{
assertThrows([], "no command given");
}
/// (8) -S neovim --noconfirm --dry-run -j4 → all flags set
unittest
{
auto p = parseArgs(["-S", "neovim", "--noconfirm", "--dry-run", "-j4"]);
assert(p.cmd == Command.install);
assert(p.arg == "neovim");
assert(p.noconfirm);
assert(p.dryRun);
assert(!p.force);
assert(p.jobs == 4);
}
/// (9) -S neovim -j0 → CliException (positive int required)
unittest
{
assertThrows(["-S", "neovim", "-j0"], "must be positive");
}
/// (10) unknown flag -Z → CliException
unittest
{
assertThrows(["-Z"], "unknown option");
}
/// (11) -S neovim extra (too many positionals) → CliException
unittest
{
assertThrows(["-S", "neovim", "extra"], "too many arguments");
}
/// (12) -Ss without query → CliException
unittest
{
assertThrows(["-Ss"], "command requires an argument");
}
+462
View File
@@ -0,0 +1,462 @@
/// tofu.commands.info — -Si package info display.
///
/// Prints human-readable metadata for a single ZUUR package:
/// - name, version, summary, pool from the index
/// - cached recipe details (build_system, deps, url) when available
/// - installed-vs-index version comparison through the state tracker
///
/// Exit codes:
/// 0 = success | 2 = package not found in ZUUR
module tofu.commands.info;
import tofu.types : PackageIndex, Pool, poolToString;
import tofu.index : fetchIndex;
import tofu.config : Config;
import tofu.cli : ParsedArgs;
import tofu.log : logError, logInfo;
import tofu.state : isInstalledByTofu, InstalledPkg;
import tofu.vercmp : compare;
import std.stdio : writeln;
import std.file : exists, readText;
import std.path : buildPath;
import std.string : indexOf;
@safe:
// ────────────────────────────────────────────────────────────
// Light recipe-file scanner helpers
// ────────────────────────────────────────────────────────────
/// Scan a Lua-like recipe file for `key = "value"` and return `value`.
/// Handles `\"` and `\\` escapes. Returns `""` when the key is absent
/// or the value is empty.
private string scanStringField(string content, string key) {
size_t pos = 0;
while (pos < content.length) {
auto kp = content[pos .. $].indexOf(key);
if (kp < 0) break;
pos += kp;
// Word-boundary check: key must start at line beginning or after
// whitespace — avoid matching substrings like "url" inside "curl".
if (pos > 0) {
auto prev = content[pos - 1];
if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r')
{
pos += key.length;
continue;
}
}
pos += key.length;
// Skip whitespace, `=`, whitespace
while (pos < content.length &&
(content[pos] == ' ' || content[pos] == '\t')) pos++;
if (pos >= content.length || content[pos] != '=') continue;
pos++;
while (pos < content.length &&
(content[pos] == ' ' || content[pos] == '\t')) pos++;
if (pos >= content.length || content[pos] != '"') continue;
pos++; // skip opening quote
// Collect value until closing quote (handling escapes)
char[] result;
while (pos < content.length) {
if (content[pos] == '\\' && pos + 1 < content.length) {
pos++;
result ~= content[pos];
pos++;
} else if (content[pos] == '"') {
pos++;
break;
} else if (content[pos] == '\n') {
break; // unexpected newline — stop
} else {
result ~= content[pos];
pos++;
}
}
// @trusted: result is freshly allocated char[], safe to cast
string s;
() @trusted { s = cast(string) result; }();
return s;
}
return "";
}
/// Scan a Lua-like recipe file for `deps = { "v1", "v2", ... }`
/// and return the joined dependency list as `"v1, v2, ..."`.
/// Returns `""` when `deps` is absent or the table is empty.
private string scanDepsArray(string content) {
// indexOf returns ptrdiff_t (-1 for not found); store in a SIGNED type
// so the `pos < 0` guard below actually fires. An unsigned size_t would
// wrap -1 to SIZE_MAX and the guard becomes a no-op, crashing later on
// content[pos .. $] with ArrayIndexError when "deps" is absent.
ptrdiff_t pos = content.indexOf("deps");
if (pos < 0) return "";
// Word-boundary check
if (pos > 0) {
auto prev = content[pos - 1];
if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r')
return "";
}
pos += 4; // skip "deps"
// Whitespace, `=`, whitespace
while (pos < content.length &&
(content[pos] == ' ' || content[pos] == '\t')) pos++;
if (pos >= content.length || content[pos] != '=') return "";
pos++;
while (pos < content.length &&
(content[pos] == ' ' || content[pos] == '\t')) pos++;
if (pos >= content.length || content[pos] != '{') return "";
pos++; // skip `{`
// Collect quoted strings inside { ... }
string[] items;
while (pos < content.length) {
// Skip whitespace, commas, newlines
while (pos < content.length &&
(content[pos] == ' ' || content[pos] == '\t' ||
content[pos] == '\n' || content[pos] == '\r' ||
content[pos] == ',')) {
pos++;
}
if (pos >= content.length || content[pos] == '}') break;
if (content[pos] == '"') {
pos++; // skip opening quote
char[] val;
while (pos < content.length) {
if (content[pos] == '\\' && pos + 1 < content.length) {
pos++;
val ~= content[pos];
pos++;
} else if (content[pos] == '"') {
pos++;
break;
} else if (content[pos] == '\n') {
break;
} else {
val ~= content[pos];
pos++;
}
}
string item;
() @trusted { item = cast(string) val; }();
if (item.length > 0) items ~= item;
} else {
pos++; // skip unexpected char
}
}
if (items.length == 0) return "";
// Join with ", "
string result = items[0];
foreach (i; 1 .. items.length) {
result ~= ", ";
result ~= items[i];
}
return result;
}
// ────────────────────────────────────────────────────────────
// Main command
// ────────────────────────────────────────────────────────────
/// Print package info for `-Si <name>`.
///
/// The `indexFetcher` delegate injects a test double for the index.
/// When `null`, the real `fetchIndex(cfg)` is used.
///
/// Returns exit code: 0 = success, 2 = package not found in ZUUR.
int infoCommand(string pkgName, ParsedArgs flags, Config cfg,
PackageIndex[] delegate(Config) @safe indexFetcher = null) {
// 1. Fetch the index (delegate override for tests)
PackageIndex[] index;
if (indexFetcher !is null)
index = indexFetcher(cfg);
else
index = fetchIndex(cfg);
// 2. Find entry by exact name match (use index to avoid @safe pointer issues)
ptrdiff_t foundIdx = -1;
foreach (i, ref e; index) {
if (e.name == pkgName) {
foundIdx = i;
break;
}
}
if (foundIdx < 0) {
logError("package '%s' not found in ZUUR", pkgName);
return 2;
}
auto entry = index[foundIdx];
// 3. Print core info from index
writeln(pkgName);
writeln(" version: ", entry.ver);
writeln(" summary: ", entry.summary);
writeln(" pool: ", poolToString(entry.pool));
// 4. Recipe section — only when pool is recipes or both
if (entry.pool == Pool.recipes || entry.pool == Pool.both) {
auto recipeDir = cfg.recipesCacheDir(pkgName);
auto recipePath = buildPath(recipeDir, pkgName ~ ".recipe");
bool cached;
() @trusted { cached = exists(recipePath); }();
if (cached) {
string content;
() @trusted { content = readText(recipePath); }();
auto buildSys = scanStringField(content, "build_system");
if (buildSys.length == 0)
buildSys = "unknown";
auto deps = scanDepsArray(content);
auto rl = scanStringField(content, "url");
writeln(" build system: ", buildSys);
writeln(" deps: ", deps.length > 0 ? deps : "none");
if (rl.length > 0)
writeln(" url: ", rl);
} else {
logInfo("recipe not cached — run 'tofu -S %s' to fetch", pkgName);
}
}
// 5. Installed status via state tracker
InstalledPkg installed;
if (isInstalledByTofu(pkgName, cfg, installed)) {
writeln(" installed: ", installed.ver);
int cmp = compare(installed.ver, entry.ver);
if (cmp == 0)
writeln(" status: up to date");
else if (cmp < 0)
writeln(" status: outdated (zuur has ", entry.ver, ")");
else
writeln(" status: newer than zuur");
}
return 0;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest) {
import std.process : thisProcessID;
import std.conv : to;
import std.file : write, mkdirRecurse, rmdirRecurse, tempDir;
import tofu.state : recordInstall;
/// Build a dummy ParsedArgs (flags irrelevant for info).
private @safe ParsedArgs dummyArgs(string name) {
ParsedArgs pa;
pa.arg = name;
return pa;
}
/// Create a temp Config with an isolated cache dir.
private @safe Config testCfg(string suffix) {
Config cfg;
cfg.cacheDir = buildPath(tempDir,
"tofu-test-info-" ~ suffix ~ "-" ~ to!string(thisProcessID()));
return cfg;
}
private @trusted void ensureDir(string path) {
try mkdirRecurse(path); catch (Exception) {}
}
private @trusted void removeDir(string path) {
try rmdirRecurse(path); catch (Exception) {}
}
/// Write a fake recipe to the correct cache location.
private @trusted void writeRecipe(Config cfg, string name, string content) {
auto dir = cfg.recipesCacheDir(name);
ensureDir(dir);
auto path = buildPath(dir, name ~ ".recipe");
write(path, content);
}
/// Canned index for all tests — no network.
private PackageIndex[] makeFakeIndex() @safe {
return [
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
PackageIndex("ripgrep", "14.1.0", "Fast grep", Pool.recipes),
];
}
}
// ── Test (1): package in index (both) → exit 0 ───────────────
@safe unittest {
auto cfg = testCfg("both");
scope (exit) removeDir(cfg.cacheDir);
auto flags = dummyArgs("neovim");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("neovim", flags, cfg, fetcher);
assert(ec == 0, "expected exit 0, got " ~ to!string(ec));
}
// ── Test (2): package not in index → exit 2 ──────────────────
@safe unittest {
auto cfg = testCfg("notfound");
scope (exit) removeDir(cfg.cacheDir);
auto flags = dummyArgs("nonexistent");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("nonexistent", flags, cfg, fetcher);
assert(ec == 2, "expected exit 2 for not-found, got " ~ to!string(ec));
}
// ── Test (3): cached recipe → prints build system + deps + url ─
@safe unittest {
auto cfg = testCfg("cached");
scope (exit) removeDir(cfg.cacheDir);
writeRecipe(cfg, "neovim", q"LUA
return {
name = "neovim",
ver = "0.9.5",
build_system = "cmake",
url = "https://github.com/neovim/neovim",
deps = { "libluv>=1.30", "msgpack-c" },
}
LUA");
auto flags = dummyArgs("neovim");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("neovim", flags, cfg, fetcher);
assert(ec == 0);
}
// ── Test (4): recipe with NO deps field → no crash (regression) ─
// Regression for the unsigned indexOf wrap: scanDepsArray previously
// stored indexOf's -1 into size_t (→ SIZE_MAX), making the `pos < 0`
// guard a no-op and crashing with ArrayIndexError on content[pos .. $].
@safe unittest {
auto cfg = testCfg("nodeps");
scope (exit) removeDir(cfg.cacheDir);
writeRecipe(cfg, "ripgrep", q"LUA
return {
name = "ripgrep",
ver = "14.1.0",
summary = "Fast grep",
build_system = "cargo",
}
LUA");
auto flags = dummyArgs("ripgrep");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("ripgrep", flags, cfg, fetcher);
assert(ec == 0, "expected exit 0 for recipe without deps, got " ~ to!string(ec));
}
// ── Test (5): installed + up to date → status line ────────────
@safe unittest {
auto cfg = testCfg("uptodate");
scope (exit) removeDir(cfg.cacheDir);
ensureDir(cfg.cacheDir);
recordInstall("neovim", "0.9.5", cfg);
auto flags = dummyArgs("neovim");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("neovim", flags, cfg, fetcher);
assert(ec == 0);
}
// ── Test (6): installed + outdated → status line with zuur ver ─
@safe unittest {
auto cfg = testCfg("outdated");
scope (exit) removeDir(cfg.cacheDir);
ensureDir(cfg.cacheDir);
recordInstall("ripgrep", "13.0.0", cfg);
auto flags = dummyArgs("ripgrep");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("ripgrep", flags, cfg, fetcher);
assert(ec == 0);
}
// ── Test (7): not installed → no status line ─────────────────
@safe unittest {
auto cfg = testCfg("notinst");
scope (exit) removeDir(cfg.cacheDir);
auto flags = dummyArgs("firefox");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("firefox", flags, cfg, fetcher);
assert(ec == 0);
}
// ── Test (8): pool=binary only → no recipe section ────────────
@safe unittest {
auto cfg = testCfg("binaryonly");
scope (exit) removeDir(cfg.cacheDir);
auto flags = dummyArgs("firefox");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("firefox", flags, cfg, fetcher);
assert(ec == 0);
}
// ── Extra: installed version newer than index → "newer than zuur"
@safe unittest {
auto cfg = testCfg("newer");
scope (exit) removeDir(cfg.cacheDir);
ensureDir(cfg.cacheDir);
recordInstall("ripgrep", "15.0.0", cfg);
auto flags = dummyArgs("ripgrep");
auto fetcher = delegate PackageIndex[](Config _) @safe {
return makeFakeIndex();
};
int ec = infoCommand("ripgrep", flags, cfg, fetcher);
assert(ec == 0);
}
+554
View File
@@ -0,0 +1,554 @@
/// tofu.commands.install — The `-S` install command pipeline.
///
/// Full pipeline (plan task 21):
/// 1. Index lookup — find package in ZUUR index
/// 2. Resolve deps — build recipe-fetch delegate, resolveDepTree
/// 3. Constrain & plan — constrainDepTree then generateBuildPlan
/// 4. Display plan + confirm (unless --noconfirm)
/// 5. Dry-run check (--dry-run → skip build/install)
/// 6. Build — buildAll with force flag
/// 7. Install — installAll
/// 8. Record state — recordInstall for each recipe package
/// 9. Summary — logOk / logError
///
/// Testability:
/// `installCommand` accepts optional delegate seams for index fetching and
/// binary version checks. When null, real implementations are used.
module tofu.commands.install;
import tofu.config; // Config
import tofu.cli; // ParsedArgs
import tofu.types; // Recipe, PackageIndex, DepConstraint, BinaryCheckResult,
// BuildPlan, BuildResult, Pool, Source, ConstrainedNode
import tofu.index; // fetchIndex, IndexException
import tofu.deps; // resolveDepTree, DepTree, DepException
import tofu.resolve; // constrainDepTree, generateBuildPlan, ResolveException
import tofu.binary; // checkBinaryVersion
import tofu.fetch; // fetchRecipe, FetchException
import tofu.build; // buildAll, BuildException
import tofu.install; // installAll, InstallException
import tofu.state; // recordInstall, installedVersion
import tofu.log; // logInfo, logOk, logError
import tofu.recipeparse; // parseRecipeFile
import tofu.errors; // exitCodeFor
import std.stdio; // readln, write, writeln, stdin, stdout, File
import std.file; // exists, readText, mkdirRecurse, rmdirRecurse, tempDir
import std.string; // indexOf, strip
import std.format; // format
import std.path : buildPath;
import std.conv; // to
private Config _installCfg;
private Recipe _recipeForDeps(string name) @safe
{
auto dir = _installCfg.recipesCacheDir(name);
auto path_ = dir ~ "/" ~ name ~ ".recipe";
if (!() @trusted { return exists(path_); }())
{
fetchRecipe(name, _installCfg);
}
return parseRecipeFile(path_);
}
private @trusted string trustedReadLine()
{
try { return readln(); }
catch (Exception) { return ""; }
}
@safe
int installCommand(string pkgName, ParsedArgs flags, Config cfg,
PackageIndex[] delegate() @safe indexFetcher = null,
BinaryCheckResult delegate(string, DepConstraint) @safe
binaryCheck = null)
{
PackageIndex[] index;
try
{
if (indexFetcher !is null)
index = indexFetcher();
else
index = fetchIndex(cfg);
}
catch (Exception e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
PackageIndex* foundEntry = null;
for (size_t i = 0; i < index.length; i++)
{
if (index[i].name == pkgName)
{
foundEntry = &index[i];
break;
}
}
if (foundEntry is null)
{
logError("package '%s' not found in ZUUR", pkgName);
return 2;
}
_installCfg = cfg;
scope (exit) _installCfg = Config.init;
DepTree tree;
try
{
tree = resolveDepTree(pkgName, &_recipeForDeps);
}
catch (DepException e)
{
logError("%s", e.msg);
return 3;
}
catch (FetchException e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
catch (Exception e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
scope bcheck = delegate (string name, DepConstraint c) @safe
{
if (binaryCheck !is null)
return binaryCheck(name, c);
return checkBinaryVersion(name, c, cfg);
};
ConstrainedNode[] constrained;
try
{
constrained = constrainDepTree(tree, index, bcheck);
}
catch (ResolveException e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
catch (Exception e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
scope fetchDelegate = delegate (string name) @safe
{
return fetchRecipe(name, cfg);
};
BuildPlan plan;
try
{
plan = generateBuildPlan(constrained, tree, cfg, fetchDelegate);
}
catch (FetchException e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
catch (Exception e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
{
auto entries = plan.order();
if (entries.length == 0)
{
logInfo("nothing to build (all dependencies are binary)");
}
else
{
string pkgList;
foreach (i, entry; entries)
{
if (i > 0) pkgList ~= ", ";
pkgList ~= entry.name;
}
logInfo("will build %d package(s): %s", entries.length, pkgList);
if (!flags.noconfirm)
{
() @trusted { write("Proceed? [y/N] "); stdout.flush(); }();
auto response = trustedReadLine().strip();
if (response != "y" && response != "Y"
&& response != "yes" && response != "YES")
{
logInfo("aborted by user");
return 0;
}
}
}
}
if (flags.dryRun)
{
logInfo("dry run — nothing built");
return 0;
}
try
{
auto result = buildAll(plan, cfg, flags.force);
if (result.failed.length > 0)
{
foreach (f; result.failed)
logError("build failed: %s — %s", f.name, f.reason);
return 4;
}
}
catch (BuildException e)
{
logError("build phase error: %s", e.msg);
return exitCodeFor(e);
}
catch (Exception e)
{
logError("build phase error: %s", e.msg);
return exitCodeFor(e);
}
try
{
installAll(plan, cfg);
}
catch (InstallException e)
{
logError("install phase error: %s", e.msg);
return exitCodeFor(e);
}
catch (Exception e)
{
logError("install phase error: %s", e.msg);
return 5;
}
{
string indexVersion = "0";
foreach (entry; index)
{
if (entry.name == pkgName && entry.ver.length > 0)
{
indexVersion = entry.ver;
break;
}
}
recordInstall(pkgName, indexVersion, cfg);
}
logOk("installed %d package(s)", plan.order().length);
return 0;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
import std.process : thisProcessID;
@trusted string makeTempDir(string suffix)
{
auto path = buildPath(tempDir, "tofu-instcmd-" ~ suffix ~ "-" ~ thisProcessID.to!string);
if (exists(path)) rmdirRecurse(path);
mkdirRecurse(path);
return path;
}
@trusted void removeDir(string path)
{
try { rmdirRecurse(path); } catch (Exception) {}
}
@trusted void sWrite(string path, string content)
{
std.file.write(path, content);
}
@trusted string makeFakeMakepkg(string dir, string exitCode = "0")
{
import core.sys.posix.sys.stat : chmod;
import std.string : toStringz;
import std.conv : octal;
auto path = buildPath(dir, "fake-makepkg");
string script = "#!/bin/bash\nset -e\n";
script ~= "RECIPE_PATH=\"$1\"\n";
script ~= "RECIPE_FILE=$(basename \"$RECIPE_PATH\")\n";
script ~= "PKG_NAME=\"${RECIPE_FILE%.recipe}\"\n";
script ~= "OUTPUT_DIR=.\n";
script ~= "while [[ $# -gt 0 ]]; do case \"$1\" in --output) OUTPUT_DIR=\"$2\"; shift 2;; *) shift;; esac; done\n";
script ~= "mkdir -p \"${OUTPUT_DIR}/packages/${PKG_NAME}\"\n";
script ~= "echo 'return { version = \"1.0.0\" }' > \"${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua\"\n";
script ~= "exit " ~ exitCode ~ "\n";
std.file.write(path, script);
chmod(toStringz(path), octal!755);
return path;
}
@trusted string makeFakeZeta(string dir, string exitCode = "0")
{
import core.sys.posix.sys.stat : chmod;
import std.string : toStringz;
import std.conv : octal;
auto path = buildPath(dir, "fake-zeta");
std.file.write(path, "#!/bin/bash\necho \"$2\" > '" ~ dir ~ "/zeta-args.txt'\nexit " ~ exitCode ~ "\n");
chmod(toStringz(path), octal!755);
return path;
}
// ════════════════════════════════════
// Test (1): full happy path
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("happy");
scope (exit) removeDir(tmp);
auto recipeDir = tmp ~ "/recipes/happypkg";
() @trusted { mkdirRecurse(recipeDir); }();
sWrite(recipeDir ~ "/happypkg.recipe",
`return { name = "happypkg", version = "1.0.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "http://127.0.0.1:1"; // dead port — fetch will fail fast
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
cfg.defaultJobs = 1;
auto ifetcher = delegate () @safe {
PackageIndex[] idx;
PackageIndex e; e.name = "happypkg"; e.ver = "1.0.0"; e.pool = Pool.recipes;
idx ~= e;
return idx;
};
auto bchecker = delegate (string n, DepConstraint c) @safe {
return BinaryCheckResult(false, "", false);
};
ParsedArgs pa;
pa.cmd = Command.install;
pa.arg = "happypkg";
pa.noconfirm = true;
auto ec = installCommand("happypkg", pa, cfg, ifetcher, bchecker);
assert(ec == 0, "expected 0, got " ~ ec.to!string);
assert(installedVersion("happypkg", cfg) == "1.0.0");
string za;
() @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }();
assert(za.strip() == "happypkg");
}
// ════════════════════════════════════
// Test (2): package not in index → 2
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("nf2");
scope (exit) removeDir(tmp);
Config cfg; cfg.cacheDir = tmp;
auto ec = installCommand("ghost", ParsedArgs(Command.install, "ghost", true),
cfg,
delegate () @safe { return cast(PackageIndex[])[]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 2, "expected 2, got " ~ ec.to!string);
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (3): dep cycle → 3
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("cyc3");
scope (exit) removeDir(tmp);
auto dA = tmp ~ "/recipes/A"; auto dB = tmp ~ "/recipes/B";
() @trusted { mkdirRecurse(dA); mkdirRecurse(dB); }();
sWrite(dA ~ "/A.recipe", `return { name = "A", deps = { "B" } }`);
sWrite(dB ~ "/B.recipe", `return { name = "B", deps = { "A" } }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
auto ec = installCommand("A", ParsedArgs(Command.install, "A", true), cfg,
delegate () @safe {
PackageIndex a, b; a.name="A"; a.pool=Pool.recipes; b.name="B"; b.pool=Pool.recipes;
return [a, b];
},
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 3, "expected 3, got " ~ ec.to!string);
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (4): build failure → 4
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("bf4");
scope (exit) removeDir(tmp);
auto d = tmp ~ "/recipes/bfp";
() @trusted { mkdirRecurse(d); }();
sWrite(d ~ "/bfp.recipe", `return { name = "bfp", version = "1.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp, "1");
cfg.zetaPath = makeFakeZeta(tmp);
auto ec = installCommand("bfp", ParsedArgs(Command.install, "bfp", true), cfg,
delegate () @safe { return [PackageIndex("bfp","1.0","",Pool.recipes)]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 4, "expected 4, got " ~ ec.to!string);
assert(installedVersion("bfp", cfg) == "");
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (5): install failure → 5
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("if5");
scope (exit) removeDir(tmp);
auto d = tmp ~ "/recipes/ifp";
() @trusted { mkdirRecurse(d); }();
sWrite(d ~ "/ifp.recipe", `return { name = "ifp", version = "1.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp, "1");
auto ec = installCommand("ifp", ParsedArgs(Command.install, "ifp", true), cfg,
delegate () @safe { return [PackageIndex("ifp","1.0","",Pool.recipes)]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 5, "expected 5, got " ~ ec.to!string);
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (6): --dry-run → 0, nothing built
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("dr6");
scope (exit) removeDir(tmp);
auto d = tmp ~ "/recipes/drp";
() @trusted { mkdirRecurse(d); }();
sWrite(d ~ "/drp.recipe", `return { name = "drp", version = "1.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
ParsedArgs pa;
pa.cmd = Command.install;
pa.arg = "drp";
pa.noconfirm = true;
pa.dryRun = true;
auto ec = installCommand("drp", pa, cfg,
delegate () @safe { return [PackageIndex("drp","1.0","",Pool.recipes)]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 0, "expected 0, got " ~ ec.to!string);
assert(!() @trusted { return exists(cfg.builtDir() ~ "/packages/drp/package.lua"); }());
assert(installedVersion("drp", cfg) == "");
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (7): --noconfirm skips prompt
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("nc7");
scope (exit) removeDir(tmp);
auto d = tmp ~ "/recipes/ncp";
() @trusted { mkdirRecurse(d); }();
sWrite(d ~ "/ncp.recipe", `return { name = "ncp", version = "1.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
auto stdinFile = tmp ~ "/stdin-n.txt";
sWrite(stdinFile, "n\n");
File saved;
() @trusted { saved = stdin; stdin = File(stdinFile, "r"); }();
scope (exit) () @trusted { stdin = saved; }();
ParsedArgs pa;
pa.cmd = Command.install;
pa.arg = "ncp";
pa.noconfirm = true;
auto ec = installCommand("ncp", pa, cfg,
delegate () @safe { return [PackageIndex("ncp","1.0","",Pool.recipes)]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 0, "expected 0 with --noconfirm, got " ~ ec.to!string);
assert(installedVersion("ncp", cfg) == "1.0");
_installCfg = Config.init;
}
// ════════════════════════════════════
// Test (8): confirmation denied → abort
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("ab8");
scope (exit) removeDir(tmp);
auto d = tmp ~ "/recipes/abp";
() @trusted { mkdirRecurse(d); }();
sWrite(d ~ "/abp.recipe", `return { name = "abp", version = "1.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "https://x";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
auto stdinFile = tmp ~ "/stdin-n.txt";
sWrite(stdinFile, "n\n");
File saved;
() @trusted { saved = stdin; stdin = File(stdinFile, "r"); }();
scope (exit) () @trusted { stdin = saved; }();
auto ec = installCommand("abp", ParsedArgs(Command.install, "abp"), cfg,
delegate () @safe { return [PackageIndex("abp","1.0","",Pool.recipes)]; },
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
assert(ec == 0, "expected 0 after abort, got " ~ ec.to!string);
assert(!() @trusted { return exists(cfg.builtDir() ~ "/packages/abp/package.lua"); }());
assert(installedVersion("abp", cfg) == "");
_installCfg = Config.init;
}
+330
View File
@@ -0,0 +1,330 @@
/// tofu.commands.remove — `-R <pkg>` remove command.
///
/// Delegates to `zeta -Remove <pkg> --pass` via `tofu.install.runRemove`.
/// Handles the confirm prompt, "still required by" reverse-dependency
/// detection, and tofu state-record cleanup on success.
module tofu.commands.remove;
import tofu.config : Config;
import tofu.log : logWarn, logInfo, logError, logOk;
import tofu.cli : ParsedArgs;
import tofu.install : runRemove, InstallException;
import tofu.state : isInstalledByTofu, removeInstallRecord, recordInstall,
InstalledPkg;
import std.string : indexOf;
import std.conv : to;
import std.stdio : readln, write, writeln, stdout;
// ─── Public API ───────────────────────────────────────────────────────────────
@safe:
/// Remove a package via ZETA.
///
/// Flow:
/// 1. Check whether tofu installed it — warn if not (still proceeds).
/// 2. Confirm prompt (`Remove <name>? [y/N]`) unless `--noconfirm`.
/// 3. Invoke `zeta -Remove <pkgName> --pass` (with `--force` if requested).
/// 4. On reverse-dep failure ("still required by") without `--force`:
/// print Zeta's error + suggest `--force`, return 1.
/// 5. On success: remove install record + logOk.
///
/// Returns: 0 on success, 1 on reverse-dep block, 5 on install failure.
int removeCommand(string pkgName, ParsedArgs flags, Config cfg)
{
// ── 1. Check whether tofu installed it ──────────────────────────────
InstalledPkg pkg;
if (!isInstalledByTofu(pkgName, cfg, pkg))
{
logWarn("package '%s' was not installed by tofu — removing via Zeta anyway",
pkgName);
}
// ── 2. Confirm prompt ───────────────────────────────────────────────
if (!flags.noconfirm)
{
() @trusted {
write("Remove ", pkgName, "? [y/N] ");
stdout.flush();
}();
string answer;
try
{
() @trusted { answer = readln(); }();
}
catch (Exception)
{
writeln("");
logInfo("aborted by user");
return 0;
}
if (answer.length == 0 || (answer[0] != 'y' && answer[0] != 'Y'))
{
logInfo("aborted by user");
return 0;
}
}
// ── 3. Invoke zeta remove ───────────────────────────────────────────
try
{
runRemove(pkgName, cfg, flags.force);
}
catch (InstallException e)
{
if (e.msg.indexOf("still required by") >= 0 && !flags.force)
{
logError("cannot remove %s (use --force to override)", pkgName);
return 1;
}
logError("%s", e.msg);
return 5;
}
// ── 4. Success — clean up tofu state ────────────────────────────────
removeInstallRecord(pkgName, cfg);
logOk("removed %s", pkgName);
return 0;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
private:
@trusted void writeFakeScript(string path, string content)
{
import std.file : write;
write(path, content);
import std.process : execute;
execute(["chmod", "+x", path]);
}
@trusted void removeDir(string path)
{
import std.file : rmdirRecurse;
try { rmdirRecurse(path); } catch (Exception) {}
}
@trusted string makeTempDir(string suffix)
{
import std.path : buildPath;
import std.file : tempDir, mkdirRecurse;
import std.process : thisProcessID;
import std.conv : to;
auto path = buildPath(tempDir, "tofu-remove-test-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
@safe Config testConfig(string zetaScript, string cacheDir)
{
import tofu.config : load;
const string[string] env = [
"TOFU_CACHE_DIR": cacheDir,
"TOFU_ZETA_PATH": zetaScript,
];
return load(null, env);
}
// ── Test (1): tofu-installed pkg, fake zeta exits 0 → removed, state record gone
@safe unittest
{
auto tmp = makeTempDir("ok");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$2" >> __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", tmp ~ "/args.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
InstalledPkg dummy;
assert(!isInstalledByTofu("testpkg", cfg, dummy),
"state record should be removed");
import std.file : readText;
import std.string : strip;
string argsContent;
() @trusted { argsContent = readText(tmp ~ "/args.txt"); }();
assert(argsContent.strip == "testpkg",
"expected zeta invoked with 'testpkg', got '" ~ argsContent.strip ~ "'");
}
// ── Test (2): NOT tofu-installed → warning printed, still removes via zeta
@safe unittest
{
auto tmp = makeTempDir("not-tofu");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "removed ok"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("foreignpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
}
// ── Test (3): fake zeta exits 1 with "still required by" → error + suggestion, state kept
@safe unittest
{
auto tmp = makeTempDir("reversedep");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "error cannot remove libfoo: still required by libbar (use --force to override)" >&2
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
recordInstall("libfoo", "2.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("libfoo", pa, cfg);
assert(rc == 1, "expected exit 1 for reverse-dep block, got " ~ rc.to!string());
InstalledPkg pkg;
assert(isInstalledByTofu("libfoo", cfg, pkg),
"state record should still exist after reverse-dep block");
}
// ── Test (4): --force → fake zeta receives --force flag
@safe unittest
{
auto tmp = makeTempDir("forceflag");
scope (exit) removeDir(tmp);
auto argsFile = tmp ~ "/args.txt";
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$@" > __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", argsFile);
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
pa.force = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
import std.file : readText;
string allArgs;
() @trusted { allArgs = readText(argsFile); }();
assert(allArgs.indexOf("--force") >= 0,
"expected --force flag in args, got: " ~ allArgs);
}
// ── Test (5): --noconfirm → no prompt, direct execution
@safe unittest
{
auto tmp = makeTempDir("noconfirm");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "removed without prompt"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
ParsedArgs pa;
pa.noconfirm = true;
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0, got " ~ rc.to!string());
InstalledPkg dummy;
assert(!isInstalledByTofu("testpkg", cfg, dummy),
"state record should be removed after noconfirm remove");
}
// ── Test (6): confirmation denied → aborted, zeta NOT invoked
// NOTE: uses stdin.open() to feed "n\n" — test is last to avoid
// polluting stdin for subsequent tests.
@safe unittest
{
auto tmp = makeTempDir("abort");
scope (exit) removeDir(tmp);
auto argsTag = tmp ~ "/zeta-called.txt";
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
touch __TAG__
exit 0
SCRIPT", "__TAG__", argsTag);
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
recordInstall("testpkg", "1.0", cfg);
import std.file : write;
auto stdinPath = tmp ~ "/fake-stdin";
() @trusted { write(stdinPath, "n\n"); }();
ParsedArgs pa;
() @trusted {
import std.stdio;
stdin.open(stdinPath);
}();
int rc = removeCommand("testpkg", pa, cfg);
assert(rc == 0, "expected exit 0 for abort, got " ~ rc.to!string());
InstalledPkg pkg;
assert(isInstalledByTofu("testpkg", cfg, pkg),
"state record should still exist after abort");
import std.file : exists;
() @trusted {
assert(!exists(argsTag),
"zeta should NOT have been called after 'no' answer");
}();
}
+376
View File
@@ -0,0 +1,376 @@
/// tofu.commands.search — -Ss ZUUR index search command.
///
/// Searches the ZUUR package index by case-insensitive substring match on
/// name, summary, and version. Results are sorted alphabetically by name
/// and printed in `zuur/<pool> <name> <ver>\n <summary>` format.
///
/// Exit codes:
/// 0 — matches found and printed
/// 1 — no packages match the query
/// 6 — network error (HttpException / IndexException)
module tofu.commands.search;
import tofu.config;
import tofu.types;
import tofu.index;
import tofu.http;
import tofu.log;
import std.string : toLower, indexOf;
import std.algorithm.sorting : sort;
import std.stdio : writeln;
// ────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────
/// Search the ZUUR index for packages matching `query`.
///
/// Matching is case-insensitive substring: a package matches if `query`
/// (lowercased) is a substring of the lowercased name, summary, or version.
///
/// The `indexFetcher` delegate injects a test double for the index.
/// When `null`, the real `fetchIndex(cfg)` is used.
///
/// Returns the exit code:
/// 0 — success (results printed to stdout)
/// 1 — no matches (error message printed to stderr)
/// 6 — network / index error (error message printed to stderr)
@safe int searchCommand(string query, Config cfg,
PackageIndex[] delegate(Config) @safe indexFetcher = null)
{
PackageIndex[] index;
try
{
if (indexFetcher !is null)
index = indexFetcher(cfg);
else
index = fetchIndex(cfg);
}
catch (HttpException e)
{
logError("%s", e.msg);
return 6;
}
catch (IndexException e)
{
logError("%s", e.msg);
return 6;
}
// ── Filter: case-insensitive substring match on name + summary + ver ─
auto q = query.toLower();
PackageIndex[] matches;
foreach (ref pkg; index)
{
if (pkg.name.toLower().indexOf(q) >= 0 ||
pkg.summary.toLower().indexOf(q) >= 0 ||
pkg.ver.toLower().indexOf(q) >= 0)
{
matches ~= pkg;
}
}
if (matches.length == 0)
{
logError("no packages match '%s'", query);
return 1;
}
// ── Sort alphabetically by name ──────────────────────────────────────
sort!("a.name < b.name")(matches);
// ── Print results ────────────────────────────────────────────────────
foreach (ref pkg; matches)
{
writeln("zuur/", poolToString(pkg.pool), " ", pkg.name, " ", pkg.ver);
writeln(" ", pkg.summary);
}
return 0;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.stdio : File, stdout, stderr;
import std.file : exists, remove, readText, tempDir;
/// Remove a temp file, silently ignoring errors (best-effort cleanup).
private void tryRemove(string path) @trusted
{
try
{
if (exists(path))
remove(path);
}
catch (Exception) {}
}
/// Capture stdout produced by `dg` into a string.
/// Swaps the global `stdout` File to a temp file, runs `dg`, then
/// reads back the full content. Restores stdout on scope exit.
/// Pattern adapted from `tofu.log` test harness.
private string captureStdout(void delegate() @safe dg) @trusted
{
auto name = tempDir() ~ "/tofu-search-stdout.tmp";
scope (exit) tryRemove(name);
{
auto file = File(name, "w");
auto saved = stdout;
stdout = file;
scope (exit) stdout = saved;
scope (failure) stdout = saved;
dg();
stdout.flush();
}
// File closed here — read from disk
string result;
() @trusted {
if (exists(name))
result = readText(name);
}();
return result;
}
/// Capture stderr produced by `dg` into a string.
private string captureStderr(void delegate() @safe dg) @trusted
{
auto name = tempDir() ~ "/tofu-search-stderr.tmp";
scope (exit) tryRemove(name);
{
auto file = File(name, "w");
auto saved = stderr;
stderr = file;
scope (exit) stderr = saved;
scope (failure) stderr = saved;
dg();
stderr.flush();
}
string result;
() @trusted {
if (exists(name))
result = readText(name);
}();
return result;
}
}
/// (1) query "neovim" with index containing neovim → finds it
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("neovim", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"should contain neovim in output: " ~ stdoutContent);
assert(stdoutContent.indexOf("zuur/both") >= 0,
"should contain zuur/both: " ~ stdoutContent);
assert(stdoutContent.indexOf("Text editor") >= 0,
"should contain summary: " ~ stdoutContent);
}
/// (2) case-insensitive: query "NEOVIM" finds "neovim"
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("NEOVIM", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"case-insensitive search should find neovim: " ~ stdoutContent);
}
/// (3) matches summary text (query "editor" matches summary "Text editor")
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("zsh", "5.9", "Z shell", Pool.both),
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
PackageIndex("ripgrep", "14.1", "Fast grep", Pool.recipes),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("editor", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"query 'editor' should match 'Text editor' summary: " ~ stdoutContent);
// only neovim should match (summary "Text editor")
assert(stdoutContent.indexOf("zsh") == -1,
"'zsh' should not match 'editor': " ~ stdoutContent);
assert(stdoutContent.indexOf("ripgrep") == -1,
"'ripgrep' should not match 'editor': " ~ stdoutContent);
}
/// (4) no match → returns 1, prints "no packages match" to stderr
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stderrContent;
int rc;
stderrContent = captureStderr({
rc = searchCommand("xyzzy", cfg, fetcher);
});
assert(rc == 1, "expected exit 1, got " ~ to!string(rc));
assert(stderrContent.indexOf("no packages match") >= 0,
"should print 'no packages match': " ~ stderrContent);
assert(stderrContent.indexOf("xyzzy") >= 0,
"should include query in error message: " ~ stderrContent);
}
/// (5) empty index → no match path
@safe unittest
{
import std.conv : to;
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return [];
};
auto cfg = Config();
string stderrContent;
int rc;
stderrContent = captureStderr({
rc = searchCommand("anything", cfg, fetcher);
});
assert(rc == 1, "expected exit 1 for empty index, got " ~ to!string(rc));
assert(stderrContent.indexOf("no packages match") >= 0,
"should print 'no packages match' for empty index: " ~ stderrContent);
}
/// (6) sort order: two matches → alphabetical
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("ripgrep", "14.1", "Fast grep", Pool.recipes),
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
// query "r" matches all three: ripgrep in name, firefox ("Web browser" has 'r'),
// neovim ("Text editor" has 'r')
stdoutContent = captureStdout({
rc = searchCommand("r", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
// Find positions of package names in output
auto firefoxPos = stdoutContent.indexOf("firefox");
auto neovimPos = stdoutContent.indexOf("neovim");
auto ripgrepPos = stdoutContent.indexOf("ripgrep");
assert(firefoxPos >= 0 && neovimPos >= 0 && ripgrepPos >= 0,
"all three packages should appear: " ~ stdoutContent);
assert(firefoxPos < neovimPos,
"firefox should appear before neovim (alphabetical): " ~ stdoutContent);
assert(neovimPos < ripgrepPos,
"neovim should appear before ripgrep (alphabetical): " ~ stdoutContent);
}
/// (7) output format exact: "zuur/both neovim 0.9.5\\n Text editor"
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("neovim", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
// Build the exact expected line (writeln adds \n to each call)
auto expected = "zuur/both neovim 0.9.5\n Text editor\n";
assert(stdoutContent.indexOf(expected) >= 0 || stdoutContent == expected,
"exact output format mismatch.\nExpected to contain:\n" ~ expected
~ "\nActual:\n" ~ stdoutContent);
}
+545
View File
@@ -0,0 +1,545 @@
/// tofu.commands.upgrade — The `-Syu` upgrade command.
///
/// Full pipeline (plan task 22):
/// 1. Fetch latest ZUUR index (indexFetcher seam, null → tofu.index.fetchIndex)
/// 2. List tofu-installed packages (tofu.state.listInstalled)
/// 3. Compare installed versions against index via tofu.vercmp.compare
/// 4. Show upgrade plan + confirm prompt (unless --noconfirm)
/// 5. For each outdated package: REUSE installCommand with force=true
/// (continue-on-failure for multi-package upgrades)
/// 6. Summary: N upgraded, M up to date, K failures
///
/// Note: Only tofu-installed recipe packages are upgraded — binary packages
/// are outside tofu's purview.
///
/// Testability:
/// `upgradeCommand` accepts optional `indexFetcher` delegate seam.
/// When null, real `fetchIndex(cfg)` is used.
module tofu.commands.upgrade;
import tofu.config; // Config
import tofu.cli; // ParsedArgs, Command
import tofu.types; // PackageIndex, DepConstraint, BinaryCheckResult, Pool
import tofu.index; // fetchIndex, IndexException
import tofu.state; // listInstalled, installedVersion, InstalledPkg, recordInstall
import tofu.vercmp; // compare
import tofu.log; // logInfo, logOk, logError, logWarn, logDetail
import tofu.commands.install; // installCommand
import tofu.errors; // exitCodeFor
import std.stdio; // write, stdout, readln, stdin, File, writeln
import std.file; // exists, mkdirRecurse, rmdirRecurse, readText, write, tempDir
import std.string; // strip
import std.format; // format
import std.path : buildPath;
// ─── @trusted I/O wrappers (stdout/stdin are @system in DMD 2.112) ───────────
private @trusted string trustedReadLine()
{
try { return readln(); }
catch (Exception) { return ""; }
}
// ─── Public API ───────────────────────────────────────────────────────────────
/// Run the -Syu upgrade command.
///
/// Params:
/// flags = Parsed command-line args (noconfirm honored for overall plan)
/// cfg = Tofu configuration
/// indexFetcher = Testability seam — when null, fetchIndex(cfg) is used
///
/// Returns: 0 on success/no-op, 1 if any upgrade failed.
@safe
int upgradeCommand(ParsedArgs flags, Config cfg,
PackageIndex[] delegate(Config) @safe indexFetcher = null)
{
// ── 1. Fetch index ──────────────────────────────────────────────
PackageIndex[] index;
try
{
if (indexFetcher !is null)
index = indexFetcher(cfg);
else
index = fetchIndex(cfg);
}
catch (Exception e)
{
logError("%s", e.msg);
return exitCodeFor(e);
}
// ── 2. List installed packages ──────────────────────────────────
auto installed = listInstalled(cfg);
if (installed.length == 0)
{
logInfo("nothing to do");
return 0;
}
// ── 3. Compare installed vs index versions ──────────────────────
string[] outdated;
PackageIndex[] outdatedEntries; // matching index entries
string[] upToDate;
string[] removed;
size_t skippedCount = 0;
foreach (pkg; installed)
{
// Find in index by name
PackageIndex* foundEntry = null;
for (size_t i = 0; i < index.length; i++)
{
if (index[i].name == pkg.name)
{
foundEntry = &index[i];
break;
}
}
if (foundEntry is null)
{
logWarn("package '%s' no longer in ZUUR — skipping", pkg.name);
removed ~= pkg.name;
continue;
}
// Only upgrade recipe-available packages (pool == recipes or both)
if (foundEntry.pool == Pool.binary)
{
logDetail("package '%s' is binary-only — skipping", pkg.name);
skippedCount++;
continue;
}
int cmp = compare(pkg.ver, foundEntry.ver);
if (cmp >= 0)
{
logDetail("package '%s' is up to date (%s)", pkg.name, pkg.ver);
upToDate ~= pkg.name;
}
else
{
// cmp < 0 → installed version is older
outdated ~= pkg.name;
outdatedEntries ~= *foundEntry;
}
}
// ── 4. No outdated packages ─────────────────────────────────────
if (outdated.length == 0)
{
logInfo("nothing to do");
return 0;
}
// ── 5. Show plan + confirm ──────────────────────────────────────
{
string pkgList;
foreach (i, name; outdated)
{
if (i > 0) pkgList ~= ", ";
pkgList ~= format("%s: %s → %s",
name, installedVersion(name, cfg), outdatedEntries[i].ver);
}
logInfo("will upgrade %d package(s): %s", outdated.length, pkgList);
if (!flags.noconfirm)
{
() @trusted { std.stdio.write("Proceed? [y/N] "); stdout.flush(); }();
auto response = trustedReadLine().strip();
if (response != "y" && response != "Y"
&& response != "yes" && response != "YES")
{
logInfo("aborted by user");
return 0;
}
}
}
// ── 6. Run installCommand for each outdated package ─────────────
// Create flags copy with force=true (ensure rebuild over stale
// output — Zeta -ReProvide handles overwrite).
// noconfirm=true on the copy because we already confirmed at
// the upgrade level.
int failures = 0;
int succeeded = 0;
// Build a closure that captures the already-fetched index so
// installCommand doesn't re-fetch.
auto installIdxFetcher = delegate PackageIndex[]() @safe
{
return index;
};
foreach (i, name; outdated)
{
// Copy flags and override force + noconfirm
auto pkgFlags = flags; // struct copy
pkgFlags.force = true; // ensure rebuild
pkgFlags.noconfirm = true; // already confirmed at upgrade level
logInfo("upgrading %s (%s → %s)...",
name, installedVersion(name, cfg), outdatedEntries[i].ver);
int ec = installCommand(name, pkgFlags, cfg, installIdxFetcher, null);
if (ec != 0)
{
logError("upgrade failed for %s", name);
failures++;
}
else
{
succeeded++;
}
}
// ── 7. Summary ──────────────────────────────────────────────────
{
import std.conv : to;
string summary = format("%d package(s) upgraded", succeeded);
if (upToDate.length > 0)
summary ~= format(", %d up to date", upToDate.length);
if (removed.length > 0)
summary ~= format(", %d no longer in ZUUR", removed.length);
if (skippedCount > 0)
summary ~= format(", %d binary-only skipped", skippedCount);
logOk("%s", summary);
if (failures > 0)
{
logWarn("%d upgrade(s) failed", failures);
return 1;
}
}
return 0;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@trusted string makeTempDir(string suffix)
{
import std.conv : to;
import std.process : thisProcessID;
auto path = buildPath(tempDir, "tofu-upgcmd-" ~ suffix ~ "-" ~ thisProcessID.to!string);
if (exists(path)) rmdirRecurse(path);
mkdirRecurse(path);
return path;
}
@trusted void removeDir(string path)
{
try { rmdirRecurse(path); } catch (Exception) {}
}
@trusted void sWrite(string path, string content)
{
std.file.write(path, content);
}
@trusted string makeFakeMakepkg(string dir, string exitCode = "0")
{
import core.sys.posix.sys.stat : chmod;
import std.string : toStringz;
import std.conv : octal;
auto path = buildPath(dir, "fake-makepkg");
string script = "#!/bin/bash\nset -e\n";
script ~= "RECIPE_PATH=\"$1\"\n";
script ~= "RECIPE_FILE=$(basename \"$RECIPE_PATH\")\n";
script ~= "PKG_NAME=\"${RECIPE_FILE%.recipe}\"\n";
script ~= "OUTPUT_DIR=.\n";
script ~= "while [[ $# -gt 0 ]]; do case \"$1\" in --output) OUTPUT_DIR=\"$2\"; shift 2;; *) shift;; esac; done\n";
script ~= "mkdir -p \"${OUTPUT_DIR}/packages/${PKG_NAME}\"\n";
script ~= "echo 'return { version = \"1.0.0\" }' > \"${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua\"\n";
script ~= "exit " ~ exitCode ~ "\n";
std.file.write(path, script);
chmod(toStringz(path), octal!755);
return path;
}
@trusted string makeFakeZeta(string dir, string exitCode = "0")
{
import core.sys.posix.sys.stat : chmod;
import std.string : toStringz;
import std.conv : octal;
auto path = buildPath(dir, "fake-zeta");
std.file.write(path, "#!/bin/bash\necho \"$2\" > '" ~ dir ~ "/zeta-args.txt'\nexit " ~ exitCode ~ "\n");
chmod(toStringz(path), octal!755);
return path;
}
/// Create a conditional fake zeta that fails for a specific package name.
@trusted string makeConditionalZeta(string dir, string failName, string failCode = "1")
{
import core.sys.posix.sys.stat : chmod;
import std.string : toStringz;
import std.conv : octal;
auto path = buildPath(dir, "fake-zeta");
string script = "#!/bin/bash\n";
script ~= "if [ \"$2\" = \"" ~ failName ~ "\" ]; then\n";
script ~= " echo \"install failed\" >&2\n";
script ~= " exit " ~ failCode ~ "\n";
script ~= "fi\n";
script ~= "echo \"$2\" > '" ~ dir ~ "/zeta-args.txt'\n";
script ~= "exit 0\n";
std.file.write(path, script);
chmod(toStringz(path), octal!755);
return path;
}
/// Create a recipe directory + .recipe file.
@trusted void makeRecipeDir(string cacheDir, string name, string ver)
{
auto d = buildPath(cacheDir, "recipes", name);
if (!exists(d)) mkdirRecurse(d);
sWrite(buildPath(d, name ~ ".recipe"),
`return { name = "` ~ name ~ `", version = "` ~ ver ~ `" }`);
}
// ════════════════════════════════════
// Test (1): no installed → "nothing to do" + exit 0
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("noinst");
scope (exit) removeDir(tmp);
Config cfg;
cfg.cacheDir = tmp;
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, "", true),
cfg,
delegate PackageIndex[](Config _) @safe {
PackageIndex e; e.name = "foo"; e.ver = "1.0"; e.pool = Pool.recipes;
return [e];
});
assert(ec == 0, "expected 0, got " ~ ec.to!string);
// State should be empty (nothing installed, nothing recorded)
assert(installedVersion("foo", cfg) == "");
}
// ════════════════════════════════════
// Test (2): installed all up to date → "nothing to do" + exit 0
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("uptodate");
scope (exit) removeDir(tmp);
Config cfg;
cfg.cacheDir = tmp;
recordInstall("foo", "2.0", cfg);
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, "", true),
cfg,
delegate PackageIndex[](Config _) @safe {
PackageIndex e; e.name = "foo"; e.ver = "2.0"; e.pool = Pool.recipes;
return [e];
});
assert(ec == 0, "expected 0, got " ~ ec.to!string);
assert(installedVersion("foo", cfg) == "2.0");
}
// ════════════════════════════════════
// Test (3): one outdated → upgraded via installCommand path
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("outdated3");
scope (exit) removeDir(tmp);
makeRecipeDir(tmp, "oldpkg", "2.0");
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "http://127.0.0.1:1"; // dead port — won't be used
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
cfg.defaultJobs = 1;
// Pre-install old version
recordInstall("oldpkg", "1.0", cfg);
assert(installedVersion("oldpkg", cfg) == "1.0");
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, "", true),
cfg,
delegate PackageIndex[](Config _) @safe {
PackageIndex e; e.name = "oldpkg"; e.ver = "2.0"; e.pool = Pool.recipes;
return [e];
});
assert(ec == 0, "expected 0, got " ~ ec.to!string);
// State should be updated
assert(installedVersion("oldpkg", cfg) == "2.0",
"expected 2.0, got " ~ installedVersion("oldpkg", cfg));
// Zeta should have been invoked
string za;
() @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }();
assert(za.strip() == "oldpkg",
"expected zeta invoked with 'oldpkg', got '" ~ za.strip() ~ "'");
}
// ════════════════════════════════════
// Test (4): package removed from index → warning, skipped
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("removed4");
scope (exit) removeDir(tmp);
makeRecipeDir(tmp, "realpkg", "2.0");
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "http://127.0.0.1:1";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
cfg.defaultJobs = 1;
// ghost is installed but not in index; realpkg needs upgrade
recordInstall("ghost", "1.0", cfg);
recordInstall("realpkg", "1.0", cfg);
assert(installedVersion("ghost", cfg) == "1.0");
assert(installedVersion("realpkg", cfg) == "1.0");
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, "", true),
cfg,
delegate PackageIndex[](Config _) @safe {
// Note: ghost is missing from index
PackageIndex e; e.name = "realpkg"; e.ver = "2.0"; e.pool = Pool.recipes;
return [e];
});
assert(ec == 0, "expected 0, got " ~ ec.to!string);
// realpkg should be upgraded
assert(installedVersion("realpkg", cfg) == "2.0");
// ghost stays at old version (not upgraded, still in state)
assert(installedVersion("ghost", cfg) == "1.0");
// Zeta should have been invoked for realpkg only
string za;
() @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }();
assert(za.strip() == "realpkg",
"expected zeta invoked with 'realpkg', got '" ~ za.strip() ~ "'");
}
// ════════════════════════════════════
// Test (5): upgrade failure → failure collected, others continue, exit 1
// ════════════════════════════════════
@safe unittest
{
auto tmp = makeTempDir("failure5");
scope (exit) removeDir(tmp);
makeRecipeDir(tmp, "goodpkg", "2.0");
// Need a separate recipe cache dir for badpkg explicitly
auto badDir = buildPath(tmp, "recipes", "badpkg");
() @trusted { mkdirRecurse(badDir); }();
sWrite(buildPath(badDir, "badpkg.recipe"),
`return { name = "badpkg", version = "2.0" }`);
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "http://127.0.0.1:1";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeConditionalZeta(tmp, "badpkg", "1"); // zeta fails for badpkg
cfg.defaultJobs = 1;
recordInstall("goodpkg", "1.0", cfg);
recordInstall("badpkg", "1.0", cfg);
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, "", true),
cfg,
delegate PackageIndex[](Config _) @safe {
PackageIndex g; g.name = "goodpkg"; g.ver = "2.0"; g.pool = Pool.recipes;
PackageIndex b; b.name = "badpkg"; b.ver = "2.0"; b.pool = Pool.recipes;
return [g, b];
});
assert(ec == 1, "expected 1 for failure, got " ~ ec.to!string);
// goodpkg should be upgraded
assert(installedVersion("goodpkg", cfg) == "2.0",
"goodpkg should be upgraded");
// badpkg should remain at old version (install failed)
assert(installedVersion("badpkg", cfg) == "1.0",
"badpkg should remain at 1.0, got " ~ installedVersion("badpkg", cfg));
}
// ════════════════════════════════════
// Test (6): confirmation denied → aborted, nothing upgraded
// ════════════════════════════════════
// NOTE: This test must be LAST because stdin redirection
// affects subsequent tests.
@safe unittest
{
auto tmp = makeTempDir("abort6");
scope (exit) removeDir(tmp);
makeRecipeDir(tmp, "abortpkg", "2.0");
Config cfg;
cfg.cacheDir = tmp;
cfg.zuurUrl = "http://127.0.0.1:1";
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
cfg.zetaPath = makeFakeZeta(tmp);
cfg.defaultJobs = 1;
recordInstall("abortpkg", "1.0", cfg);
// Redirect stdin to "n\n"
File savedStdin;
() @trusted
{
savedStdin = stdin;
auto stdinFile = tmp ~ "/stdin-n.txt";
sWrite(stdinFile, "n\n");
stdin = File(stdinFile, "r");
}();
scope (exit) () @trusted { stdin = savedStdin; }();
auto ec = upgradeCommand(
ParsedArgs(Command.upgrade, ""), // noconfirm=false
cfg,
delegate PackageIndex[](Config _) @safe {
PackageIndex e; e.name = "abortpkg"; e.ver = "2.0"; e.pool = Pool.recipes;
return [e];
});
assert(ec == 0, "expected 0 after abort, got " ~ ec.to!string);
// State should be unchanged
assert(installedVersion("abortpkg", cfg) == "1.0",
"abortpkg should remain at 1.0, got " ~ installedVersion("abortpkg", cfg));
// Zeta args file should NOT exist (no install was attempted)
assert(!() @trusted { return exists(tmp ~ "/zeta-args.txt"); }(),
"zeta should NOT have been invoked");
}
+508
View File
@@ -0,0 +1,508 @@
/// tofu config — environment variable and TOML-based configuration loading.
///
/// Priority: environment variables > TOML config file > hardcoded defaults.
module tofu.config;
import toml;
import std.file : readText, write, remove, tempDir;
import std.path : expandTilde, buildPath;
import std.process : environment, thisProcessID;
import std.conv : to, ConvException;
import std.string : strip;
import std.stdio : stderr;
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown on fatal configuration errors.
class ConfigException : Exception {
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow {
super(message, file, line);
}
}
// ─── Config struct ───────────────────────────────────────────────────────────
/// Holds all runtime configuration for the tofu package manager.
///
/// Fields are populated by `load()` with priority: env override > TOML > default.
struct Config {
/// Base URL for the ZUUR repository.
string zuurUrl;
/// Local cache directory (resolved absolute path).
string cacheDir;
/// Path to the zeta-toolchain binary (empty = find on PATH).
string zetaToolchainPath;
/// Path to the ZETA binary (empty = find on PATH).
string zetaPath;
/// Default number of parallel build jobs (1 = serial).
int defaultJobs;
// ── URL helpers ──────────────────────────────────────────────────────
pure @safe nothrow
string recipesUrl(string name) const {
return zuurUrl ~ "/recipes/" ~ name;
}
pure @safe nothrow
string binaryManifestUrl(string name) const {
return zuurUrl ~ "/binary/packages/" ~ name ~ "/package.lua";
}
pure @safe nothrow
string indexUrl() const {
return zuurUrl ~ "/index.lua";
}
// ── Cache path helpers ───────────────────────────────────────────────
pure @safe nothrow
string recipesCacheDir(string name) const {
return cacheDir ~ "/recipes/" ~ name;
}
pure @safe nothrow
string builtDir() const {
return cacheDir ~ "/built";
}
pure @safe nothrow
string builtPackagesDir() const {
return cacheDir ~ "/built/packages";
}
}
// ─── Private helpers ─────────────────────────────────────────────────────────
/// Read an environment variable, falling back to a default.
/// When `envOverrides` is non-null, uses it instead of real env (for tests).
private @trusted
string getEnv(string key, string defaultValue,
const string[string] envOverrides) {
if (envOverrides !is null && (key in envOverrides))
return envOverrides[key];
auto val = environment.get(key);
return val.length ? val : defaultValue;
}
/// Parse an integer from a string, falling back with a stderr warning.
private @safe
int parseIntOr(string raw, int defaultValue, string name) {
auto s = raw.strip;
if (s.length == 0)
return defaultValue;
try {
return to!int(s);
} catch (ConvException) {
() @trusted {
stderr.writefln(
"Warning: invalid %s '%s', using default %d",
name, s, defaultValue);
}();
return defaultValue;
}
}
// ─── Load ────────────────────────────────────────────────────────────────────
/// Load configuration with standard priority: env > TOML file > defaults.
///
/// Params:
/// configFile = override config file path (default: from TOFU_CONFIG env or
/// ~/.config/tofu/config.toml)
/// envOverrides = environment var map for testing (null = use real process env)
///
/// Returns:
/// Populated `Config` struct. Never throws — malformed config falls back to
/// defaults with a warning to stderr.
Config load(string configFile = null,
const string[string] envOverrides = null) @safe {
// ── 1. Hardcoded defaults ────────────────────────────────────────────
Config cfg;
cfg.zuurUrl = "https://files.spectoria.dev/zuur";
cfg.cacheDir = expandTilde("~/.cache/tofu");
cfg.zetaToolchainPath = "";
cfg.zetaPath = "";
cfg.defaultJobs = 1;
// ── 2. Resolve config file path ──────────────────────────────────────
string tomlPath;
if (configFile.length > 0) {
tomlPath = configFile;
} else {
auto tofuConfig = getEnv("TOFU_CONFIG", "", envOverrides);
if (tofuConfig.length > 0) {
tomlPath = tofuConfig;
} else {
auto home = getEnv("HOME", "", envOverrides);
if (home.length > 0)
tomlPath = home ~ "/.config/tofu/config.toml";
else
tomlPath = ".config/tofu/config.toml";
}
}
// ── 3. Try reading & parsing TOML config file ────────────────────────
string content;
bool hasContent = false;
() @trusted {
try {
content = readText(tomlPath);
hasContent = true;
} catch (Exception) {
// File missing or unreadable — not an error, use defaults.
}
}();
TOMLDocument doc;
bool hasDoc = false;
if (hasContent) {
try {
() @trusted { doc = parseTOML(content); }();
hasDoc = true;
} catch (TOMLParserException e) {
() @trusted {
stderr.writefln(
"Warning: malformed TOML config at %s: %s",
tomlPath, e.msg);
}();
} catch (Exception) {
() @trusted {
stderr.writefln(
"Warning: could not parse TOML config at %s, "
~ "using defaults", tomlPath);
}();
}
}
// ── 4. Apply TOML [core] values ──────────────────────────────────────
if (hasDoc) {
// TOML string value helper
auto getStr = (string key) {
auto cv = "core" in doc;
if (cv is null || cv.type != TOML_TYPE.TABLE)
return string.init;
auto v = key in *cv;
if (v is null || v.type != TOML_TYPE.STRING)
return string.init;
return v.str;
};
// TOML int value helper
auto getInt = (string key) {
auto cv = "core" in doc;
if (cv is null || cv.type != TOML_TYPE.TABLE)
return long.min;
auto v = key in *cv;
if (v is null || v.type != TOML_TYPE.INTEGER)
return long.min;
return v.integer;
};
auto tUrl = getStr("zuur_url");
if (tUrl.length > 0) cfg.zuurUrl = tUrl;
auto tCache = getStr("cache_dir");
if (tCache.length > 0) cfg.cacheDir = tCache;
auto tToolchain = getStr("zeta_toolchain_path");
if (tToolchain.length > 0) cfg.zetaToolchainPath = tToolchain;
auto tZeta = getStr("zeta_path");
if (tZeta.length > 0) cfg.zetaPath = tZeta;
auto tJobs = getInt("default_jobs");
if (tJobs != long.min)
{
if (tJobs < 1 || tJobs > 1024)
{
// Out of range (negative or absurdly large) — cast(int)
// would silently truncate; fall back to the default.
() @trusted {
stderr.writefln(
"Warning: invalid default_jobs %d in config, "
~ "using default 1", tJobs);
}();
cfg.defaultJobs = 1;
}
else
{
cfg.defaultJobs = cast(int) tJobs;
}
}
}
// ── 5. Override with environment variables (highest priority) ────────
auto zuurUrlEnv = getEnv("TOFU_ZUUR_URL", "", envOverrides);
if (zuurUrlEnv.length > 0) cfg.zuurUrl = zuurUrlEnv;
auto cacheDirEnv = getEnv("TOFU_CACHE_DIR", "", envOverrides);
if (cacheDirEnv.length > 0) cfg.cacheDir = cacheDirEnv;
auto zetaToolchainEnv = getEnv("TOFU_ZETA_TOOLCHAIN_PATH", "", envOverrides);
if (zetaToolchainEnv.length > 0) cfg.zetaToolchainPath = zetaToolchainEnv;
auto zetaPathEnv = getEnv("TOFU_ZETA_PATH", "", envOverrides);
if (zetaPathEnv.length > 0) cfg.zetaPath = zetaPathEnv;
auto defaultJobsEnv = getEnv("TOFU_DEFAULT_JOBS", "", envOverrides);
if (defaultJobsEnv.length > 0)
cfg.defaultJobs = parseIntOr(defaultJobsEnv, 1, "TOFU_DEFAULT_JOBS");
return cfg;
}
// ─── Unittests ───────────────────────────────────────────────────────────────
/// Empty env-override map for tests.
private @safe const(string[string]) emptyEnv() {
const string[string] m;
return m;
}
/// Write a test TOML file (trusted wrapper).
private @trusted void writeTestFile(string path, string content) {
write(path, content);
}
/// Remove a test file, ignoring errors (trusted wrapper).
private @trusted void removeTestFile(string path) {
try { remove(path); } catch (Exception) {}
}
/// Build a unique temp file path for tests.
private @safe string testTempPath(string suffix) {
return buildPath(tempDir, "tofu-test-config-" ~ suffix
~ "-" ~ thisProcessID.to!string ~ ".toml");
}
// ── Test (1): No env vars, no config file → all defaults.
@safe unittest {
auto cfg = load(null, emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
assert(cfg.cacheDir == expandTilde("~/.cache/tofu"));
assert(cfg.zetaToolchainPath == "");
assert(cfg.zetaPath == "");
assert(cfg.defaultJobs == 1);
}
// ── Test (2): TOFU_ZUUR_URL override wins over default.
@safe unittest {
const string[string] env = ["TOFU_ZUUR_URL": "https://custom.example.com/zuur"];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://custom.example.com/zuur");
}
// ── Test (3): Config file values used when env vars unset.
@safe unittest {
auto tmp = testTempPath("toml-only");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://toml.example.com/zuur"
cache_dir = "/tmp/tofu-cache"
zeta_toolchain_path = "/usr/bin/zeta-toolchain"
zeta_path = "/usr/bin/zeta"
default_jobs = 4
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.zuurUrl == "https://toml.example.com/zuur");
assert(cfg.cacheDir == "/tmp/tofu-cache");
assert(cfg.zetaToolchainPath == "/usr/bin/zeta-toolchain");
assert(cfg.zetaPath == "/usr/bin/zeta");
assert(cfg.defaultJobs == 4);
}
// ── Test (3b): Env var wins over TOML.
@safe unittest {
auto tmp = testTempPath("env-over-toml");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://toml.example.com/zuur"
]");
const string[string] env = ["TOFU_ZUUR_URL": "https://env.example.com/zuur"];
auto cfg = load(tmp, env);
assert(cfg.zuurUrl == "https://env.example.com/zuur");
}
// ── Test (4): Malformed TOML → defaults + warning (no crash).
@safe unittest {
auto tmp = testTempPath("bad");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, "[core\nzuur_url = totally broken @@@");
auto cfg = load(tmp, emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
assert(cfg.defaultJobs == 1);
}
// ── Test (5): URL helper methods produce correct paths.
@safe unittest {
const string[string] env = ["TOFU_ZUUR_URL": "https://example.com/zuur"];
auto cfg = load(null, env);
assert(cfg.recipesUrl("firefox") ==
"https://example.com/zuur/recipes/firefox");
assert(cfg.indexUrl() ==
"https://example.com/zuur/index.lua");
assert(cfg.binaryManifestUrl("firefox") ==
"https://example.com/zuur/binary/packages/firefox/package.lua");
}
// ── Test (5b): Cache dir helpers produce correct paths.
@safe unittest {
const string[string] env = ["TOFU_CACHE_DIR": "/tmp/tofu-test-cache"];
auto cfg = load(null, env);
assert(cfg.recipesCacheDir("firefox") ==
"/tmp/tofu-test-cache/recipes/firefox");
assert(cfg.builtDir() == "/tmp/tofu-test-cache/built");
assert(cfg.builtPackagesDir() == "/tmp/tofu-test-cache/built/packages");
}
// ── Test (6): TOFU_CONFIG env overrides config file path.
@safe unittest {
auto tmp = testTempPath("tofu-config");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
zuur_url = "https://alt-toml.example.com/zuur"
]");
const string[string] env = ["TOFU_CONFIG": tmp];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://alt-toml.example.com/zuur");
}
// ── Test (6b): Explicit configFile parameter overrides TOFU_CONFIG env.
@safe unittest {
auto explicitTmp = testTempPath("explicit");
auto ignoredTmp = testTempPath("ignored");
scope (exit) { removeTestFile(explicitTmp); removeTestFile(ignoredTmp); }
writeTestFile(explicitTmp, q"[
[core]
zuur_url = "https://explicit.example.com/zuur"
]");
writeTestFile(ignoredTmp, q"[
[core]
zuur_url = "https://ignored.example.com/zuur"
]");
const string[string] env = ["TOFU_CONFIG": ignoredTmp];
auto cfg = load(explicitTmp, env);
assert(cfg.zuurUrl == "https://explicit.example.com/zuur");
}
// ── Extra: TOFU_DEFAULT_JOBS with garbage → default + warning.
@safe unittest {
const string[string] env = ["TOFU_DEFAULT_JOBS": "not-a-number"];
auto cfg = load(null, env);
assert(cfg.defaultJobs == 1);
}
// ── Extra: TOFU_DEFAULT_JOBS valid integer.
@safe unittest {
const string[string] env = ["TOFU_DEFAULT_JOBS": "8"];
auto cfg = load(null, env);
assert(cfg.defaultJobs == 8);
}
// ── Extra: default_jobs = 5000000000 (overflow) → fallback to 1.
@safe unittest {
auto tmp = testTempPath("jobs-overflow");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
default_jobs = 5000000000
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.defaultJobs == 1);
}
// ── Extra: default_jobs = 1024 (max valid) → accepted.
@safe unittest {
auto tmp = testTempPath("jobs-max");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
default_jobs = 1024
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.defaultJobs == 1024);
}
// ── Extra: default_jobs = 2048 (> 1024) → fallback to 1.
@safe unittest {
auto tmp = testTempPath("jobs-too-big");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
default_jobs = 2048
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.defaultJobs == 1);
}
// ── Extra: default_jobs negative → fallback to 1.
@safe unittest {
auto tmp = testTempPath("jobs-negative");
scope (exit) removeTestFile(tmp);
writeTestFile(tmp, q"[
[core]
default_jobs = -3
]");
auto cfg = load(tmp, emptyEnv());
assert(cfg.defaultJobs == 1);
}
// ── Extra: Missing config file is not an error.
@safe unittest {
auto cfg = load("/nonexistent/tofu/config.toml", emptyEnv());
assert(cfg.zuurUrl == "https://files.spectoria.dev/zuur");
}
// ── Extra: All env vars set simultaneously.
@safe unittest {
const string[string] env = [
"TOFU_ZUUR_URL": "https://all-env.example.com/zuur",
"TOFU_CACHE_DIR": "/tmp/all-env-cache",
"TOFU_ZETA_TOOLCHAIN_PATH": "/opt/zeta-toolchain",
"TOFU_ZETA_PATH": "/opt/zeta",
"TOFU_DEFAULT_JOBS": "16"
];
auto cfg = load(null, env);
assert(cfg.zuurUrl == "https://all-env.example.com/zuur");
assert(cfg.cacheDir == "/tmp/all-env-cache");
assert(cfg.zetaToolchainPath == "/opt/zeta-toolchain");
assert(cfg.zetaPath == "/opt/zeta");
assert(cfg.defaultJobs == 16);
}
+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);
}
+600
View File
@@ -0,0 +1,600 @@
/// tofu.errors — Shared error helpers, exit-code mapping, and lock-file management.
///
/// Provides:
/// - `TofuError` base class with `exitCode` field for direct-exit exceptions.
/// - `exitCodeFor(Exception)` — maps every known exception type to the correct
/// exit code per the plan's exit-code table.
/// - `acquireLock` / `releaseLock` / `isLocked` — PID-based lock file at
/// `<cacheDir>/.lock` to prevent concurrent tofu invocations.
///
/// All public APIs are `@safe`. Filesystem operations are isolated behind
/// `@trusted` helpers following the project convention.
module tofu.errors;
import tofu.build : BuildException;
import tofu.install : InstallException;
import tofu.fetch : FetchException;
import tofu.http : HttpException;
import tofu.index : IndexException;
import tofu.deps : DepException;
import tofu.resolve : ResolveException;
import tofu.config : ConfigException;
import tofu.cli : CliException;
import std.process : thisProcessID;
import std.file : exists, readText, write, remove, mkdirRecurse;
import std.path : buildPath;
import std.conv : to, ConvException;
import std.stdio : stderr;
import std.string : indexOf;
version (Posix) {
import core.sys.posix.signal : kill;
import core.sys.posix.sys.types : pid_t;
import core.stdc.errno : ESRCH, errno;
}
// ────────────────────────────────────────────────────────────
// TofuError — base for exceptions carrying an explicit exit code
// ────────────────────────────────────────────────────────────
/// An exception that carries its own exit code. Direct callers throw this
/// instead of the module-specific exception when they already know the exit
/// code; `exitCodeFor` checks for it first.
class TofuError : Exception
{
int exitCode;
@safe this(int exitCode, string msg, string file = __FILE__,
size_t line = __LINE__)
{
this.exitCode = exitCode;
super(msg, file, line);
}
}
// ────────────────────────────────────────────────────────────
// Exit-code mapping
// ────────────────────────────────────────────────────────────
/// Map any exception to the appropriate exit code per the plan's table:
///
/// | Code | Meaning |
/// |------|---------|
/// | 0 | Success |
/// | 1 | Generic error (invalid args, usage) |
/// | 2 | Package not found in ZUUR |
/// | 3 | Dependency resolution failure |
/// | 4 | Build failure |
/// | 5 | Install failure |
/// | 6 | Network error |
/// | 7 | Config / tool-not-found error |
/// | 130 | SIGINT (handled separately in main) |
///
/// Marker checks on exception fields (`toolMissing`, `notFound`) allow
/// distinguishing sub-cases (e.g. tool-missing → 7 vs build failure → 4).
@safe int exitCodeFor(Exception e)
{
// ── TofuError carries its own exit code ─────────────────
{
auto te = cast(TofuError) e;
if (te !is null)
return te.exitCode;
}
// ── Build errors ────────────────────────────────────────
{
auto be = cast(BuildException) e;
if (be !is null)
return be.toolMissing ? 7 : 4;
}
// ── Install errors ──────────────────────────────────────
{
auto ie = cast(InstallException) e;
if (ie !is null)
return ie.toolMissing ? 7 : 5;
}
// ── Fetch errors ────────────────────────────────────────
{
auto fe = cast(FetchException) e;
if (fe !is null)
return fe.notFound ? 2 : 6;
}
// ── HTTP / network errors ───────────────────────────────
{
auto he = cast(HttpException) e;
if (he !is null)
return 6;
}
{
auto ie = cast(IndexException) e;
if (ie !is null)
return 6;
}
// ── Resolution errors ───────────────────────────────────
{
auto de = cast(DepException) e;
if (de !is null)
return 3;
}
{
auto re = cast(ResolveException) e;
if (re !is null)
return 3;
}
// ── Config errors ───────────────────────────────────────
{
auto ce = cast(ConfigException) e;
if (ce !is null)
return 7;
}
// ── CLI / usage errors ─────────────────────────────────
{
auto ce = cast(CliException) e;
if (ce !is null)
return 1;
}
// ── Generic fallback ────────────────────────────────────
return 1;
}
// ────────────────────────────────────────────────────────────
// Lock-file exception
// ────────────────────────────────────────────────────────────
/// Thrown when another tofu process holds the lock.
class LockException : Exception
{
@safe this(string msg, string file = __FILE__, size_t line = __LINE__)
{
super(msg, file, line);
}
}
// ────────────────────────────────────────────────────────────
// Trusted filesystem helpers
// ────────────────────────────────────────────────────────────
private @trusted bool lockFileExists(string path)
{
return exists(path);
}
private @trusted string readLockFile(string path)
{
return readText(path);
}
private @trusted void writeLockFile(string path, string content)
{
write(path, content);
}
private @trusted void removeLockFile(string path)
{
try
{
if (exists(path))
remove(path);
}
catch (Exception) {}
}
/// Ensure the cache directory exists, creating it if necessary.
/// Idempotent — `mkdirRecurse` succeeds if the directory already exists.
private @trusted void ensureCacheDir(string cacheDir)
{
try
{
mkdirRecurse(cacheDir);
}
catch (Exception e)
{
// Permission denied, path isn't a directory, etc.
throw new LockException(
"cannot create cache directory " ~ cacheDir ~ ": " ~ e.msg);
}
}
// ────────────────────────────────────────────────────────────
// PID-liveness check
// ────────────────────────────────────────────────────────────
/// Check whether a PID is alive by sending signal 0.
/// Returns `true` if the PID exists, `false` if no such process.
version (Posix)
private @trusted bool pidAlive(int pid)
{
errno = 0;
int result = kill(cast(pid_t) pid, 0);
if (result == 0)
return true;
if (errno == ESRCH)
return false;
// Permission error or other — assume alive (conservative)
return true;
}
else
private @safe bool pidAlive(int pid)
{
// Non-POSIX fallback: always assume alive (conservative)
return true;
}
// ────────────────────────────────────────────────────────────
// Public lock API
// ────────────────────────────────────────────────────────────
/// Build the lock-file path for a given cache directory.
private @safe string lockPath(string cacheDir)
{
return buildPath(cacheDir, ".lock");
}
/// Check whether the lock is held by a live process.
/// Returns `true` if another tofu instance is running, `false` otherwise.
/// If the lock file exists but the PID inside is dead, the lock is considered
/// stale — `isLocked` returns `false` and stale detection is handled by
/// `acquireLock`.
@safe bool isLocked(string cacheDir)
{
auto path = lockPath(cacheDir);
if (!lockFileExists(path))
return false;
string content;
try
{
content = readLockFile(path);
}
catch (Exception)
{
return false;
}
int pid;
try
{
pid = content.to!int;
}
catch (ConvException)
{
// Corrupted lock — treat as not locked (acquireLock fixes it)
return false;
}
return pidAlive(pid);
}
/// Acquire the tofu lock at `<cacheDir>/.lock`.
///
/// If the lock is held by a live process → throws `LockException`.
/// If the lock is stale (dead PID) → removes it and proceeds.
/// If no lock exists → creates one with the current PID.
/// Returns `true` on successful acquisition.
@safe bool acquireLock(string cacheDir)
{
import tofu.log : logWarn;
// Ensure the cache directory exists before writing the lock file.
// On a fresh system ~/.cache/tofu may not exist yet.
ensureCacheDir(cacheDir);
auto path = lockPath(cacheDir);
auto myPid = thisProcessID.to!string;
if (lockFileExists(path))
{
string content;
try
{
content = readLockFile(path);
}
catch (Exception)
{
// Corrupted lock file — remove and proceed
removeLockFile(path);
writeLockFile(path, myPid);
return true;
}
int pid;
try
{
pid = content.to!int;
}
catch (ConvException)
{
// Corrupted lock content — remove and proceed
removeLockFile(path);
writeLockFile(path, myPid);
return true;
}
if (!pidAlive(pid))
{
// Stale lock — the PID is dead
logWarn("removing stale lock (PID %d not alive)", pid);
removeLockFile(path);
writeLockFile(path, myPid);
return true;
}
// Live lock — another tofu is running
throw new LockException(
"another tofu process is running (lock: " ~ path
~ ", PID " ~ content ~ ")");
}
// No lock file — create one
writeLockFile(path, myPid);
return true;
}
/// Release the tofu lock by removing the lock file.
/// Best-effort — exceptions during removal are silently ignored.
@safe void releaseLock(string cacheDir)
{
removeLockFile(lockPath(cacheDir));
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.file : tempDir, mkdirRecurse, rmdirRecurse;
import std.process : thisProcessID;
import std.path : buildPath;
import std.conv : to;
/// Create a unique temp directory for lock-file tests.
private @trusted string makeCacheDir(string suffix)
{
auto path = buildPath(tempDir, "tofu-errors-test-" ~ suffix
~ "-" ~ thisProcessID.to!string);
if (exists(path))
rmdirRecurse(path);
mkdirRecurse(path);
return path;
}
/// Recursively remove a directory, ignoring errors.
private @trusted void removeCacheDir(string path)
{
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Remove a lock artifact, ignoring errors.
private @trusted void removeLockArtifact(string cacheDir)
{
auto p = lockPath(cacheDir);
try { if (exists(p)) remove(p); } catch (Exception) {}
}
}
// ── Test (1): exitCodeFor — HttpException → 6 ──────────────
@safe unittest
{
auto e = new HttpException("HTTP 404 fetching https://example.com");
assert(exitCodeFor(e) == 6);
}
// ── Test (2): exitCodeFor — BuildException → 4 ─────────────
@safe unittest
{
auto e = new BuildException("build failed for foo: error");
assert(exitCodeFor(e) == 4);
}
// ── Test (3): exitCodeFor — BuildException(toolMissing) → 7 ─
@safe unittest
{
auto be = new BuildException("zeta-makepkg not found");
be.toolMissing = true;
assert(exitCodeFor(be) == 7);
}
// ── Test (4): exitCodeFor — InstallException → 5 ───────────
@safe unittest
{
auto e = new InstallException("install failed for foo: error");
assert(exitCodeFor(e) == 5);
}
// ── Test (5): exitCodeFor — InstallException(toolMissing) → 7
@safe unittest
{
auto ie = new InstallException("zeta not found");
ie.toolMissing = true;
assert(exitCodeFor(ie) == 7);
}
// ── Test (6): exitCodeFor — FetchException(notFound) → 2 ───
@safe unittest
{
auto fe = new FetchException("package 'hello' not found in ZUUR recipes");
fe.notFound = true;
assert(exitCodeFor(fe) == 2);
}
// ── Test (7): exitCodeFor — FetchException(no marker) → 6 ──
@safe unittest
{
auto fe = new FetchException("connection refused");
assert(exitCodeFor(fe) == 6);
}
// ── Test (8): exitCodeFor — DepException → 3 ────────────────
@safe unittest
{
auto e = new DepException("dependency cycle: A -> B -> A");
assert(exitCodeFor(e) == 3);
}
// ── Test (9): exitCodeFor — ResolveException → 3 ────────────
@safe unittest
{
auto e = new ResolveException(
"dependency 'libfoo' not found in ZUUR");
assert(exitCodeFor(e) == 3);
}
// ── Test (10): exitCodeFor — ConfigException → 7 ────────────
@safe unittest
{
auto e = new ConfigException("missing required config key");
assert(exitCodeFor(e) == 7);
}
// ── Test (11): exitCodeFor — IndexException → 6 ─────────────
@safe unittest
{
auto e = new IndexException("ZUUR index is invalid");
assert(exitCodeFor(e) == 6);
}
// ── Test (12): exitCodeFor — TofuError(explicit) bypass ─────
@safe unittest
{
auto te = new TofuError(42, "custom exit");
assert(exitCodeFor(te) == 42);
// TofuError is also an Exception → exitCodeFor should check it first
Exception e = te;
assert(exitCodeFor(e) == 42);
}
// ── Test (13): exitCodeFor — generic Exception → 1 ──────────
@safe unittest
{
auto e = new Exception("unknown error");
assert(exitCodeFor(e) == 1);
}
// ── Test (14): lock — no lock → acquire succeeds ────────────
@safe unittest
{
auto tmp = makeCacheDir("lock-acquire");
scope (exit) {
removeLockArtifact(tmp);
removeCacheDir(tmp);
}
assert(acquireLock(tmp));
assert(lockFileExists(lockPath(tmp)));
// Release
releaseLock(tmp);
assert(!lockFileExists(lockPath(tmp)));
}
// ── Test (15): lock — stale lock (dead PID) → removed ───────
@safe unittest
{
auto tmp = makeCacheDir("lock-stale");
scope (exit) {
removeLockArtifact(tmp);
removeCacheDir(tmp);
}
// Write a lock file with a PID that's almost certainly dead
writeLockFile(lockPath(tmp), "99999999");
// Acquire should detect the stale lock, remove it, and create a new one
assert(acquireLock(tmp));
// Read the new lock content
auto content = readLockFile(lockPath(tmp));
assert(content.to!int != 99999999, "Stale lock should have been replaced");
releaseLock(tmp);
}
// ── Test (16): lock — live lock → LockException ─────────────
@safe unittest
{
auto tmp = makeCacheDir("lock-live");
scope (exit) {
removeLockArtifact(tmp);
removeCacheDir(tmp);
}
// Use the current PID to simulate a live lock
writeLockFile(lockPath(tmp), thisProcessID.to!string);
bool caught = false;
try
{
acquireLock(tmp);
assert(false, "Expected LockException");
}
catch (LockException e)
{
caught = true;
assert(e.msg.indexOf("another tofu process is running") >= 0,
"Expected 'another tofu process is running', got: " ~ e.msg);
assert(e.msg.indexOf(thisProcessID.to!string) >= 0,
"Expected PID in message");
}
assert(caught, "Should have thrown LockException");
// Clean up manually (our PID owns the lock)
releaseLock(tmp);
}
// ── Test (17): lock — corrupted lock content → ignored ──────
@safe unittest
{
auto tmp = makeCacheDir("lock-corrupt");
scope (exit) {
removeLockArtifact(tmp);
removeCacheDir(tmp);
}
writeLockFile(lockPath(tmp), "not-a-number");
assert(acquireLock(tmp),
"Should acquire lock when lock content is corrupted");
releaseLock(tmp);
}
// ── Test (18): lock — isLocked checks liveness ──────────────
@safe unittest
{
auto tmp = makeCacheDir("lock-islocked");
scope (exit) {
removeLockArtifact(tmp);
removeCacheDir(tmp);
}
// No lock file → not locked
assert(!isLocked(tmp));
// Dead PID → not locked
writeLockFile(lockPath(tmp), "99999999");
assert(!isLocked(tmp));
// Live PID → locked
removeLockArtifact(tmp);
writeLockFile(lockPath(tmp), thisProcessID.to!string);
assert(isLocked(tmp));
releaseLock(tmp);
}
// ── Test (19): exitCodeFor — BuildException with stderr details → still 4 ──
@safe unittest
{
auto e = new BuildException("build failed for hello:\nerror: no space left on device");
assert(exitCodeFor(e) == 4);
}
// ── Test (20): exitCodeFor — CliException → 1 ──────────────
@safe unittest
{
auto e = new CliException("unknown option: -Z");
assert(exitCodeFor(e) == 1);
}
+746
View File
@@ -0,0 +1,746 @@
/// tofu.fetch — ZUUR recipe download into local cache.
///
/// Downloads recipe directories from the ZUUR repository using a
/// known-file strategy: .recipe (required), package.lua (optional),
/// build.sh (optional), and any custom build_script referenced in the
/// recipe. No directory listing is assumed.
///
/// Light recipe scanning extracts `build_script` and `build_system`
/// fields via simple string matching — full Lua parsing is done by a
/// later module (task 9 or 10).
///
/// All public APIs are `@safe`; filesystem operations are isolated
/// behind `@trusted` helpers.
module tofu.fetch;
import tofu.http;
import tofu.config;
import tofu.types;
import tofu.log;
import std.file;
import std.path;
import std.format;
import std.string;
// (no std.algorithm needed — use indexOf for string search)
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown when a recipe cannot be fetched — 404 on the .recipe
/// file means the package is not in the ZUUR repository; other
/// errors are propagated with their original message.
class FetchException : Exception
{
/// True when the error is "package not found in ZUUR recipes"
/// (→ exit code 2, not 6). Set by the 404-on-.recipe throw site.
bool notFound = false;
@safe this(string msg)
{
super(msg);
}
@safe this(string msg, string file, size_t line)
{
super(msg, file, line);
}
}
// ────────────────────────────────────────────────────────────
// Light .recipe scanner
// ────────────────────────────────────────────────────────────
/// Light scan of a .recipe file (Lua `return { ... }` table) to
/// extract the `build_script` path. Only returns a value when
/// `build_system = "custom"` is also found.
///
/// This is NOT a full Lua parser — full parsing is done by a later
/// module. We use simple string scanning: find the key, skip
/// whitespace and `=`, then read the quoted string value.
private @safe string extractBuildScript(string content)
{
// Step 1 — check that build_system is "custom"
auto sysVal = extractKeyValue(content, "build_system");
if (sysVal.length == 0 || sysVal != "custom")
return null;
// Step 2 — extract build_script path
auto bsVal = extractKeyValue(content, "build_script");
return bsVal;
}
/// Validate a `build_script` path extracted from a recipe before it is
/// used to build a URL or a local cache destination.
///
/// Rejects paths that could escape the package cache directory or the
/// ZUUR URL path: `..` segments, absolute paths (leading `/`), and
/// backslashes (Windows-style separators).
///
/// Throws `FetchException` for unsafe paths.
private @safe void validateBuildScriptPath(string path)
{
if (path.indexOf("..") >= 0
|| (path.length > 0 && path[0] == '/')
|| path.indexOf("\\") >= 0)
{
throw new FetchException("build_script path is unsafe: " ~ path);
}
}
/// Find `key = "..."` in Lua-like content and return the quoted
/// string value. Returns `null` when the key is not found or
/// the value is not a quoted string.
private @safe string extractKeyValue(string content, string key)
{
size_t searchFrom = 0;
while (true)
{
auto idx = indexOf(content[searchFrom .. $], key);
if (idx < 0)
return null;
auto pos = searchFrom + idx + key.length;
// Skip whitespace after key name
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect '='
if (pos >= content.length || content[pos] != '=')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip '='
// Skip whitespace after '='
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect opening double-quote
if (pos >= content.length || content[pos] != '"')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip opening quote
// Read until closing double-quote
auto valStart = pos;
while (pos < content.length && content[pos] != '"')
pos++;
if (pos >= content.length)
return null;
return content[valStart .. pos];
}
}
/// @safe predicate: is the character whitespace?
private @safe bool isWhite(char c)
{
return c == ' ' || c == '\t';
}
// ────────────────────────────────────────────────────────────
// Cleanup helper
// ────────────────────────────────────────────────────────────
/// Remove a list of created files, ignoring errors. Used for
/// partial-download cleanup on failure.
private void cleanupFiles(string[] paths) @trusted
{
foreach (p; paths)
{
try
{
if (exists(p))
remove(p);
}
catch (Exception) {}
}
}
// ────────────────────────────────────────────────────────────
// fetchRecipe — main public API
// ────────────────────────────────────────────────────────────
/// Download the ZUUR recipe for `name` into the local cache
/// directory `cfg.recipesCacheDir(name)`.
///
/// Download strategy (known-file — no directory listing):
/// 1. `{recipesUrl}/{name}.recipe` → required, 404 = "package not found"
/// 2. `{recipesUrl}/package.lua` → optional, warn on 404
/// 3. `{recipesUrl}/build.sh` → optional, warn on 404
/// 4. Parse .recipe for `build_system = "custom"` /
/// `build_script = "..."` → download referenced script
/// (404 on a referenced build script is a real error)
///
/// Returns the path to the downloaded `.recipe` file on success.
///
/// Throws `FetchException` on any unrecoverable error. On failure
/// of the primary .recipe download, any partially-created files are
/// removed.
@safe string fetchRecipe(string name, tofu.config.Config cfg)
{
logStep("fetching recipe %s", name);
auto cacheDir = cfg.recipesCacheDir(name);
auto recipeUrl = cfg.recipesUrl(name) ~ "/" ~ name ~ ".recipe";
auto recipeDest = cacheDir ~ "/" ~ name ~ ".recipe";
// Track created files so we can clean up on failure
string[] createdFiles;
try
{
// Ensure base cache directory exists
() @trusted {
if (!exists(cacheDir))
mkdirRecurse(cacheDir);
}();
// ── Step 1: .recipe file (REQUIRED) ────────────────
logDetail("downloading %s.recipe", name);
try
{
downloadFile(recipeUrl, recipeDest);
}
catch (HttpException e)
{
if (e.msg.indexOf("HTTP 404") >= 0)
{
auto fe = new FetchException(
format("package '%s' not found in ZUUR recipes", name));
fe.notFound = true;
throw fe;
}
throw new FetchException(e.msg);
}
createdFiles ~= recipeDest;
// ── Step 2: package.lua (OPTIONAL) ──────────────────
auto pkgLuaUrl = cfg.recipesUrl(name) ~ "/package.lua";
auto pkgLuaDest = cacheDir ~ "/package.lua";
try
{
logDetail("downloading package.lua");
downloadFile(pkgLuaUrl, pkgLuaDest);
createdFiles ~= pkgLuaDest;
}
catch (HttpException e)
{
if (e.msg.indexOf("HTTP 404") >= 0)
{
logInfo("no package.lua for %s (optional manifest)", name);
}
else
{
throw new FetchException(e.msg);
}
}
// ── Step 3: build.sh (OPTIONAL) ─────────────────────
auto buildShUrl = cfg.recipesUrl(name) ~ "/build.sh";
auto buildShDest = cacheDir ~ "/build.sh";
try
{
logDetail("downloading build.sh");
downloadFile(buildShUrl, buildShDest);
createdFiles ~= buildShDest;
}
catch (HttpException e)
{
if (e.msg.indexOf("HTTP 404") >= 0)
{
logInfo("no build.sh for %s (optional build script)", name);
}
else
{
throw new FetchException(e.msg);
}
}
// ── Step 4: custom build_script (if referenced) ─────
string recipeContent;
() @trusted { recipeContent = readText(recipeDest); }();
auto buildScriptPath = extractBuildScript(recipeContent);
if (buildScriptPath.length > 0)
{
// Reject paths that escape the cache dir or the ZUUR URL path
// (.. segments, absolute paths, backslashes).
validateBuildScriptPath(buildScriptPath);
logDetail("custom build system: fetching %s", buildScriptPath);
auto bsUrl = cfg.recipesUrl(name) ~ "/" ~ buildScriptPath;
auto bsDest = cacheDir ~ "/" ~ buildScriptPath;
// Defense in depth: the resolved destination must stay inside
// the package cache directory.
if (!bsDest.startsWith(cacheDir ~ "/"))
throw new FetchException(
"build_script path is unsafe: " ~ buildScriptPath);
// Ensure parent directories for nested build scripts
() @trusted {
auto bsDir = bsDest.dirName;
if (bsDir.length > 0 && !exists(bsDir))
mkdirRecurse(bsDir);
}();
// This 404 IS an error — a referenced build script must exist
try
{
downloadFile(bsUrl, bsDest);
createdFiles ~= bsDest;
}
catch (HttpException e)
{
if (e.msg.indexOf("HTTP 404") >= 0)
{
throw new FetchException(
format("build script '%s' not found for package '%s'",
buildScriptPath, name));
}
throw new FetchException(e.msg);
}
}
logOk("recipe %s downloaded", name);
return recipeDest;
}
catch (FetchException e)
{
// Re-throw our own exceptions directly — no additional cleanup
// needed beyond what downloadFile already handles internally
throw e;
}
catch (HttpException e)
{
// Unexpected HTTP error — clean up partial files
cleanupFiles(createdFiles);
throw new FetchException(e.msg);
}
catch (Exception e)
{
cleanupFiles(createdFiles);
throw new FetchException(e.msg);
}
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@safe unittest
{
// ── extractBuildScript helpers ────────────────────────
assert(extractBuildScript(``).length == 0);
// build_system = "custom" + build_script = "build.sh"
assert(extractBuildScript(
`build_system = "custom"
build_script = "build.sh"`) == "build.sh");
// With whitespace variations
assert(extractBuildScript(
`build_system = "custom"
build_script = "scripts/build.sh"`) == "scripts/build.sh");
// build_system not "custom" → null
assert(extractBuildScript(
`build_system = "cmake"
build_script = "build.sh"`).length == 0);
// build_system = "custom" but no build_script → null
assert(extractBuildScript(
`build_system = "custom"`).length == 0);
// ── validateBuildScriptPath: reject traversal / absolute / backslash ──
foreach (bad; ["../../evil.sh", "/etc/passwd", "a\\b.sh", "..\\win.sh"])
{
bool threw = false;
try
{
validateBuildScriptPath(bad);
assert(false, "Expected rejection for: " ~ bad);
}
catch (FetchException e)
{
threw = true;
assert(e.msg.indexOf("build_script path is unsafe") >= 0,
"Expected unsafe-path message, got: " ~ e.msg);
}
assert(threw, "validateBuildScriptPath should reject: " ~ bad);
}
// Valid relative paths pass without throwing
validateBuildScriptPath("build.sh");
validateBuildScriptPath("scripts/build.sh");
validateBuildScriptPath("tools/build-1.2.sh");
}
version (unittest)
{
import std.process : spawnProcess, kill, Pid, thisProcessID;
import std.socket;
import std.file;
import std.conv;
import core.thread;
import std.datetime;
/// Bind an ephemeral port then close it — return the port number.
private ushort findFreePort() @trusted
{
auto s = new TcpSocket();
s.bind(new InternetAddress("127.0.0.1", InternetAddress.PORT_ANY));
auto addr = cast(InternetAddress) s.localAddress();
auto port = addr.port;
s.close();
return port;
}
/// Poll until a TCP connection to `port` succeeds (server is ready).
private void waitForPort(ushort port) @trusted
{
for (int i = 0; i < 50; i++)
{
try
{
auto sock = new TcpSocket();
sock.connect(new InternetAddress("127.0.0.1", port));
sock.close();
return;
}
catch (Throwable)
{
Thread.sleep(100.msecs);
}
}
throw new Exception(
"Timed out waiting for server on port " ~ port.to!string);
}
/// Kill a process by PID (no-op on failure).
private void killServer(Pid pid) @trusted
{
import core.sys.posix.signal : SIGTERM;
try { kill(pid, SIGTERM); } catch (Throwable) {}
}
/// Write `content` to `path` inside `baseDir`, creating parent
/// dirs as needed. `@trusted` wrapper for filesystem ops.
private void writeTestFile(string baseDir, string relPath,
string content) @trusted
{
auto fullPath = buildPath(baseDir, relPath);
auto parentDir = fullPath.dirName;
if (parentDir.length > 0 && !exists(parentDir))
mkdirRecurse(parentDir);
write(fullPath, content);
}
/// Create a temporary directory and return its path.
private string makeTempDir(string suffix) @trusted
{
import std.process : thisProcessID;
auto dir = buildPath(tempDir(),
"tofu-fetch-" ~ suffix ~ "-"
~ thisProcessID.to!string);
if (exists(dir))
rmdirRecurse(dir);
mkdirRecurse(dir);
return dir;
}
/// Recursively remove a directory tree.
private void removeDir(string path) @trusted
{
try { rmdirRecurse(path); } catch (Throwable) {}
}
/// Build a Config struct that uses a local test server as ZUUR.
private @safe tofu.config.Config testConfig(string baseUrl, string cacheDir)
{
tofu.config.Config cfg;
cfg.zuurUrl = baseUrl;
cfg.cacheDir = cacheDir;
return cfg;
}
}
// ── Test 1: happy path — recipe + package.lua + build.sh all downloaded
@safe unittest
{
// ── Setup temp dir with recipe files ──────────────────
auto serveDir = makeTempDir("happy");
scope (exit) removeDir(serveDir);
writeTestFile(serveDir, "recipes/hello/hello.recipe",
"return { name = 'hello', build_system = 'make' }");
writeTestFile(serveDir, "recipes/hello/package.lua",
"return { version = '1.0' }");
writeTestFile(serveDir, "recipes/hello/build.sh",
"#!/bin/sh\necho ok");
// ── Start python3 http.server ─────────────────────────
auto port = findFreePort();
Pid pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
auto baseUrl = "http://127.0.0.1:" ~ port.to!string;
// ── Fetch ─────────────────────────────────────────────
auto cacheDir = makeTempDir("cache-happy");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(baseUrl, cacheDir);
auto recipePath = fetchRecipe("hello", cfg);
// Verify recipe path returned
assert(recipePath == cacheDir ~ "/recipes/hello/hello.recipe",
"Expected recipe path, got: " ~ recipePath);
// Verify all three files exist in cache
bool allOk = () @trusted {
return exists(cacheDir ~ "/recipes/hello/hello.recipe")
&& exists(cacheDir ~ "/recipes/hello/package.lua")
&& exists(cacheDir ~ "/recipes/hello/build.sh");
}();
assert(allOk, "All three files should exist in cache");
}
// ── Test 2: 404 on .recipe → FetchException "package not found"
@safe unittest
{
auto serveDir = makeTempDir("nf");
scope (exit) removeDir(serveDir);
// Empty recipes directory — no hello dir → 404 on .recipe
() @trusted { mkdirRecurse(buildPath(serveDir, "recipes")); }();
auto port = findFreePort();
auto pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
auto baseUrl = "http://127.0.0.1:" ~ port.to!string;
auto cacheDir = makeTempDir("cache-nf");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(baseUrl, cacheDir);
bool caught = false;
try
{
fetchRecipe("hello", cfg);
assert(false, "Expected FetchException");
}
catch (FetchException e)
{
caught = true;
assert(e.msg.indexOf("package 'hello' not found in ZUUR recipes") >= 0,
"Expected 'package not found' message, got: " ~ e.msg);
}
assert(caught, "Should have thrown FetchException");
}
// ── Test 3: custom build system — build_script fetched from subdir
@safe unittest
{
auto serveDir = makeTempDir("custom");
scope (exit) removeDir(serveDir);
writeTestFile(serveDir, "recipes/hello/hello.recipe",
`return {
name = "hello",
build_system = "custom",
build_script = "scripts/build.sh"
}`);
writeTestFile(serveDir, "recipes/hello/scripts/build.sh",
"#!/bin/sh\necho custom build");
auto port = findFreePort();
auto pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
auto baseUrl = "http://127.0.0.1:" ~ port.to!string;
auto cacheDir = makeTempDir("cache-custom");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(baseUrl, cacheDir);
auto recipePath = fetchRecipe("hello", cfg);
// Verify custom build script was downloaded
bool scriptOk = () @trusted {
return exists(cacheDir ~ "/recipes/hello/hello.recipe")
&& exists(cacheDir ~ "/recipes/hello/scripts/build.sh");
}();
assert(scriptOk,
"Both .recipe and scripts/build.sh should exist in cache");
}
// ── Test 4: 404 on package.lua / build.sh → no error, recipe still fetched
@safe unittest
{
auto serveDir = makeTempDir("opt404");
scope (exit) removeDir(serveDir);
// Only the .recipe file exists — no package.lua or build.sh
writeTestFile(serveDir, "recipes/hello/hello.recipe",
"return { name = 'hello', build_system = 'make' }");
auto port = findFreePort();
auto pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
auto baseUrl = "http://127.0.0.1:" ~ port.to!string;
auto cacheDir = makeTempDir("cache-opt404");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(baseUrl, cacheDir);
auto recipePath = fetchRecipe("hello", cfg);
// Should succeed — recipe downloaded, optional files merely warned
assert(recipePath == cacheDir ~ "/recipes/hello/hello.recipe");
bool recipeExists = () @trusted {
return exists(cacheDir ~ "/recipes/hello/hello.recipe");
}();
assert(recipeExists, "Recipe file should exist");
}
// ── Test 5: recipe download connection error → no partial files in cache
@safe unittest
{
auto serveDir = makeTempDir("fail");
scope (exit) removeDir(serveDir);
// Start a server that serves files, but point the config at a
// different (dead) port so the recipe download fails with a
// connection error.
auto port = findFreePort();
auto pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
// Use a dead port (nothing listening) for the recipe URL
auto deadPort = findFreePort(); // closed immediately, nothing listening
auto deadUrl = "http://127.0.0.1:" ~ deadPort.to!string;
auto cacheDir = makeTempDir("cache-fail");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(deadUrl, cacheDir);
bool caught = false;
try
{
fetchRecipe("hello", cfg);
assert(false, "Expected FetchException for connection error");
}
catch (FetchException e)
{
caught = true;
assert(e.msg.length > 0, "Should have an error message");
}
assert(caught, "Should have thrown FetchException");
// Verify no partial files were left in the cache directory
bool cacheClean = () @trusted {
// The cache dir may exist (mkdirRecurse) but should have no files
auto recipeDir = cacheDir ~ "/recipes/hello";
if (!exists(recipeDir))
return true;
// Check for files inside
import std.file : dirEntries, SpanMode;
foreach (de; dirEntries(recipeDir, SpanMode.shallow))
return false; // any entry = not clean
return true;
}();
assert(cacheClean,
"Cache directory should be clean after failed download");
}
// ── Test 6: malicious build_script path → FetchException, no escape
@safe unittest
{
auto serveDir = makeTempDir("evil");
scope (exit) removeDir(serveDir);
writeTestFile(serveDir, "recipes/hello/hello.recipe",
`return {
name = "hello",
build_system = "custom",
build_script = "../../evil.sh"
}`);
auto port = findFreePort();
auto pid = () @trusted {
return spawnProcess(
["python3", "-m", "http.server", port.to!string,
"--bind", "127.0.0.1"],
workDir: serveDir);
}();
scope (exit) killServer(pid);
waitForPort(port);
auto baseUrl = "http://127.0.0.1:" ~ port.to!string;
auto cacheDir = makeTempDir("cache-evil");
scope (exit) removeDir(cacheDir);
auto cfg = testConfig(baseUrl, cacheDir);
bool caught = false;
try
{
fetchRecipe("hello", cfg);
assert(false, "Expected FetchException for unsafe build_script");
}
catch (FetchException e)
{
caught = true;
assert(e.msg.indexOf("build_script path is unsafe") >= 0,
"Expected unsafe-path message, got: " ~ e.msg);
}
assert(caught, "Should have thrown FetchException");
// Verify nothing escaped the cache dir
bool noEscape = () @trusted {
return !exists(buildPath(serveDir, "evil.sh"))
&& !exists(buildPath(cacheDir, "evil.sh"));
}();
assert(noEscape, "No file should be written outside the cache dir");
}
+356
View File
@@ -0,0 +1,356 @@
/// tofu.http — synchronous HTTP client using `std.net.curl`.
///
/// Provides `get()` and `downloadFile()` with configurable timeouts,
/// redirect following, and a custom User-Agent. All public APIs are
/// `@safe`; the `@system` curl internals are isolated behind `@trusted`
/// helpers.
///
/// URL scheme: Both `http://` and `https://` URLs are accepted. The
/// test harness requires `http://` for local mock servers; production
/// configuration points at HTTPS.
///
/// Callers catch `HttpException` and translate to appropriate exit
/// codes — this module does NOT log; it only throws.
module tofu.http;
import std.net.curl;
import std.file;
import std.path;
import std.conv;
import std.stdio;
import core.time;
import std.format;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown on any HTTP error (status >= 400), connection failure,
/// timeout, or curl-level error.
class HttpException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Public API (@safe — @trusted wrappers isolate curl)
// ────────────────────────────────────────────────────────────
/// Perform a GET request to `url` and return the response body as a string.
///
/// Throws `HttpException` on:
/// - HTTP status code >= 400 (includes status code and url)
/// - Connection failure
/// - Timeout (connect 30s, operation 120s)
/// - Any curl-level error
@safe string get(string url)
{
return getImpl(url);
}
/// Download `url` to `destPath` on disk.
///
/// Creates parent directories as needed. Downloads to a `.part` temp
/// file first, renames on success, and removes the partial file on
/// any error. Returns `destPath` on success.
///
/// Throws `HttpException` on any HTTP, connection, or timeout error.
@safe string downloadFile(string url, string destPath)
{
return downloadFileImpl(url, destPath);
}
// ────────────────────────────────────────────────────────────
// Constants
// ────────────────────────────────────────────────────────────
private enum uint maxRedirectsCount = 5;
private enum string userAgentString = "tofu/0.1.0";
// ────────────────────────────────────────────────────────────
// @trusted helpers — all @system curl calls are isolated here
// ────────────────────────────────────────────────────────────
/// Apply common settings to an HTTP handle.
private @trusted void configure(HTTP http)
{
http.connectTimeout = 30.seconds;
http.operationTimeout = 120.seconds;
http.maxRedirects = maxRedirectsCount;
http.setUserAgent(userAgentString);
}
/// Core GET implementation — all curl interaction happens here.
private @trusted string getImpl(string url)
{
auto http = HTTP();
configure(http);
http.url = url;
string content;
int statusCode;
http.onReceive = (ubyte[] data)
{
content ~= cast(string) data;
return data.length;
};
http.onReceiveStatusLine = (HTTP.StatusLine l)
{
statusCode = l.code;
};
try
{
http.perform();
}
catch (CurlTimeoutException e)
{
throw new HttpException(
"cannot reach ZUUR at " ~ url ~ ": timeout after 120s");
}
catch (CurlException e)
{
throw new HttpException(e.msg);
}
if (statusCode >= 400)
{
throw new HttpException(
format("HTTP %d fetching %s", statusCode, url));
}
return content;
}
/// Core download implementation — .part temp file + rename.
private @trusted string downloadFileImpl(string url, string destPath)
{
import std.file : mkdirRecurse, rename, exists, remove;
// Create parent directories
auto dir = destPath.dirName;
if (dir.length > 0 && !exists(dir))
mkdirRecurse(dir);
auto tmpPath = destPath ~ ".part";
auto f = File(tmpPath, "wb");
scope (failure)
{
if (exists(tmpPath))
remove(tmpPath);
}
auto http = HTTP();
configure(http);
http.url = url;
int statusCode;
http.onReceive = (ubyte[] data)
{
f.rawWrite(data);
return data.length;
};
http.onReceiveStatusLine = (HTTP.StatusLine l)
{
statusCode = l.code;
};
try
{
http.perform();
f.close();
}
catch (CurlTimeoutException e)
{
throw new HttpException(
"cannot reach ZUUR at " ~ url ~ ": timeout after 120s");
}
catch (CurlException e)
{
throw new HttpException(e.msg);
}
if (statusCode >= 400)
{
throw new HttpException(
format("HTTP %d fetching %s", statusCode, url));
}
// Atomically rename partial → final
try
{
if (exists(destPath))
remove(destPath);
rename(tmpPath, destPath);
}
catch (Exception e)
{
if (exists(tmpPath))
remove(tmpPath);
throw new HttpException(
"Failed to finalize download to " ~ destPath ~ ": " ~ e.msg);
}
return destPath;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.socket;
import std.concurrency;
import std.string;
import std.algorithm.searching : canFind;
/// Spawn a one-shot TCP server that sends `response` to the
/// first connecting client then exits.
private static void oneShotResponder(shared TcpSocket listener,
string response) @trusted
{
try
{
auto sock = (cast() listener).accept();
// drain the request
ubyte[8192] buf = void;
sock.receive(buf[]);
sock.send(cast(immutable(ubyte)[]) response);
sock.close();
}
catch (Throwable) {}
}
/// Bind a listener on an ephemeral port and return the URL.
private static auto bindAndSpawn(string response) @trusted
{
auto listener = new TcpSocket();
listener.bind(new InternetAddress("127.0.0.1",
InternetAddress.PORT_ANY));
listener.listen(1);
auto port = listener.localAddress().toPortString();
auto url = "http://127.0.0.1:" ~ port ~ "/";
spawn(&oneShotResponder, cast(shared) listener, response);
return url;
}
/// Build a minimal HTTP response string.
private static string httpResponse(int code, string reason, string body) @trusted
{
return format(
"HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s",
code, reason, body.length, body);
}
}
// ── Test 1: GET against a local server, verify body ────
@safe unittest
{
auto url = bindAndSpawn(httpResponse(200, "OK", "hello tofu"));
auto body = get(url);
assert(body == "hello tofu",
"Expected 'hello tofu' but got: '" ~ body ~ "'");
}
// ── Test 2: 404 → HttpException with correct message ────
@safe unittest
{
auto url = bindAndSpawn(httpResponse(404, "Not Found", "gone"));
bool caught = false;
try
{
get(url);
assert(false, "Expected HttpException for 404");
}
catch (HttpException e)
{
caught = true;
assert(e.msg.canFind("HTTP 404"),
"Message should contain 'HTTP 404', got: " ~ e.msg);
assert(e.msg.canFind(url),
"Message should contain url, got: " ~ e.msg);
}
assert(caught, "Should have thrown HttpException");
}
// ── Test 3: downloadFile writes complete file ───────────
@safe unittest
{
import std.file : tempDir, exists, readText;
import std.path : buildPath;
import std.process : thisProcessID;
auto url = bindAndSpawn(httpResponse(200, "OK", "downloaded content!"));
auto dest = buildPath(tempDir(), "tofu_test_dl_" ~ to!string(thisProcessID()));
scope (exit)
{
if (exists(dest))
(() @trusted => remove(dest))();
}
auto result = downloadFile(url, dest);
assert(result == dest, "Should return destPath");
assert(exists(dest), "File should exist after download");
string content;
(() @trusted { content = readText(dest); })();
assert(content == "downloaded content!",
"File content mismatch, got: '" ~ content ~ "'");
}
// ── Test 4: connection refused → HttpException ──────────
@safe unittest
{
import std.socket;
// Get an ephemeral port then close it so nothing listens
auto dead = new TcpSocket();
dead.bind(new InternetAddress("127.0.0.1", InternetAddress.PORT_ANY));
auto port = dead.localAddress().toPortString();
dead.close();
auto url = "http://127.0.0.1:" ~ port ~ "/";
bool caught = false;
try
{
get(url);
assert(false, "Expected HttpException for refused connection");
}
catch (HttpException e)
{
caught = true;
// Message should contain some curl-level error description
assert(e.msg.length > 0);
}
assert(caught, "Should have thrown HttpException");
}
// ── Test 5: GET with 500 status → HttpException ─────────
@safe unittest
{
auto url = bindAndSpawn(httpResponse(500, "Internal Server Error", "boom"));
bool caught = false;
try
{
get(url);
assert(false, "Expected HttpException for 500");
}
catch (HttpException e)
{
caught = true;
assert(e.msg.canFind("HTTP 500"),
"Message should contain 'HTTP 500', got: " ~ e.msg);
assert(e.msg.canFind(url),
"Message should contain url, got: " ~ e.msg);
}
assert(caught, "Should have thrown HttpException");
}
+694
View File
@@ -0,0 +1,694 @@
/// tofu.index — ZUUR index.lua fetch and sandboxed parse.
///
/// Downloads the repository index via `tofu.http.get`, then safely
/// executes it in a restricted Lua subprocess. The sandbox uses a
/// whitelist approach (ported from ZETA lib/sandbox.lua) — the index
/// file CANNOT access io, os, require, dofile, loadfile, loadstring,
/// package, debug, or any other dangerous global.
///
/// The Lua subprocess prints a JSON array that `std.json.parseJSON`
/// parses back into `PackageIndex[]` structs.
module tofu.index;
import tofu.config;
import tofu.types;
import tofu.http;
import tofu.log;
import std.process : execute, ProcessException, thisProcessID;
import std.stdio : File;
import std.json;
import std.file;
import std.path;
import std.conv;
import std.string;
import std.format;
import std.exception;
import std.algorithm.searching : canFind;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown when the ZUUR index cannot be fetched, the Lua sandbox
/// subprocess fails, or the returned JSON is unparseable.
class IndexException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Sandbox Lua script (embedded as string constant)
// ────────────────────────────────────────────────────────────
/// The sandbox loader script executed by the system `lua` interpreter.
///
/// ## Safety: whitelist approach
/// The index file receives ONLY the safe subset of _G that ZETA
/// lib/sandbox.lua defines: basic functions (assert, error, ipairs,
/// pairs, …), string/table/math libraries, and nothing more.
/// io, os, require, loadfile, dofile, loadstring, package, debug, and
/// any other escape hatch are absent from the sandbox environment.
///
/// ## Lua version compatibility
/// Lua 5.1 / LuaJIT: `loadstring` + `setfenv`.
/// Lua 5.2+: `load` with 4th arg `env`.
/// Detected via `if setfenv` — nil on 5.2+.
private enum sandboxLuaScript = q"SCRIPT
-- tofu sandbox loader — safe ZUUR index.lua execution
-- Reads the index file, compiles it under a whitelist sandbox,
-- and prints a JSON array of {name,ver,summary,pool} objects.
local index_path = arg[1]
-- ── Build whitelist sandbox (ported from ZETA lib/sandbox.lua) ──
local unpack_fn = unpack or table.unpack
local env = {
_VERSION = _VERSION,
assert = assert,
error = error,
ipairs = ipairs,
next = next,
pairs = pairs,
pcall = pcall,
select = select,
tonumber = tonumber,
tostring = tostring,
type = type,
rawequal = rawequal,
rawget = rawget,
rawset = rawset,
setmetatable = setmetatable,
getmetatable = getmetatable,
unpack = unpack_fn,
string = string,
table = table,
math = math,
}
if rawlen then env.rawlen = rawlen end
-- ── Read the index file contents ──
local f, ierr = io.open(index_path, "rb")
if not f then
print("LUA_ERROR:cannot open " .. tostring(index_path) .. ": " .. tostring(ierr))
os.exit(1)
end
local src = f:read("*a")
f:close()
-- ── Compile with sandbox environment ──
local chunk, cerr
if setfenv then
-- Lua 5.1 / LuaJIT
chunk, cerr = loadstring(src, "@" .. index_path)
if chunk then setfenv(chunk, env) end
else
-- Lua 5.2+ (including 5.5.x)
chunk, cerr = load(src, "@" .. index_path, "t", env)
end
if not chunk then
print("LUA_ERROR:syntax error: " .. tostring(cerr))
os.exit(1)
end
-- ── Execute sandboxed chunk ──
local ok, raw = pcall(chunk)
if not ok then
print("LUA_ERROR:runtime error: " .. tostring(raw))
os.exit(1)
end
if type(raw) ~= "table" then
print("LUA_ERROR:index did not return a table, got " .. type(raw))
os.exit(1)
end
-- ── JSON escape helper ──
local function esc(v)
local s = tostring(v or "")
-- Order matters: backslash before quote
s = s:gsub("\\", "\\\\")
s = s:gsub('"', '\\"')
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
return s
end
-- ── Convert to JSON array ──
local parts = {}
for _, e in ipairs(raw) do
if type(e) == "table" then
local name_ = esc(e.name)
-- Accept both "ver" (D field) and "version" (ZETA indexer field)
local ver_ = esc(e.ver or e.version or "")
local summary_ = esc(e.summary or "")
local pool_ = esc(e.pool or "both")
parts[#parts + 1] = string.format(
'{"name":"%s","ver":"%s","summary":"%s","pool":"%s"}',
name_, ver_, summary_, pool_
)
end
end
print("[" .. table.concat(parts, ",") .. "]")
SCRIPT";
// ────────────────────────────────────────────────────────────
// @trusted helpers — file I/O and process spawning
// ────────────────────────────────────────────────────────────
/// Write the sandbox Lua script to a temp file, return its path.
private @trusted string writeSandboxScript(string dir)
{
auto path = buildPath(dir, "tofu-sandbox-" ~ to!string(thisProcessID()) ~ ".lua");
auto f = File(path, "w");
f.write(sandboxLuaScript);
f.close();
return path;
}
/// Write index content to a temp file, return its path.
private @trusted string writeTempIndex(string dir, string content)
{
auto path = buildPath(dir, "tofu-index-" ~ to!string(thisProcessID()) ~ ".lua");
auto f = File(path, "w");
f.write(content);
f.close();
return path;
}
/// Remove a file, ignoring errors (best-effort cleanup).
private @trusted void removeFile(string path)
{
try
{
if (exists(path))
remove(path);
}
catch (Exception) {}
}
/// Spawn `lua [scriptPath] [indexPath]`, capture stdout.
/// Returns the full (newline-terminated) output on success.
/// Throws `IndexException` if the process exits non-zero or
/// cannot be found.
private @trusted string runLuaSandbox(string luaPath, string scriptPath,
string indexPath)
{
string output;
int status;
try
{
auto result = execute([luaPath, scriptPath, indexPath]);
status = result.status;
output = result.output;
}
catch (ProcessException e)
{
if (e.msg.canFind("execvp") || e.msg.canFind("Command not found")
|| e.msg.canFind("not found"))
{
throw new IndexException(
"ZUUR index is invalid: lua command not found on PATH");
}
throw new IndexException(
"ZUUR index is invalid: cannot run lua: " ~ e.msg);
}
if (status != 0)
{
// Check for LUA_ERROR: prefix — extract the detail
string detail = "unknown lua error";
if (output.canFind("LUA_ERROR:"))
{
auto idx = output.indexOf("LUA_ERROR:");
detail = output[idx + 10 .. $].strip();
}
else
{
detail = output.strip();
if (detail.length == 0)
detail = "lua exited with status " ~ to!string(status);
}
throw new IndexException("ZUUR index is invalid: " ~ detail);
}
return output;
}
// ────────────────────────────────────────────────────────────
// Core parser
// ────────────────────────────────────────────────────────────
/// Parse the JSON array printed by the sandbox script into
/// `PackageIndex[]`. Skips entries with empty name (with warning)
/// and entries with invalid pool values (with warning).
private @safe PackageIndex[] parseIndexJson(string jsonOutput)
{
PackageIndex[] results;
JSONValue parsed;
try
{
parsed = parseJSON(jsonOutput);
}
catch (JSONException e)
{
throw new IndexException(
"ZUUR index is invalid: cannot parse Lua output as JSON: " ~ e.msg);
}
if (parsed.type != JSONType.array)
throw new IndexException(
"ZUUR index is invalid: expected JSON array, got " ~ to!string(parsed.type));
JSONValue[] entries;
() @trusted { entries = parsed.array; }();
foreach (entry; entries)
{
if (entry.type != JSONType.object)
{
logWarn("skipping non-object index entry");
continue;
}
string name = entry["name"].str;
if (name.length == 0)
{
logWarn("skipping index entry with empty name");
continue;
}
string ver = entry["ver"].str;
string summary = entry["summary"].str;
string poolStr = entry["pool"].str;
// Validate pool
try
{
auto pool = fromPoolString(poolStr);
results ~= PackageIndex(name, ver, summary, pool);
}
catch (TypesException)
{
logWarn("skipping index entry '%s': invalid pool '%s'",
name, poolStr);
continue;
}
}
return results;
}
// ────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────
/// Fetch the ZUUR index from `cfg.indexUrl()`, parse it through
/// the Lua sandbox, and return a list of `PackageIndex` entries.
///
/// ## Security
/// The index is a Lua file obtained from a remote repository. A
/// malicious index could attempt `os.execute("rm -rf /")` or
/// `io.open("/etc/shadow")`. The sandbox strips all I/O, process-
/// spawning, and module-loading globals before executing the index
/// code, so those calls will fail with "attempt to call a nil value".
///
/// ## Error handling
/// - HTTP errors → `HttpException` (thrown by `tofu.http.get`)
/// - Lua subprocess fails → `IndexException` with detail
/// - Invalid JSON from lua → `IndexException`
/// - Individual entries with empty name or invalid pool → skipped
/// with `logWarn` (defensive parsing)
///
/// ## Logging
/// - `logDetail("fetching index <url>")` before request
/// - `logOk("index loaded: N packages")` on success
@safe PackageIndex[] fetchIndex(Config cfg)
{
return fetchIndexImpl(cfg, "lua");
}
/// Test-only entry point with an explicit lua binary path.
/// Production callers use `fetchIndex(Config)`.
version (unittest)
package @safe PackageIndex[] fetchIndexWithLua(Config cfg, string luaPath)
{
return fetchIndexImpl(cfg, luaPath);
}
/// Shared implementation.
private @safe PackageIndex[] fetchIndexImpl(Config cfg, string luaPath)
{
auto url = cfg.indexUrl();
logDetail("fetching index %s", url);
auto content = get(url);
auto tmpDir = tempDir();
auto indexPath = writeTempIndex(tmpDir, content);
scope (exit) removeFile(indexPath);
auto scriptPath = writeSandboxScript(tmpDir);
scope (exit) removeFile(scriptPath);
auto jsonOutput = runLuaSandbox(luaPath, scriptPath, indexPath);
auto results = parseIndexJson(jsonOutput);
logOk("index loaded: %d packages", results.length);
return results;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.socket;
import std.concurrency;
import std.file : tempDir, exists, readText;
import std.path : buildPath;
/// Spawn a one-shot TCP server that sends `response` to the
/// first connecting client then exits.
/// Pattern copied from tofu.http test harness.
private static void oneShotResponder(shared TcpSocket listener,
string response) @trusted
{
try
{
auto sock = (cast() listener).accept();
// drain the request
ubyte[8192] buf = void;
sock.receive(buf[]);
sock.send(cast(immutable(ubyte)[]) response);
sock.close();
}
catch (Throwable) {}
}
/// Bind a listener on an ephemeral port, spawn a one-shot
/// responder, and return the URL.
private static auto bindAndSpawn(string response) @trusted
{
auto listener = new TcpSocket();
listener.bind(new InternetAddress("127.0.0.1",
InternetAddress.PORT_ANY));
listener.listen(1);
auto port = listener.localAddress().toPortString();
auto url = "http://127.0.0.1:" ~ port ~ "/";
spawn(&oneShotResponder, cast(shared) listener, response);
return url;
}
/// Build a minimal HTTP response string.
private static string httpResponse(int code, string reason, string body) @trusted
{
return format(
"HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s",
code, reason, body.length, body);
}
/// Create a Config pointing at a local server URL.
private @safe Config testConfig(string baseUrl)
{
const string[string] env = ["TOFU_ZUUR_URL": baseUrl];
return load(null, env);
}
}
// ── Test 1: happy path — parse valid index ──────────────
@safe unittest
{
string indexBody = q"LUA
return {
{ name = "firefox", version = "120.0", summary = "Web browser", pool = "binary" },
{ name = "neovim", ver = "0.9.5", summary = "Text editor", pool = "both" },
{ name = "ripgrep", version = "14.1", summary = "Fast grep", pool = "recipes" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
auto results = fetchIndexWithLua(cfg, "lua");
assert(results.length == 3, "expected 3 entries, got " ~ to!string(results.length));
assert(results[0].name == "firefox");
assert(results[0].ver == "120.0");
assert(results[0].summary == "Web browser");
assert(results[0].pool == Pool.binary);
assert(results[1].name == "neovim");
assert(results[1].ver == "0.9.5");
assert(results[1].summary == "Text editor");
assert(results[1].pool == Pool.both);
assert(results[2].name == "ripgrep");
assert(results[2].ver == "14.1");
assert(results[2].summary == "Fast grep");
assert(results[2].pool == Pool.recipes);
}
// ── Test 2: malicious index — sandbox blocks os.execute ──
@safe unittest
{
// The index tries to delete /tmp. If the sandbox works, the
// call is "attempt to index a nil value (global 'os')" and the
// sentinel file below still exists after parsing.
string sentinelPath = buildPath(tempDir(), "tofu-sandbox-sentinel-" ~ to!string(thisProcessID()));
() @trusted { write(sentinelPath, "safe"); }();
scope (exit) () @trusted { if (exists(sentinelPath)) remove(sentinelPath); }();
assert(exists(sentinelPath), "sentinel must exist before test");
string indexBody = q"LUA
-- Malicious index attempting RCE via os.execute
os.execute("rm -rf /")
return {
{ name = "evil", version = "1.0", summary = "bomb", pool = "binary" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
// Should throw IndexException because os is nil in sandbox
bool caught = false;
try
{
fetchIndexWithLua(cfg, "lua");
assert(false, "expected IndexException for malicious index");
}
catch (IndexException e)
{
caught = true;
// The error should be about os being nil
assert(e.msg.canFind("ZUUR index is invalid"),
"message should contain 'ZUUR index is invalid', got: " ~ e.msg);
}
assert(caught, "should have thrown IndexException");
// CRITICAL: sentinel file must still exist (no RCE happened)
assert(exists(sentinelPath),
"SANDBOX FAILED: sentinel file is gone — os.execute was NOT blocked!");
}
// ── Test 3: malicious index — io.open blocked ───────────
@safe unittest
{
string indexBody = q"LUA
-- Malicious index attempting file read via io.open
io.open("/etc/shadow")
return {
{ name = "evil2", version = "1.0", summary = "bomb", pool = "binary" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
bool caught = false;
try
{
fetchIndexWithLua(cfg, "lua");
assert(false, "expected IndexException for malicious io.open");
}
catch (IndexException e)
{
caught = true;
}
assert(caught, "should have thrown IndexException for io.open attempt");
}
// ── Test 4: lua not found → IndexException ─────────────
@safe unittest
{
// Use a nonsense lua path
string indexBody = "return { { name = 'pkg', version = '1.0', summary = 'ok', pool = 'binary' } }";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
bool caught = false;
try
{
fetchIndexWithLua(cfg, "/nonexistent/tofu-fake-lua-binary");
assert(false, "expected IndexException for missing lua");
}
catch (IndexException e)
{
caught = true;
assert(e.msg.canFind("lua"),
"message should reference lua, got: " ~ e.msg);
}
assert(caught, "should have thrown IndexException");
}
// ── Test 5: invalid JSON from lua → IndexException ─────
// This test uses a syntactically valid Lua that produces bad JSON
// (e.g., the sandbox script crashed). We test the parser directly.
@safe unittest
{
bool caught = false;
try
{
parseIndexJson("not json at all");
assert(false, "expected IndexException for bad JSON");
}
catch (IndexException e)
{
caught = true;
assert(e.msg.canFind("cannot parse"), "expected parse error, got: " ~ e.msg);
}
assert(caught, "should have thrown IndexException");
// Also test non-array JSON
caught = false;
try
{
parseIndexJson(`{"name":"not-an-array"}`);
assert(false, "expected IndexException for non-array JSON");
}
catch (IndexException e)
{
caught = true;
assert(e.msg.canFind("expected JSON array"), "expected array error, got: " ~ e.msg);
}
assert(caught, "should have thrown IndexException");
}
// ── Test 6: empty index → empty array ──────────────────
@safe unittest
{
string indexBody = "return {}";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
auto results = fetchIndexWithLua(cfg, "lua");
assert(results.length == 0, "expected 0 entries, got " ~ to!string(results.length));
}
// ── Test 7: entries with empty name → skipped ──────────
@safe unittest
{
string indexBody = q"LUA
return {
{ name = "", version = "1.0", summary = "bad", pool = "binary" },
{ name = "good", version = "2.0", summary = "ok", pool = "both" },
{ name = "", version = "3.0", summary = "also bad", pool = "recipes" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
auto results = fetchIndexWithLua(cfg, "lua");
assert(results.length == 1, "expected 1 entry after skipping empties, got "
~ to!string(results.length));
assert(results[0].name == "good");
assert(results[0].ver == "2.0");
}
// ── Test 8: entries with invalid pool → skipped ────────
@safe unittest
{
string indexBody = q"LUA
return {
{ name = "valid", version = "1.0", summary = "ok", pool = "binary" },
{ name = "badpool", version = "2.0", summary = "nope", pool = "bad_pool_value" },
{ name = "also-valid", version = "3.0", summary = "yep", pool = "both" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
auto results = fetchIndexWithLua(cfg, "lua");
assert(results.length == 2, "expected 2 entries after skipping bad pool, got "
~ to!string(results.length));
assert(results[0].name == "valid");
assert(results[1].name == "also-valid");
}
// ── Test 9: lua syntax error in index → IndexException ──
@safe unittest
{
string indexBody = "this is not valid lua syntax @@@";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
bool caught = false;
try
{
fetchIndexWithLua(cfg, "lua");
assert(false, "expected IndexException for syntax error");
}
catch (IndexException e)
{
caught = true;
assert(e.msg.canFind("syntax error") || e.msg.canFind("ZUUR index is invalid"),
"expected error message, got: " ~ e.msg);
}
assert(caught, "should have thrown IndexException");
}
// ── Test 10: JSON string escaping — quotes and backslashes ──
@safe unittest
{
string indexBody = q"LUA
return {
{ name = "quote\"test", version = "1.0", summary = "has \"quotes\" and \\backslash", pool = "binary" },
}
LUA";
auto url = bindAndSpawn(httpResponse(200, "OK", indexBody));
auto cfg = testConfig(url);
auto results = fetchIndexWithLua(cfg, "lua");
assert(results.length == 1);
assert(results[0].name == `quote"test`);
assert(results[0].summary == `has "quotes" and \backslash`);
}
+708
View File
@@ -0,0 +1,708 @@
/// tofu.install — invoke ZETA to install built packages into the system.
///
/// Wraps `zeta -LocalProvide <pkgName> --pass` with:
/// - Environment: `ZETA_LOCAL_PACKAGES` (built packages dir) and
/// `ZETA_REPO` (binary pool URL), passed per-child via `pipeProcess`.
/// - ZETA_ROOT is intentionally NOT set (config has no such field).
/// - Real-time output streaming with a rolling 20-line buffer for
/// error reporting.
///
/// The per-child env is set via `pipeProcess`'s `env` parameter — the
/// child process inherits the parent env plus these overrides. No
/// parent-process environment mutation is needed.
module tofu.install;
import tofu.config;
import tofu.log;
import tofu.types : BuildPlan, BuildPlanEntry, Source;
// Selective imports to avoid `Config` name conflict between
// `std.process.Config` and `tofu.config.Config`.
import std.process : pipeProcess, ProcessPipes, Redirect, wait,
ProcessException;
import std.stdio : write, writeln, stdout;
import std.string : indexOf;
import std.conv : to;
import std.file : exists;
// ─── Exception ───────────────────────────────────────────────────────────────
/// Thrown when a package install fails.
class InstallException : Exception
{
/// True when the error is "zeta not found" (→ exit code 7,
/// not 5). Set by the throw site below.
bool toolMissing = false;
this(string message, string file = __FILE__, size_t line = __LINE__)
@safe pure nothrow
{
super(message, file, line);
}
}
// ─── Public API ──────────────────────────────────────────────────────────────
@safe:
/// Invoke `zeta -LocalProvide <pkgName> --pass` to install a previously-built
/// package from the local tree into the system.
///
/// The child process is spawned with `pipeProcess` so output is captured in
/// real-time: each line is written immediately to the parent's stdout and
/// buffered (last 20 lines) for error reporting.
///
/// Params:
/// pkgName = Name of the package to install (passed to `-LocalProvide`).
/// cfg = Loaded tofu configuration.
///
/// Throws:
/// InstallException if zeta exits non-zero or if the zeta binary cannot
/// be found.
///
/// "already installed" messages (ZETA `actions.localprovide` line 174–178)
/// produce exit code 0 — treated as a non-error with a logInfo note.
void runLocalProvide(string pkgName, Config cfg)
{
// ── 1. Determine zeta binary ────────────────────────────────────────
string zetaBin = cfg.zetaPath.length > 0 ? cfg.zetaPath : "zeta";
// ── 2. Build per-child environment ──────────────────────────────────
const string[string] childEnv = [
"ZETA_LOCAL_PACKAGES": cfg.builtPackagesDir(),
"ZETA_REPO": cfg.zuurUrl ~ "/binary",
];
// ZETA_ROOT intentionally not set — config has no ZETA_ROOT field.
// ── 3. Spawn child with piped stdout + stderr-into-stdout ───────────
ProcessPipes pipes;
() @trusted
{
try
{
pipes = pipeProcess(
[zetaBin, "-LocalProvide", pkgName, "--pass"],
Redirect.stdout | Redirect.stderrToStdout,
childEnv,
);
}
catch (ProcessException e)
{
auto ie = new InstallException(
"zeta not found. Install Zeta to use package management.");
ie.toolMissing = true;
throw ie;
}
}();
scope (exit)
{
() @trusted
{
try { pipes.stdout.close(); } catch (Exception) {}
}();
}
// ── 4. Read output in real-time (tee + rolling buffer) ─────────────
string[] rollingBuffer;
() @trusted
{
foreach (line; pipes.stdout.byLine)
{
string s = line.idup;
write(s); // line already ends with \n
stdout.flush();
rollingBuffer ~= s;
if (rollingBuffer.length > 20)
rollingBuffer = rollingBuffer[1 .. $];
}
}();
// ── 5. Wait for exit ───────────────────────────────────────────────
int exitStatus;
() @trusted { exitStatus = wait(pipes.pid); }();
// ── 6. Build last-lines string (for error / "already installed") ───
string lastLines;
foreach (line; rollingBuffer)
lastLines ~= line; // line already ends with \n
// ── 7. Handle result ───────────────────────────────────────────────
if (exitStatus == 0)
{
// ZETA actions.localprovide: already-installed + exit 0 → NOT an error.
if (lastLines.indexOf("already installed") >= 0)
{
logInfo("already installed — skipping");
}
return;
}
// Non-zero exit → InstallException with last 20 lines of output.
throw new InstallException(
"install failed for " ~ pkgName ~ ": " ~ lastLines);
}
/// Invoke `zeta -Remove <pkgName> --pass` to remove a package from the
/// system. Shares the same subprocess/tee/rolling-buffer pattern as
/// `runLocalProvide`.
///
/// Params:
/// pkgName = Name of the package to remove.
/// cfg = Loaded tofu configuration.
/// force = If true, appends `--force` to the args (bypasses ZETA's
/// reverse-dependency check).
///
/// Throws:
/// InstallException if zeta exits non-zero or if the zeta binary cannot
/// be found. Callers should check the exception message for the string
/// "still required by" to decide whether to suggest `--force`.
void runRemove(string pkgName, Config cfg, bool force = false)
{
// ── 1. Determine zeta binary ────────────────────────────────────────
string zetaBin = cfg.zetaPath.length > 0 ? cfg.zetaPath : "zeta";
// ── 2. Build per-child environment ──────────────────────────────────
const string[string] childEnv = [
"ZETA_LOCAL_PACKAGES": cfg.builtPackagesDir(),
"ZETA_REPO": cfg.zuurUrl ~ "/binary",
];
// ── 3. Build args ───────────────────────────────────────────────────
string[] args = [zetaBin, "-Remove", pkgName, "--pass"];
if (force)
args ~= "--force";
// ── 4. Spawn child with piped stdout + stderr-into-stdout ───────────
ProcessPipes pipes;
() @trusted
{
try
{
pipes = pipeProcess(
args,
Redirect.stdout | Redirect.stderrToStdout,
childEnv,
);
}
catch (ProcessException e)
{
auto ie = new InstallException(
"zeta not found. Install Zeta to use package management.");
ie.toolMissing = true;
throw ie;
}
}();
scope (exit)
{
() @trusted
{
try { pipes.stdout.close(); } catch (Exception) {}
}();
}
// ── 5. Read output in real-time (tee + rolling buffer) ─────────────
string[] rollingBuffer;
() @trusted
{
foreach (line; pipes.stdout.byLine)
{
string s = line.idup;
write(s);
stdout.flush();
rollingBuffer ~= s;
if (rollingBuffer.length > 20)
rollingBuffer = rollingBuffer[1 .. $];
}
}();
// ── 6. Wait for exit ───────────────────────────────────────────────
int exitStatus;
() @trusted { exitStatus = wait(pipes.pid); }();
// ── 7. Build last-lines string ─────────────────────────────────────
string lastLines;
foreach (line; rollingBuffer)
lastLines ~= line;
// ── 8. Handle result ───────────────────────────────────────────────
if (exitStatus == 0)
return;
throw new InstallException(
"remove failed for " ~ pkgName ~ ": " ~ lastLines);
}
/// Install orchestrator: verify all recipe-built packages exist in the
/// built cache, then invoke `zeta -LocalProvide` on the **root** package.
/// ZETA's `deps.resolve` walks the full dependency tree internally —
/// tofu passes only the root, not individual dependencies.
///
/// Root = last entry in plan.order() (topological order: deps first,
/// target last — same convention as `tofu.deps` and `tofu.resolve`).
///
/// Params:
/// plan = build plan from `generateBuildPlan` (only recipe entries).
/// cfg = loaded tofu configuration.
///
/// Throws:
/// InstallException if any recipe-built package is missing from the
/// cache, or if the zeta invocation fails.
///
/// Empty plan is a no-op (logInfo + return).
///
/// "already installed" is handled by `runLocalProvide` — exit 0 with
/// the expected message produces a logInfo note and no throw.
void installAll(BuildPlan plan, Config cfg)
{
// ── 1. Empty plan → no-op ────────────────────────────────────────────
if (plan.isEmpty())
{
logInfo("nothing to install");
return;
}
auto order = plan.order();
// ── 2. Verify all recipe-built packages exist in cache ───────────────
// ZETA's deps.resolve fetches package.lua from ZETA_LOCAL_PACKAGES.
// If a dependency is missing, deps.resolve fails with an unclear
// error — pre-checking gives a clear tofu-level message.
foreach (entry; order)
{
if (entry.source == Source.recipe)
{
auto pkgPath = cfg.builtPackagesDir() ~ "/" ~ entry.name
~ "/package.lua";
bool pkgExists;
() @trusted { pkgExists = exists(pkgPath); }();
if (!pkgExists)
{
throw new InstallException(
"built package missing from cache: " ~ entry.name
~ " (was the build skipped?)");
}
}
}
// ── 3. Determine root (last entry — topological order) ──────────────
string rootName = order[$ - 1].name;
// ── 4. Call runLocalProvide for the root ONLY ───────────────────────
// ZETA's deps.resolve handles the full tree: recipe-built deps via
// ZETA_LOCAL_PACKAGES, binary deps via ZETA_REPO. Tofu does NOT
// iterate per package. InstallException propagates to caller.
runLocalProvide(rootName, cfg);
// ── 5. Success ──────────────────────────────────────────────────────
logOk("installed " ~ rootName ~ " with "
~ (order.length - 1).to!string ~ " dependencies");
}
// ─── Unittests ───────────────────────────────────────────────────────────────
private:
/// Write a shell script to the given path and make it executable.
@trusted void writeFakeScript(string path, string content)
{
import std.file : write, setAttributes;
write(path, content);
// chmod +x
import std.process : execute;
execute(["chmod", "+x", path]);
}
/// Remove a file, ignoring errors.
@trusted void removeFile(string path)
{
import std.file : remove;
try { remove(path); } catch (Exception) {}
}
/// Create a unique temp directory path for a test.
@trusted string makeTempDir(string suffix)
{
import std.path : buildPath;
import std.file : tempDir, mkdirRecurse;
import std.process : thisProcessID;
import std.conv : to;
auto path = buildPath(tempDir, "tofu-install-test-" ~ suffix
~ "-" ~ thisProcessID.to!string);
mkdirRecurse(path);
return path;
}
/// Remove a directory tree, ignoring errors.
@trusted void removeDir(string path)
{
import std.file : rmdirRecurse;
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a `Config` that points `zetaPath` at a fake script and uses
/// a given temp dir as cache root (so `builtPackagesDir()` resolves).
@safe Config testConfig(string zetaScript, string cacheDir)
{
import tofu.config : load;
const string[string] env = [
"TOFU_CACHE_DIR": cacheDir,
"TOFU_ZETA_PATH": zetaScript,
];
return load(null, env);
}
// ── Test (1): fake zeta exits 0 → returns, no throw ─────────────────────
@safe unittest
{
auto tmp = makeTempDir("exit0");
scope (exit) removeDir(tmp);
// Fake script that writes env vars to a file and exits cleanly.
auto scriptPath = tmp ~ "/fake-zeta";
import std.string : replace;
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES" >> __ENV_FILE__
echo "ZETA_REPO=$ZETA_REPO" >> __ENV_FILE__
echo "installing package..." >&2
exit 0
SCRIPT", "__ENV_FILE__", tmp ~ "/env.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
runLocalProvide("testpkg", cfg);
// Verify env vars were set correctly.
import std.file : readText;
string envContent;
() @trusted { envContent = readText(tmp ~ "/env.txt"); }();
assert(envContent.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0);
assert(envContent.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0);
}
// ── Test (2): fake zeta exits 1 → InstallException ──────────────────────
@safe unittest
{
auto tmp = makeTempDir("exit1");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "error: build failed"
echo "reason: missing dependency"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
try
{
runLocalProvide("badpkg", cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("install failed for badpkg") >= 0);
assert(e.msg.indexOf("error: build failed") >= 0);
assert(e.msg.indexOf("reason: missing dependency") >= 0);
}
}
// ── Test (3): "already installed" exit 0 → no throw, logInfo note ──────
@safe unittest
{
auto tmp = makeTempDir("already");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "testpkg-2.1 is already installed -- use -ReProvide to reinstall"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
// Should not throw.
runLocalProvide("testpkg", cfg);
}
// ── Test (4): zeta not found → InstallException ────────────────────────
@safe unittest
{
auto tmp = makeTempDir("notfound");
scope (exit) removeDir(tmp);
// Point at a nonexistent binary path.
auto cfg = testConfig(tmp ~ "/nonexistent-zeta", tmp);
try
{
runLocalProvide("testpkg", cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("zeta not found") >= 0);
}
}
// ── Test (5): env correctness (ZETA_LOCAL_PACKAGES, ZETA_REPO) ─────────
@safe unittest
{
auto tmp = makeTempDir("envcheck");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
// Script that dumps env vars and exits 0.
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "ZETA_LOCAL_PACKAGES=$ZETA_LOCAL_PACKAGES"
echo "ZETA_REPO=$ZETA_REPO"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
// Capture stdout during the call to verify env values in output.
import std.file : exists, remove, tempDir;
import std.stdio : File;
auto capturePath = tempDir ~ "/tofu-install-capture-" ~
() @trusted {
import std.process : thisProcessID;
import std.conv : to;
return thisProcessID.to!string;
}() ~ ".txt";
scope (exit) { () @trusted { if (exists(capturePath)) remove(capturePath); }(); }
// Capture stdout via file-swap (trusted — mirrors log.d's capture()).
() @trusted {
auto captureFile = File(capturePath, "w+");
auto savedStdout = stdout;
stdout = captureFile;
scope (exit) stdout = savedStdout;
runLocalProvide("testpkg", cfg);
stdout.flush();
}();
// Read captured output.
import std.file : readText;
string captured;
() @trusted { captured = readText(capturePath); }();
assert(captured.indexOf("ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()) >= 0,
"expected ZETA_LOCAL_PACKAGES=" ~ cfg.builtPackagesDir()
~ " in: " ~ captured);
assert(captured.indexOf("ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary") >= 0,
"expected ZETA_REPO=" ~ cfg.zuurUrl ~ "/binary"
~ " in: " ~ captured);
}
// ═══════════════════════════════════════════════════════════════════════════════
// installAll unittests
// ═══════════════════════════════════════════════════════════════════════════════
// ── Test (1): plan [B, C, A(root)] with all package.lua
// → fake zeta invoked with ONLY root name "A" ──────────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write, readText;
import std.string : strip, replace;
auto tmp = makeTempDir("installall-args");
scope (exit) removeDir(tmp);
// Create built package directories with package.lua files.
auto builtDir = tmp ~ "/built/packages";
foreach (name; ["A", "B", "C"])
{
auto pkgDir = builtDir ~ "/" ~ name;
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
}
// Fake zeta: capture package name arg ($2) to a file.
auto scriptPath = tmp ~ "/fake-zeta";
string scriptContent = replace(q"SCRIPT
#!/bin/bash
echo "$2" >> __ARGS_FILE__
exit 0
SCRIPT", "__ARGS_FILE__", tmp ~ "/args.txt");
writeFakeScript(scriptPath, scriptContent);
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("B", "/fake/B.recipe", Source.recipe);
plan.add("C", "/fake/C.recipe", Source.recipe);
plan.add("A", "/fake/A.recipe", Source.recipe);
installAll(plan, cfg);
// Verify fake zeta received ONLY "A" as the package name.
string argsContent;
() @trusted { argsContent = readText(tmp ~ "/args.txt"); }();
auto capturedName = argsContent.strip;
assert(capturedName == "A",
"expected root name 'A', got '" ~ capturedName ~ "'");
}
// ── Test (2): missing built package.lua → InstallException ──────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-missing");
scope (exit) removeDir(tmp);
// Create package.lua for C and A only — B is missing.
auto builtDir = tmp ~ "/built/packages";
foreach (name; ["A", "C"])
{
auto pkgDir = builtDir ~ "/" ~ name;
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
}
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("B", "/fake/B.recipe", Source.recipe);
plan.add("C", "/fake/C.recipe", Source.recipe);
plan.add("A", "/fake/A.recipe", Source.recipe);
try
{
installAll(plan, cfg);
assert(false, "expected InstallException for missing built package");
}
catch (InstallException e)
{
assert(e.msg.indexOf("built package missing from cache: B") >= 0,
"expected 'built package missing from cache: B' in: " ~ e.msg);
assert(e.msg.indexOf("was the build skipped?") >= 0);
}
}
// ── Test (3): empty plan → no-op (logInfo, return) ──────────────────────
@safe unittest
{
auto tmp = makeTempDir("installall-empty");
scope (exit) removeDir(tmp);
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "SHOULD NOT BE INVOKED"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan; // empty
installAll(plan, cfg); // Should not throw and not invoke zeta.
}
// ── Test (4): fake zeta exits 1 → InstallException ──────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-exit1");
scope (exit) removeDir(tmp);
auto pkgDir = tmp ~ "/built/packages/A";
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "install failed: conflict detected"
exit 1
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("A", "/fake/A.recipe", Source.recipe);
try
{
installAll(plan, cfg);
assert(false, "expected InstallException");
}
catch (InstallException e)
{
assert(e.msg.indexOf("install failed for A") >= 0,
"expected 'install failed for A' in: " ~ e.msg);
assert(e.msg.indexOf("install failed: conflict detected") >= 0);
}
}
// ── Test (5): "already installed" exit 0 → no throw ─────────────────────
@safe unittest
{
import std.file : mkdirRecurse, write;
auto tmp = makeTempDir("installall-already");
scope (exit) removeDir(tmp);
auto pkgDir = tmp ~ "/built/packages/A";
() @trusted { mkdirRecurse(pkgDir); }();
() @trusted { write(pkgDir ~ "/package.lua", "-- fake package\n"); }();
auto scriptPath = tmp ~ "/fake-zeta";
writeFakeScript(scriptPath, q"SCRIPT
#!/bin/bash
echo "testpkg-2.1 is already installed -- use -ReProvide to reinstall"
exit 0
SCRIPT");
auto cfg = testConfig(scriptPath, tmp);
BuildPlan plan;
plan.add("A", "/fake/A.recipe", Source.recipe);
installAll(plan, cfg); // Should not throw.
}
+242
View File
@@ -0,0 +1,242 @@
/// Colored, always-verbose logging for tofu, mirroring zeta-toolchain's
/// `toolchain/lib/log.lua`.
///
/// Every operation is printed so the user sees exactly what is happening.
/// Colors are disabled when `NO_COLOR` is set (to any value, including
/// empty) or when `TERM` is unset, empty, or `dumb`. All functions take a
/// format string plus optional arguments, e.g. `logStep("building %s", "foo")`,
/// and write immediately — nothing is buffered.
module tofu.log;
import core.stdc.stdlib : exit;
import std.format : format;
import std.process : environment;
import std.stdio : File, stdout, stderr;
/// ANSI escape sequences used by tofu. Values match zeta-toolchain exactly.
private enum ColorCodes
{
reset = "\x1b[0m",
cyan = "\x1b[36m",
green = "\x1b[32m",
yellow = "\x1b[33m",
red = "\x1b[31m",
dim = "\x1b[2m",
}
/// Whether the current terminal supports color output.
///
/// Re-reads the environment on every call so it reacts to runtime changes
/// and is trivially unit-testable without restarting the process. Returns
/// `false` when `NO_COLOR` is set, or when `TERM` is missing, empty, or
/// `dumb`.
bool colorEnabled() @safe
{
if (environment.get("NO_COLOR") !is null)
return false;
auto term = environment.get("TERM");
return term !is null && term.length > 0 && term != "dumb";
}
/// Wrap `text` in `code` and a trailing reset, or return it unchanged when
/// colors are disabled.
private string paint(string code, string text) @safe
{
return colorEnabled() ? code ~ text ~ ColorCodes.reset : text;
}
/// Write `line` (already newline-terminated by `writeln`) to stdout.
/// `@trusted`: `stdout` itself is `@system` to access in dmd 2.112.
private void writeStdout(string line) @trusted
{
stdout.writeln(line);
}
/// Write `line` to stderr. See `writeStdout`.
private void writeStderr(string line) @trusted
{
stderr.writeln(line);
}
/// `==> message` in cyan on stdout.
void logStep(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.cyan, "==> " ~ format(args)));
}
/// ` ok message` in green on stdout.
void logOk(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.green, " ok " ~ format(args)));
}
/// `warn message` in yellow on stderr.
void logWarn(A...)(A args) @safe
{
writeStderr(paint(ColorCodes.yellow, "warn " ~ format(args)));
}
/// `error message` in red on stderr.
void logError(A...)(A args) @safe
{
writeStderr(paint(ColorCodes.red, "error") ~ " " ~ format(args));
}
/// ` - message` plain on stdout.
void logInfo(A...)(A args) @safe
{
writeStdout(" - " ~ format(args));
}
/// ` . message` dim on stdout.
void logDetail(A...)(A args) @safe
{
writeStdout(paint(ColorCodes.dim, " . " ~ format(args)));
}
/// `error message` in red on stderr, then exits with status 1.
void logFatal(A...)(A args) @trusted
{
logError(args);
exit(1);
}
/// Test helper: set `NO_COLOR`/`TERM` for a color test and restore the
/// previous values on scope exit, so tests are independent of the ambient
/// environment. A `null` value removes the variable.
private struct ColorEnv
{
private
{
string oldNoColor;
string oldTerm;
bool hadNoColor;
bool hadTerm;
}
this(string noColor, string term) @trusted
{
auto nc = environment.get("NO_COLOR");
if (nc !is null)
{
hadNoColor = true;
oldNoColor = nc;
}
auto t = environment.get("TERM");
if (t !is null)
{
hadTerm = true;
oldTerm = t;
}
if (noColor is null)
environment.remove("NO_COLOR");
else
environment["NO_COLOR"] = noColor;
if (term is null)
environment.remove("TERM");
else
environment["TERM"] = term;
}
~this() @trusted
{
if (hadNoColor)
environment["NO_COLOR"] = oldNoColor;
else
environment.remove("NO_COLOR");
if (hadTerm)
environment["TERM"] = oldTerm;
else
environment.remove("TERM");
}
}
/// Captures stdout (or stderr) writes of a callback into a memory string by
/// temporarily swapping the global stream, mirroring the `(1)`/`(3)` tests.
private string capture(void delegate() dg) @trusted
{
import std.file : exists, remove, tempDir;
auto name = tempDir() ~ "/tofu-log-capture.tmp";
scope (exit) if (exists(name)) remove(name);
auto file = File(name, "w+");
auto savedStream = stdout;
stdout = file;
scope (exit) stdout = savedStream;
scope (failure) stdout = savedStream;
dg();
stdout.flush();
file.rewind();
return file.readln();
}
unittest
{
// (1) logStep paints a cyan "==> " line to stdout.
import std.algorithm.searching : canFind;
auto env = ColorEnv(null, "xterm-256color");
auto line = capture({ logStep("building %s", "foo"); });
assert(line.canFind("\x1b[36m"), "expected cyan escape in: `" ~ line ~ "`");
assert(line.canFind("==> building foo"),
"expected prefixed message in: `" ~ line ~ "`");
}
unittest
{
// (2) NO_COLOR, set to any value including empty, disables color.
auto env = ColorEnv(null, "xterm");
assert(colorEnabled(), "color should be on with a real TERM and no NO_COLOR");
auto env1 = ColorEnv("1", "xterm");
assert(!colorEnabled(), "NO_COLOR=1 must disable color");
auto env2 = ColorEnv("", "xterm");
assert(!colorEnabled(), "NO_COLOR set-but-empty must disable color");
}
unittest
{
// (3) logError writes a red "error " line to stderr.
import std.algorithm.searching : canFind;
import std.file : exists, remove, tempDir;
import std.stdio : stderr;
auto env = ColorEnv(null, "xterm-256color");
auto name = tempDir() ~ "/tofu-log-stderr.tmp";
scope (exit) if (exists(name)) remove(name);
auto file = File(name, "w+");
auto savedStream = stderr;
stderr = file;
scope (exit) stderr = savedStream;
logError("failed %s", "deploy");
stderr.flush();
file.rewind();
auto line = file.readln();
assert(line.canFind("\x1b[31m"), "expected red escape in: `" ~ line ~ "`");
assert(line.canFind("\x1b[31merror\x1b[0m failed deploy"),
"expected error line in: `" ~ line ~ "`");
}
unittest
{
// (4) TERM unset, empty, or "dumb" disables color.
auto env1 = ColorEnv(null, "dumb");
assert(!colorEnabled(), "TERM=dumb must disable color");
auto env2 = ColorEnv(null, null);
assert(!colorEnabled(), "missing TERM must disable color");
auto env3 = ColorEnv(null, "");
assert(!colorEnabled(), "empty TERM must disable color");
auto env4 = ColorEnv(null, "xterm");
assert(colorEnabled(), "TERM=xterm must enable color");
}
+4
View File
@@ -0,0 +1,4 @@
/// tofu — a package manager for the ZereneOS Unofficial User Repository.
///
/// Future modules (config, log, types, ...) live under this package.
module tofu;
+472
View File
@@ -0,0 +1,472 @@
/// tofu.recipeparse — Light Lua .recipe file parser.
///
/// Scans a .recipe file (Lua `return { ... }` table) and populates a
/// `tofu.types.Recipe` struct. This is NOT a full Lua parser — it uses
/// simple string scanning (the `extractKeyValue` pattern from `tofu.fetch`).
///
/// ## Supported fields
/// name, version, summary, url, sha256, build_system, configure_args,
/// build_script, test, files, deps
///
/// ## Limitations
/// - Value types are ALL read as strings (or string arrays for deps/files).
/// - The `deps` field is parsed as a list of quoted strings inside `{ }`.
/// - The `files` field is similarly parsed as a list of quoted strings.
/// - Multi-line strings (Lua `[[ ... ]]`) are NOT supported.
/// - Nested tables (e.g. `configure_args = { "a", "b" }`) are parsed as
/// the raw table text (limited support — just enough for common patterns).
/// - Unknown build_system strings map to `BuildSystem.unknown`.
module tofu.recipeparse;
import tofu.types; // Recipe, BuildSystem, buildSystemFromString
import std.file; // readText, exists
import std.string; // indexOf
import std.format; // format
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown when a .recipe file cannot be parsed.
class RecipeParseException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Light key-value scanner (same pattern as fetch.d)
// ────────────────────────────────────────────────────────────
/// Find `key = "..."` in Lua-like content and return the quoted
/// string value. Returns `null` when the key is not found or
/// the value is not a quoted string.
private @safe string extractKeyValue(string content, string key)
{
size_t searchFrom = 0;
while (true)
{
auto idx = indexOf(content[searchFrom .. $], key);
if (idx < 0)
return null;
auto pos = searchFrom + idx + key.length;
// Skip whitespace after key name
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect '='
if (pos >= content.length || content[pos] != '=')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip '='
// Skip whitespace after '='
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect opening double-quote
if (pos >= content.length || content[pos] != '"')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip opening quote
// Read until closing double-quote
auto valStart = pos;
while (pos < content.length && content[pos] != '"')
pos++;
if (pos >= content.length)
return null;
return content[valStart .. pos];
}
}
/// @safe predicate: is the character whitespace?
private @safe bool isWhite(char c)
{
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
// ────────────────────────────────────────────────────────────
// Deps list parser
// ────────────────────────────────────────────────────────────
/// Extract the `deps = { "a", "b>=1.0" }` array into `string[]`.
/// Returns empty array if the key is not found or cannot be parsed.
private @safe string[] extractList(string content, string key)
{
size_t searchFrom = 0;
while (true)
{
auto idx = indexOf(content[searchFrom .. $], key);
if (idx < 0)
return [];
auto pos = searchFrom + idx + key.length;
// Skip whitespace after key name
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect '='
if (pos >= content.length || content[pos] != '=')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip '='
// Skip whitespace after '='
while (pos < content.length && isWhite(content[pos]))
pos++;
// Expect '{'
if (pos >= content.length || content[pos] != '{')
{
searchFrom += idx + key.length;
continue;
}
pos++; // skip '{'
// Read quoted strings until closing '}'
string[] result;
while (pos < content.length && content[pos] != '}')
{
// Skip whitespace and commas between entries
while (pos < content.length && (isWhite(content[pos]) || content[pos] == ','))
pos++;
if (pos >= content.length || content[pos] == '}')
break;
// Expect opening double-quote
if (content[pos] != '"')
{
// Not a quoted string — skip to next comma or '}'
while (pos < content.length && content[pos] != ',' && content[pos] != '}')
pos++;
continue;
}
pos++; // skip opening quote
// Read until closing double-quote (no escape handling — limited parser)
auto valStart = pos;
bool foundClose = false;
while (pos < content.length)
{
if (content[pos] == '"')
{
result ~= content[valStart .. pos];
pos++; // skip closing quote
foundClose = true;
break;
}
pos++;
}
if (!foundClose)
break; // unterminated string
}
return result;
}
}
// ────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────
/// Parse a .recipe Lua file into a `Recipe` struct.
///
/// Reads the file at `path`, scans for known keys, and populates
/// the struct fields. Missing keys are left at their defaults.
///
/// Throws:
/// `RecipeParseException` if the file cannot be read.
/// Empty or missing files produce a Recipe with name="" (caller
/// should validate).
@safe
Recipe parseRecipeFile(string path)
{
string content;
() @trusted {
content = readText(path);
}();
Recipe recipe;
// ── Scalar string fields ──────────────────────────
recipe.name = extractKeyValue(content, "name");
// Accept both "version" (D keyword avoidance in files) and "ver"
string ver = extractKeyValue(content, "version");
if (ver.length == 0)
ver = extractKeyValue(content, "ver");
recipe.ver = ver;
recipe.summary = extractKeyValue(content, "summary");
recipe.url = extractKeyValue(content, "url");
recipe.sha256 = extractKeyValue(content, "sha256");
// ── Build system ──────────────────────────────────
string bs = extractKeyValue(content, "build_system");
if (bs.length > 0)
recipe.buildSystem = buildSystemFromString(bs);
// ── Custom build script ───────────────────────────
recipe.buildScript = extractKeyValue(content, "build_script");
// ── Test command ──────────────────────────────────
recipe.testCmd = extractKeyValue(content, "test");
// ── Deps list ─────────────────────────────────────
recipe.deps = extractList(content, "deps");
// ── Files list ────────────────────────────────────
recipe.files = extractList(content, "files");
// ── Configure args (raw string for now) ────────────
// For simplicity, we treat configure_args as a single
// quoted string. Multi-arg lists can be parsed later.
string ca = extractKeyValue(content, "configure_args");
if (ca.length > 0)
recipe.configureArgs = [ca];
else
{
// Try list form: configure_args = { "a", "b" }
recipe.configureArgs = extractList(content, "configure_args");
}
return recipe;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@safe unittest
{
// ── Test 1: parse simple recipe with all scalar fields ──
string recipeContent = q"LUA
return {
name = "hello",
version = "1.0.0",
summary = "A friendly greeting program",
url = "https://example.com/hello-1.0.0.tar.gz",
sha256 = "abc123def456",
build_system = "autotools",
build_script = "",
test = "test -f /usr/bin/hello",
deps = { "glibc", "ncurses>=6.0" },
files = { "usr/bin/hello", "usr/share/man/man1/hello.1" },
}
LUA";
string tmpDir;
() @trusted {
import std.file : tempDir, write, rmdirRecurse, mkdirRecurse, exists;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
tmpDir = buildPath(tempDir,
"tofu-recipeparse-t1-" ~ thisProcessID.to!string);
mkdirRecurse(tmpDir);
write(buildPath(tmpDir, "hello.recipe"), recipeContent);
}();
auto tmp = tmpDir; // capture for scope(exit) since tmpDir is in @trusted
scope (exit) {
() @trusted {
import std.file : rmdirRecurse, exists;
if (exists(tmp))
rmdirRecurse(tmp);
}();
}
import std.path : buildPath;
auto recipe = parseRecipeFile(buildPath(tmp, "hello.recipe"));
assert(recipe.name == "hello");
assert(recipe.ver == "1.0.0");
assert(recipe.summary == "A friendly greeting program");
assert(recipe.url == "https://example.com/hello-1.0.0.tar.gz");
assert(recipe.sha256 == "abc123def456");
assert(recipe.buildSystem == BuildSystem.autotools);
assert(recipe.buildScript == "");
assert(recipe.testCmd == "test -f /usr/bin/hello");
assert(recipe.deps.length == 2);
assert(recipe.deps[0] == "glibc");
assert(recipe.deps[1] == "ncurses>=6.0");
assert(recipe.files.length == 2);
assert(recipe.files[0] == "usr/bin/hello");
assert(recipe.files[1] == "usr/share/man/man1/hello.1");
}
@safe unittest
{
// ── Test 2: parse recipe with "ver" field (not "version") ──
string recipeContent = q"LUA
return {
name = "pkg",
ver = "2.0",
build_system = "cmake",
deps = { },
}
LUA";
string tmpDir;
() @trusted {
import std.file : tempDir, write, mkdirRecurse;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
tmpDir = buildPath(tempDir,
"tofu-recipeparse-t2-" ~ thisProcessID.to!string);
mkdirRecurse(tmpDir);
write(buildPath(tmpDir, "pkg.recipe"), recipeContent);
}();
auto tmp = tmpDir;
scope (exit) {
() @trusted {
import std.file : rmdirRecurse, exists;
if (exists(tmp))
rmdirRecurse(tmp);
}();
}
import std.path : buildPath;
auto recipe = parseRecipeFile(buildPath(tmp, "pkg.recipe"));
assert(recipe.name == "pkg");
assert(recipe.ver == "2.0"); // "ver" field accepted
assert(recipe.buildSystem == BuildSystem.cmake);
assert(recipe.deps.length == 0);
}
@safe unittest
{
// ── Test 3: custom build system with build_script ──
string recipeContent = q"LUA
return {
name = "custompkg",
version = "3.0",
build_system = "custom",
build_script = "scripts/build.sh",
}
LUA";
string tmpDir;
() @trusted {
import std.file : tempDir, write, mkdirRecurse;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
tmpDir = buildPath(tempDir,
"tofu-recipeparse-t3-" ~ thisProcessID.to!string);
mkdirRecurse(tmpDir);
write(buildPath(tmpDir, "custompkg.recipe"), recipeContent);
}();
auto tmp = tmpDir;
scope (exit) {
() @trusted {
import std.file : rmdirRecurse, exists;
if (exists(tmp))
rmdirRecurse(tmp);
}();
}
import std.path : buildPath;
auto recipe = parseRecipeFile(buildPath(tmp, "custompkg.recipe"));
assert(recipe.name == "custompkg");
assert(recipe.ver == "3.0");
assert(recipe.buildSystem == BuildSystem.custom);
assert(recipe.buildScript == "scripts/build.sh");
}
@safe unittest
{
// ── Test 4: unknown build_system → BuildSystem.unknown ──
string recipeContent = q"LUA
return {
name = "weirdpkg",
version = "1.0",
build_system = "bazel",
}
LUA";
string tmpDir;
() @trusted {
import std.file : tempDir, write, mkdirRecurse;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
tmpDir = buildPath(tempDir,
"tofu-recipeparse-t4-" ~ thisProcessID.to!string);
mkdirRecurse(tmpDir);
write(buildPath(tmpDir, "weirdpkg.recipe"), recipeContent);
}();
auto tmp = tmpDir;
scope (exit) {
() @trusted {
import std.file : rmdirRecurse, exists;
if (exists(tmp))
rmdirRecurse(tmp);
}();
}
import std.path : buildPath;
auto recipe = parseRecipeFile(buildPath(tmp, "weirdpkg.recipe"));
assert(recipe.name == "weirdpkg");
assert(recipe.buildSystem == BuildSystem.unknown);
}
@safe unittest
{
// ── Test 5: minimal recipe (name only) ──
string recipeContent = q"LUA
return {
name = "minimal",
}
LUA";
string tmpDir;
() @trusted {
import std.file : tempDir, write, mkdirRecurse;
import std.path : buildPath;
import std.process : thisProcessID;
import std.conv : to;
tmpDir = buildPath(tempDir,
"tofu-recipeparse-t5-" ~ thisProcessID.to!string);
mkdirRecurse(tmpDir);
write(buildPath(tmpDir, "minimal.recipe"), recipeContent);
}();
auto tmp = tmpDir;
scope (exit) {
() @trusted {
import std.file : rmdirRecurse, exists;
if (exists(tmp))
rmdirRecurse(tmp);
}();
}
import std.path : buildPath;
auto recipe = parseRecipeFile(buildPath(tmp, "minimal.recipe"));
assert(recipe.name == "minimal");
assert(recipe.ver == "");
assert(recipe.summary == "");
assert(recipe.url == "");
assert(recipe.sha256 == "");
assert(recipe.buildSystem == BuildSystem.unknown);
assert(recipe.deps.length == 0);
}
+851
View File
@@ -0,0 +1,851 @@
/// 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.config; // Config
import tofu.fetch; // FetchException
import tofu.log; // logInfo, logStep, logOk
import std.file : exists;
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;
}
// ────────────────────────────────────────────────────────────
// Build Plan Generation
// ────────────────────────────────────────────────────────────
/// Generate a BuildPlan from a constrained dependency tree.
///
/// Iterates over the constrained nodes in their existing topological
/// order (deps-first, root last). For each node whose source is
/// recipe (excluding binary-satisfied deps), verifies the recipe
/// file exists in the local cache and adds it to the build plan.
///
/// If a recipe file is missing from cache:
/// - With a `fetchRecipe` delegate provided: calls it to re-fetch.
/// - Without a delegate: throws `FetchException`.
///
/// If all constrained nodes are binary-satisfied, returns an empty
/// BuildPlan — the caller should print "nothing to build".
///
/// Params:
/// constrained = Source-annotated nodes from constrainDepTree
/// (already in topological / deps-first order).
/// tree = The original dependency tree (preserved for context).
/// cfg = Runtime configuration (used for cache paths).
/// fetchRecipe = Optional delegate to re-fetch a recipe if missing
/// from cache. Takes package name, returns recipe path.
/// Tests inject mocks; production wires a lambda over
/// tofu.fetch.fetchRecipe.
@safe
BuildPlan generateBuildPlan(scope const ConstrainedNode[] constrained,
scope const DepTree tree, scope const Config cfg,
scope string delegate(string) @safe fetchRecipe = null)
{
logStep("generating build plan");
BuildPlan plan;
foreach (node; constrained)
{
// Exclude binary-satisfied deps — Zeta handles those.
if (node.source == DepSource.binary)
continue;
// Build the expected cache path for the recipe file.
string recipePath = cfg.recipesCacheDir(node.name)
~ "/" ~ node.name ~ ".recipe";
// Verify the recipe exists in the local cache.
bool recipeExists = () @trusted {
return exists(recipePath);
}();
if (!recipeExists)
{
if (fetchRecipe is null)
{
throw new FetchException(
"recipe not cached and no fetcher provided");
}
logInfo("recipe %s not in cache, re-fetching", node.name);
recipePath = fetchRecipe(node.name);
}
logInfo(" + %s (recipe)", node.name);
plan.add(node.name, recipePath, Source.recipe);
}
if (plan.isEmpty())
logInfo("nothing to build (all binary)");
else
logOk("build plan: %d packages", plan.entries.length);
return plan;
}
// ────────────────────────────────────────────────────────────
// 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");
}
// ────────────────────────────────────────────────────────────
// generateBuildPlan helpers (unittest-only)
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.file : mkdirRecurse, write, rmdirRecurse, tempDir;
import std.conv : to;
import std.process : thisProcessID;
/// Create a recipe file in a temp cache dir that matches
/// what `Config.recipesCacheDir` produces.
private @trusted void createCachedRecipe(string cacheDir, string name)
{
auto dir = cacheDir ~ "/recipes/" ~ name;
if (!exists(dir))
mkdirRecurse(dir);
write(dir ~ "/" ~ name ~ ".recipe", "return {}");
}
/// Create a unique temp directory for test isolation.
private @trusted string testTempDir(string suffix)
{
auto dir = tempDir() ~ "/tofu-resolve-" ~ suffix ~ "-"
~ thisProcessID.to!string;
if (exists(dir))
rmdirRecurse(dir);
mkdirRecurse(dir);
return dir;
}
/// Recursively remove a temp test directory.
private @trusted void testRmdir(string path)
{
try { rmdirRecurse(path); } catch (Exception) {}
}
/// Build a minimal test Config pointing at a temp cache dir.
private @safe Config testConfig(string cacheDir)
{
Config cfg;
cfg.cacheDir = cacheDir;
return cfg;
}
}
// ── Test (9): [C(recipe), B(binary), A(recipe)] → [C, A] ──
@safe unittest
{
auto tmpDir = testTempDir("t9");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "C");
createCachedRecipe(tmpDir, "A");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "C";
cn.source = DepSource.recipe;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "B";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "A";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.entries.length == 2,
"should contain C and A, got " ~ plan.entries.length.to!string);
assert(plan.entries[0].name == "C", "C should be first (dep before root)");
assert(plan.entries[0].source == Source.recipe);
assert(plan.entries[1].name == "A", "A is root, should be last");
assert(plan.entries[1].source == Source.recipe);
}
// ── Test (10): missing cache + fetch delegate → fetch called ──
@safe unittest
{
auto tmpDir = testTempDir("t10");
scope (exit) testRmdir(tmpDir);
// No recipe file on disk — must trigger fetch delegate.
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
bool fetchCalled = false;
string fetchResult;
scope fetchRecipe = delegate (string name) @safe {
fetchCalled = true;
fetchResult = tmpDir ~ "/re-fetched/" ~ name ~ ".recipe";
return fetchResult;
};
auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe);
assert(fetchCalled, "fetch delegate should have been called");
assert(plan.entries.length == 1);
assert(plan.entries[0].name == "libfoo");
assert(plan.entries[0].recipePath == fetchResult,
"plan should use the path returned by the fetch delegate");
}
// ── Test (11): missing cache + no fetch delegate → FetchException ──
@safe unittest
{
auto tmpDir = testTempDir("t11");
scope (exit) testRmdir(tmpDir);
// No recipe file on disk, no delegate → exception.
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
bool caught = false;
try
{
generateBuildPlan(constrained, tree, cfg);
assert(false, "expected FetchException");
}
catch (FetchException e)
{
caught = true;
assert(e.msg.indexOf("not cached") >= 0,
"exception message should mention 'not cached', got: " ~ e.msg);
}
assert(caught, "should have thrown FetchException");
}
// ── Test (12): all binary → empty plan ──
@safe unittest
{
auto tmpDir = testTempDir("t12");
scope (exit) testRmdir(tmpDir);
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "B";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "A";
cn.source = DepSource.binary;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.isEmpty(), "all-binary deps → empty build plan");
}
// ── Test (13): root included even when all deps are binary ──
@safe unittest
{
auto tmpDir = testTempDir("t13");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "mypkg");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "dep";
cn.source = DepSource.binary;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "mypkg";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
auto plan = generateBuildPlan(constrained, tree, cfg);
assert(plan.entries.length == 1,
"root is always recipe — must appear even with binary-only deps");
assert(plan.entries[0].name == "mypkg");
assert(plan.entries[0].source == Source.recipe);
}
// ── Test (14): recipe files already in cache → no fetch call ──
@safe unittest
{
auto tmpDir = testTempDir("t14");
scope (exit) testRmdir(tmpDir);
createCachedRecipe(tmpDir, "libfoo");
createCachedRecipe(tmpDir, "mypkg");
ConstrainedNode[] constrained;
{
ConstrainedNode cn;
cn.name = "libfoo";
cn.source = DepSource.recipe;
constrained ~= cn;
}
{
ConstrainedNode cn;
cn.name = "mypkg";
cn.source = DepSource.recipe;
constrained ~= cn;
}
DepTree tree;
auto cfg = testConfig(tmpDir);
// Fetch delegate that throws if called — must not be invoked.
scope fetchRecipe = delegate (string name) @safe {
assert(false, "fetchRecipe should NOT be called when recipe exists in cache");
return "";
};
auto plan = generateBuildPlan(constrained, tree, cfg, fetchRecipe);
assert(plan.entries.length == 2);
assert(plan.entries[0].name == "libfoo");
assert(plan.entries[0].recipePath ==
tmpDir ~ "/recipes/libfoo/libfoo.recipe");
assert(plan.entries[0].source == Source.recipe);
assert(plan.entries[1].name == "mypkg");
assert(plan.entries[1].recipePath ==
tmpDir ~ "/recipes/mypkg/mypkg.recipe");
assert(plan.entries[1].source == Source.recipe);
}
+366
View File
@@ -0,0 +1,366 @@
/// tofu state — post-install package tracking for -Syu upgrade checks.
///
/// Tofu maintains its own lightweight JSON state file separate from ZETA's
/// per-package database. This tracks what tofu installed (by recipe) so the
/// upgrade command can compare installed versions against the ZUUR index.
///
/// State file: `cfg.cacheDir ~ "/installed.json"` — a JSON array of objects:
/// `[{"name":"hello","ver":"1.0","installedAt":<unix-ts>,"source":"recipe"}]`
///
/// Atomic writes: tmp file + rename ensures the state file is never
/// half-written.
///
/// Corrupted or missing state file → empty list + logWarn (never throws).
module tofu.state;
import std.file : readText, write, rename, exists, remove, tempDir;
import std.process : thisProcessID;
import std.json : parseJSON, JSONValue, JSONType;
import std.datetime : Clock;
import std.conv : to;
import std.format : format;
import std.path : buildPath;
import tofu.config : Config;
import tofu.log : logWarn;
// ─── Data types ──────────────────────────────────────────────────────────────
/// A single entry in tofu's installed-package state.
struct InstalledPkg {
string name = "";
string ver = "";
long installedAt;
string source = "";
}
// ─── @trusted wrappers (fs + json — @system in Phobos) ──────────────────────
private @trusted string fReadText(string path) { return readText(path); }
private @trusted void fWrite(string path, string c) { write(path, c); }
private @trusted void fRename(string from, string to) { rename(from, to); }
private @trusted void fRemove(string path) { try remove(path); catch (Exception) {} }
private @trusted bool fExists(string path) { return exists(path); }
private @trusted JSONValue fParseJSON(string content) { return parseJSON(content); }
private @trusted JSONValue[] fArray(ref JSONValue v) { return v.array; }
// ─── Path helper ─────────────────────────────────────────────────────────────
/// Absolute path to the installed-state JSON file.
private pure @safe nothrow
string statePath(Config cfg) {
return cfg.cacheDir ~ "/installed.json";
}
// ─── Internal: load / save ───────────────────────────────────────────────────
/// Read the state file and parse it into an `InstalledPkg[]`.
/// Missing file → empty array. Corrupted JSON → logWarn + empty array.
private @safe
InstalledPkg[] loadState(Config cfg) {
auto path = statePath(cfg);
if (!fExists(path))
return [];
string content;
try {
content = fReadText(path);
} catch (Exception) {
logWarn("could not read installed state at %s", path);
return [];
}
JSONValue root;
try {
root = fParseJSON(content);
} catch (Exception e) {
logWarn("corrupted installed state at %s: %s", path, e.msg);
return [];
}
if (root.type != JSONType.array) {
logWarn("corrupted installed state at %s: expected JSON array", path);
return [];
}
InstalledPkg[] result;
foreach (entry; fArray(root)) {
if (entry.type != JSONType.object)
continue;
try {
InstalledPkg pkg;
pkg.name = entry["name"].str;
pkg.ver = entry["ver"].str;
pkg.installedAt = entry["installedAt"].integer;
pkg.source = entry["source"].str;
if (pkg.name.length > 0)
result ~= pkg;
} catch (Exception) {
// Skip individual corrupted entries silently.
}
}
return result;
}
/// Serialize entries to a JSON array string.
private @safe
string serialize(const InstalledPkg[] pkgs) {
import std.array : appender;
auto w = appender!string();
w.put("[");
foreach (i, pkg; pkgs) {
if (i > 0) w.put(",");
w.put(format(
`{"name":"%s","ver":"%s","installedAt":%d,"source":"%s"}`,
pkg.name, pkg.ver, pkg.installedAt, pkg.source));
}
w.put("]");
return w.data;
}
/// Write entries to the state file atomically: tmp file → rename.
private @safe
void saveState(const InstalledPkg[] pkgs, Config cfg) {
auto path = statePath(cfg);
auto tmp = path ~ ".tmp";
fWrite(tmp, serialize(pkgs));
fRename(tmp, path);
}
// ─── Public API ──────────────────────────────────────────────────────────────
/// Record an install (or reinstall). If an entry for `name` already exists
/// it is replaced with the new version and timestamp; otherwise a new entry
/// is appended. Written atomically to disk.
void recordInstall(string name, string ver, Config cfg) @safe {
auto pkgs = loadState(cfg);
auto now = Clock.currTime().toUnixTime();
bool found = false;
foreach (ref pkg; pkgs) {
if (pkg.name == name) {
pkg.ver = ver;
pkg.installedAt = now;
pkg.source = "recipe";
found = true;
break;
}
}
if (!found)
pkgs ~= InstalledPkg(name, ver, now, "recipe");
saveState(pkgs, cfg);
}
/// Return every package tofu has recorded as installed.
/// Missing or corrupted state file → empty array.
InstalledPkg[] listInstalled(Config cfg) @safe {
return loadState(cfg);
}
/// Check whether `name` was installed by tofu. On success fills `pkg` and
/// returns `true`. On failure returns `false` (pkg unchanged).
bool isInstalledByTofu(string name, Config cfg, out InstalledPkg pkg) @safe {
auto pkgs = loadState(cfg);
foreach (entry; pkgs) {
if (entry.name == name) {
pkg = entry;
return true;
}
}
return false;
}
/// Remove the install record for `name`. Missing entry → no-op.
/// Written atomically to disk.
void removeInstallRecord(string name, Config cfg) @safe {
auto pkgs = loadState(cfg);
bool found = false;
InstalledPkg[] filtered;
foreach (pkg; pkgs) {
if (pkg.name == name)
found = true;
else
filtered ~= pkg;
}
if (found)
saveState(filtered, cfg);
}
/// Convenience: return the installed version string for `name`, or `""` if
/// not installed by tofu.
string installedVersion(string name, Config cfg) @safe {
auto pkgs = loadState(cfg);
foreach (pkg; pkgs) {
if (pkg.name == name)
return pkg.ver;
}
return "";
}
// ─── Unittests ───────────────────────────────────────────────────────────────
// Test helpers
private @safe Config makeTestConfig(string suffix) {
Config cfg;
cfg.cacheDir = buildPath(tempDir,
"tofu-test-state-" ~ suffix ~ "-" ~ thisProcessID.to!string);
return cfg;
}
private @trusted void ensureDir(string path) {
import std.file : mkdirRecurse;
try mkdirRecurse(path); catch (Exception) {}
}
private @trusted void removeDir(string path) {
import std.file : rmdirRecurse;
try rmdirRecurse(path); catch (Exception) {}
}
private @trusted void ensureStateDir(Config cfg) {
ensureDir(cfg.cacheDir);
}
// ── Test (1): recordInstall → listInstalled contains it with correct ver.
@safe unittest {
auto cfg = makeTestConfig("basic");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("hello", "1.0", cfg);
auto pkgs = listInstalled(cfg);
assert(pkgs.length == 1);
assert(pkgs[0].name == "hello");
assert(pkgs[0].ver == "1.0");
assert(pkgs[0].source == "recipe");
assert(pkgs[0].installedAt > 0);
}
// ── Test (2): recordInstall twice same name → single entry, latest ver.
@safe unittest {
auto cfg = makeTestConfig("replace");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("hello", "1.0", cfg);
recordInstall("hello", "2.0", cfg);
auto pkgs = listInstalled(cfg);
assert(pkgs.length == 1);
assert(pkgs[0].name == "hello");
assert(pkgs[0].ver == "2.0");
}
// ── Test (3): isInstalledByTofu → true with filled pkg; unknown → false.
@safe unittest {
auto cfg = makeTestConfig("check");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("firefox", "120.0", cfg);
InstalledPkg pkg;
assert(isInstalledByTofu("firefox", cfg, pkg));
assert(pkg.name == "firefox");
assert(pkg.ver == "120.0");
assert(pkg.source == "recipe");
InstalledPkg notFound;
assert(!isInstalledByTofu("nonexistent", cfg, notFound));
}
// ── Test (4): removeInstallRecord → gone; removing missing → no-op.
@safe unittest {
auto cfg = makeTestConfig("remove");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("a", "1.0", cfg);
recordInstall("b", "2.0", cfg);
assert(listInstalled(cfg).length == 2);
removeInstallRecord("a", cfg);
auto pkgs = listInstalled(cfg);
assert(pkgs.length == 1);
assert(pkgs[0].name == "b");
// Removing missing → no-op
removeInstallRecord("nonexistent", cfg);
pkgs = listInstalled(cfg);
assert(pkgs.length == 1);
}
// ── Test (5): corrupted JSON file → empty list + no throw.
@safe unittest {
auto cfg = makeTestConfig("corrupt");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
// Write garbage to the state file
auto path = statePath(cfg);
() @trusted { write(path, "this is not valid {{{ JSON"); }();
auto pkgs = listInstalled(cfg);
assert(pkgs.length == 0);
// Verify we can still write to it after corruption
recordInstall("recovery", "1.0", cfg);
pkgs = listInstalled(cfg);
assert(pkgs.length == 1);
assert(pkgs[0].name == "recovery");
}
// ── Test (6): missing file → empty list.
@safe unittest {
auto cfg = makeTestConfig("missing");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
// Don't write anything — cache dir exists but no installed.json
auto pkgs = listInstalled(cfg);
assert(pkgs.length == 0);
// installedVersion returns "" for missing
assert(installedVersion("anything", cfg) == "");
}
// ── Test (7): file is valid JSON after writes (parse back).
@safe unittest {
auto cfg = makeTestConfig("valid");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("x", "1.0", cfg);
recordInstall("y", "2.1", cfg);
// Read raw file and parse back externally
auto path = statePath(cfg);
string raw;
() @trusted { raw = readText(path); }();
JSONValue parsed;
() @trusted { parsed = parseJSON(raw); }();
assert(parsed.type == JSONType.array);
assert(fArray(parsed).length == 2);
bool foundX, foundY;
foreach (entry; fArray(parsed)) {
auto name = entry["name"].str;
auto ver = entry["ver"].str;
if (name == "x") { assert(ver == "1.0"); foundX = true; }
if (name == "y") { assert(ver == "2.1"); foundY = true; }
}
assert(foundX && foundY);
}
// ── Extra: installedVersion convenience.
@safe unittest {
auto cfg = makeTestConfig("instver");
scope (exit) removeDir(cfg.cacheDir);
ensureStateDir(cfg);
recordInstall("zed", "3.3.1", cfg);
assert(installedVersion("zed", cfg) == "3.3.1");
assert(installedVersion("nope", cfg) == "");
}
+541
View File
@@ -0,0 +1,541 @@
/// tofu.types — Core data structures shared across all tofu modules.
///
/// Pure data types with no I/O. All structs are @safe, all strings
/// default to `""` (never null).
module tofu.types;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Single exception type for the types module.
class TypesException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Pool — package source pool
// ────────────────────────────────────────────────────────────
enum Pool
{
binary,
recipes,
both,
}
/// Parse a pool string value.
@safe Pool fromPoolString(string s)
{
switch (s)
{
case "binary":
return Pool.binary;
case "recipes":
return Pool.recipes;
case "both":
return Pool.both;
default:
throw new TypesException("invalid pool value");
}
}
/// Return canonical string representation of a Pool.
@safe string poolToString(Pool p)
{
final switch (p)
{
case Pool.binary:
return "binary";
case Pool.recipes:
return "recipes";
case Pool.both:
return "both";
}
}
// ────────────────────────────────────────────────────────────
// BuildSystem — known build systems
// ────────────────────────────────────────────────────────────
enum BuildSystem
{
autotools,
cmake,
meson,
make,
cargo,
custom,
unknown,
}
/// Map a build-system string to the enum. Unknown strings
/// map to `BuildSystem.unknown` — callers decide how to handle it.
@safe BuildSystem buildSystemFromString(string s)
{
switch (s)
{
case "autotools":
return BuildSystem.autotools;
case "cmake":
return BuildSystem.cmake;
case "meson":
return BuildSystem.meson;
case "make":
return BuildSystem.make;
case "cargo":
return BuildSystem.cargo;
case "custom":
return BuildSystem.custom;
default:
return BuildSystem.unknown;
}
}
// ────────────────────────────────────────────────────────────
// DepOp — dependency-constraint operators
// ────────────────────────────────────────────────────────────
enum DepOp
{
ge, // >=
le, // <=
eq, // == (and = normalised)
ne, // ~=
gt, // >
lt, // <
none, // unconstrained
}
// ────────────────────────────────────────────────────────────
// Source — where a package comes from in a build-plan entry
// ────────────────────────────────────────────────────────────
enum Source
{
recipe,
binary,
}
// ────────────────────────────────────────────────────────────
// Character helpers for dep-spec scanning
// ────────────────────────────────────────────────────────────
private @safe bool isNameChar(char c)
{
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9') || c == '_' || c == '.'
|| c == '+' || c == '-';
}
private @safe bool isWhite(char c)
{
return c == ' ' || c == '\t';
}
// ────────────────────────────────────────────────────────────
// PackageIndex — index.lua entry
// ────────────────────────────────────────────────────────────
struct PackageIndex
{
string name = "";
string ver = "";
string summary = "";
Pool pool = Pool.both;
}
// ────────────────────────────────────────────────────────────
// Recipe — .recipe file fields
// ────────────────────────────────────────────────────────────
struct Recipe
{
string name = "";
string ver = "";
string summary = "";
string url = "";
string sha256 = "";
string[] deps; // raw dep specs e.g. ["libfoo", "libbar>=2.0"]
BuildSystem buildSystem = BuildSystem.unknown;
string[] configureArgs; // extra configure arguments
string buildScript = ""; // required for custom build system
string testCmd = ""; // e.g. "test -f ${DESTDIR}/usr/bin/pkg"
string[] files; // committed file paths
}
// ────────────────────────────────────────────────────────────
// DepConstraint — parsed dependency spec
// ────────────────────────────────────────────────────────────
/// A single parsed dependency constraint.
///
/// Examples:
/// parse("libfoo") → name="libfoo", op=none, ver=""
/// parse("libfoo>=2.0") → name="libfoo", op=ge, ver="2.0"
/// parse("pcre2~=10.42") → name="pcre2", op=ne, ver="10.42"
struct DepConstraint
{
string name = "";
DepOp op = DepOp.none;
string ver = "";
/// Parse a single dependency specification string.
///
/// Semantics ported from `vercmp.parse_dep` in ZETA/lib/vercmp.lua.
/// - name : `[A-Za-z0-9_.+-]+`
/// - ops : `>=` `<=` `==` `~=` `>` `<` `=` (2-char checked first)
/// - `=` normalises to `==`
/// - trailing garbage after version → `TypesException`
/// - empty string / no name → `TypesException`
@safe static DepConstraint parse(string spec)
{
size_t pos = 0;
// ── skip leading whitespace ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── extract name ──
size_t nameStart = pos;
while (pos < spec.length && isNameChar(spec[pos]))
pos++;
if (pos == nameStart)
throw new TypesException("bad dependency \"" ~ spec ~ "\"");
DepConstraint result;
result.name = spec[nameStart .. pos];
// ── skip whitespace after name ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── unconstrained: no operator present ──
if (pos >= spec.length)
return result; // op=none, ver=""
// ── try matching an operator (2-char first, then 1-char) ──
string opStr;
if (pos + 1 < spec.length)
{
string two = spec[pos .. pos + 2];
if (two == ">=") { result.op = DepOp.ge; opStr = ">="; }
else if (two == "<=") { result.op = DepOp.le; opStr = "<="; }
else if (two == "==") { result.op = DepOp.eq; opStr = "=="; }
else if (two == "~=") { result.op = DepOp.ne; opStr = "~="; }
}
if (opStr.length == 0)
{
char c = spec[pos];
if (c == '>') { result.op = DepOp.gt; opStr = ">"; }
else if (c == '<') { result.op = DepOp.lt; opStr = "<"; }
else if (c == '=') { result.op = DepOp.eq; opStr = "="; }
}
if (opStr.length == 0)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
pos += opStr.length;
// ── skip whitespace after operator ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
// ── extract version ──
size_t verStart = pos;
while (pos < spec.length && isNameChar(spec[pos]))
pos++;
size_t verEnd = pos;
if (verStart == verEnd)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
result.ver = spec[verStart .. verEnd];
// ── check for trailing garbage ──
while (pos < spec.length && isWhite(spec[pos]))
pos++;
if (pos < spec.length)
{
throw new TypesException(
"bad dependency constraint \"" ~ spec
~ "\" (expected NAME OP VERSION)");
}
return result;
}
}
// ────────────────────────────────────────────────────────────
// BuildPlanEntry / BuildPlan
// ────────────────────────────────────────────────────────────
struct BuildPlanEntry
{
string name = "";
string recipePath = "";
Source source = Source.recipe;
}
/// Ordered set of packages to build. Sorting/deps-first ordering
/// is guaranteed by the caller; this is a plain container.
struct BuildPlan
{
BuildPlanEntry[] entries;
@safe void add(string name, string recipePath, Source source)
{
entries ~= BuildPlanEntry(name, recipePath, source);
}
/// Return entries in the order they were added (caller
/// must arrange deps-first ordering before calling this).
@safe BuildPlanEntry[] order()
{
return entries;
}
@safe bool isEmpty()
{
return entries.length == 0;
}
}
// ────────────────────────────────────────────────────────────
// CacheManifest — cached-package metadata
// ────────────────────────────────────────────────────────────
struct CacheManifest
{
string name = "";
string ver = "";
long fetchedAt = 0; // unix timestamp
}
// ────────────────────────────────────────────────────────────
// BuildFailure — single build failure record
// ────────────────────────────────────────────────────────────
struct BuildFailure
{
string name = "";
string reason = "";
}
// ────────────────────────────────────────────────────────────
// BuildResult — build outcome tracker
// ────────────────────────────────────────────────────────────
struct BuildResult
{
string[] succeeded;
BuildFailure[] failed;
/// Return just the names of the packages that failed.
@safe string[] failedNames()
{
string[] names;
foreach (f; failed)
names ~= f.name;
return names;
}
}
// ────────────────────────────────────────────────────────────
// BinaryCheckResult — pre-built binary availability
// ────────────────────────────────────────────────────────────
struct BinaryCheckResult
{
bool exists = false;
string ver = "";
bool satisfies = false;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
@safe unittest
{
// ── Pool parsing ──────────────────────────────────────
assert(fromPoolString("binary") == Pool.binary);
assert(fromPoolString("recipes") == Pool.recipes);
assert(fromPoolString("both") == Pool.both);
assert(poolToString(Pool.binary) == "binary");
assert(poolToString(Pool.recipes) == "recipes");
assert(poolToString(Pool.both) == "both");
bool caught = false;
try
{
fromPoolString("nope");
assert(false, "expected exception");
}
catch (TypesException e)
{
caught = true;
}
assert(caught, "invalid pool should throw");
}
@safe unittest
{
// ── DepConstraint: unconstrained ─────────────────────
{
auto d = DepConstraint.parse("libfoo");
assert(d.name == "libfoo");
assert(d.op == DepOp.none);
assert(d.ver == "");
}
{
auto d = DepConstraint.parse("pcre2");
assert(d.name == "pcre2");
assert(d.op == DepOp.none);
assert(d.ver == "");
}
}
@safe unittest
{
// ── DepConstraint: all operators ─────────────────────
{
auto d = DepConstraint.parse("libfoo>=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.ge);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo<=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.le);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo==2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.eq);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo~=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.ne);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo>2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.gt);
assert(d.ver == "2.0");
}
{
auto d = DepConstraint.parse("libfoo<2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.lt);
assert(d.ver == "2.0");
}
{
// single = normalises to ==
auto d = DepConstraint.parse("libfoo=2.0");
assert(d.name == "libfoo");
assert(d.op == DepOp.eq);
assert(d.ver == "2.0");
}
}
@safe unittest
{
// ── DepConstraint: edge cases ────────────────────────
// empty string → throw
bool caught = false;
try
{
DepConstraint.parse("");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "empty string should throw");
// whitespace-only → throw
caught = false;
try
{
DepConstraint.parse(" ");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "whitespace-only should throw");
// trailing garbage
caught = false;
try
{
DepConstraint.parse("libfoo>=2.0 extra");
assert(false, "expected exception");
}
catch (TypesException)
{
caught = true;
}
assert(caught, "trailing garbage should throw");
}
@safe unittest
{
// ── BuildPlan ────────────────────────────────────────
BuildPlan bp;
assert(bp.isEmpty());
bp.add("mypkg", "path/to/mypkg.recipe", Source.recipe);
assert(!bp.isEmpty());
bp.add("libbar", "path/to/libbar.recipe", Source.recipe);
auto ordered = bp.order();
assert(ordered.length == 2);
assert(ordered[0].name == "mypkg");
assert(ordered[0].recipePath == "path/to/mypkg.recipe");
assert(ordered[0].source == Source.recipe);
assert(ordered[1].name == "libbar");
assert(ordered[1].recipePath == "path/to/libbar.recipe");
assert(ordered[1].source == Source.recipe);
}
@safe unittest
{
// ── BuildResult.failedNames ──────────────────────────
BuildResult br;
assert(br.failedNames().length == 0);
br.failed ~= BuildFailure("foo", "compile error");
br.failed ~= BuildFailure("bar", "link error");
br.succeeded ~= "baz";
auto names = br.failedNames();
assert(names.length == 2);
assert(names[0] == "foo");
assert(names[1] == "bar");
}
+275
View File
@@ -0,0 +1,275 @@
/// Progress output and terminal UI helpers for tofu — spinner, build
/// separators, and summary table.
///
/// Spinner:
/// Uses a background thread to animate `/ - \ |` with `\r` carriage return
/// on stdout. Only renders when stdout is a terminal; when redirected,
/// prints a single plain-text `<label>...` line once. `stop()` clears the
/// animation line and emits ` ok <label>` via `logOk`.
///
/// buildSeparator:
/// Prints `──── building <name> (<i>/<n>) ────` (U+2500 box-drawing).
/// This duplicates build.d's inline separator for use by other callers;
/// do **not** modify build.d.
///
/// summaryTable:
/// Fixed-width tabular summary of install results — columns PACKAGE
/// (20 chars), VERSION (16 chars), STATUS (variable).
module tofu.ui;
import core.sys.posix.unistd : isatty;
import core.thread : Thread;
import core.time : dur;
import std.format : format;
import std.stdio : stdout;
import tofu.log : logOk;
// ─── Spinner ────────────────────────────────────────────────────────────────
/// Animated progress spinner. Only animates when stdout is a terminal;
/// otherwise prints a single plain-text line.
class Spinner
{
private:
Thread _thread;
shared bool _running;
string _label;
bool _isTTY;
/// Check whether stdout (fd 1) is a terminal.
static private bool isStdoutTTY() @trusted
{
return isatty(1) != 0;
}
/// Background thread function: animate `/ - \ -` every 100 ms.
private void threadFn() @trusted
{
immutable string[4] frames = ["/", "-", "\\", "-"];
size_t i = 0;
while (_running)
{
stdout.writef("\r %s %s", frames[i % 4], _label);
stdout.flush();
i++;
Thread.sleep(dur!("msecs")(100));
}
}
public:
/// Create a spinner for `label` (does **not** start animation).
this(string label) @safe
{
_label = label;
_isTTY = isStdoutTTY();
}
/// Start the spinner. On a TTY launches the background animation
/// thread; on non-TTY output prints a single `<label>...` line.
void start() @safe
{
if (!_isTTY)
{
() @trusted { stdout.writeln(_label ~ "..."); }();
return;
}
_running = true;
() @trusted {
_thread = new Thread(&threadFn);
_thread.start();
}();
}
/// Stop the spinner. On a TTY signals the background thread, joins
/// it (the thread checks `_running` every 100 ms so join returns
/// promptly), clears the animation line, and prints
/// ` ok <label>` via `logOk`. Non-TTY is a no-op.
void stop() @safe
{
if (!_isTTY)
return;
// Signal the worker thread to exit.
_running = false;
if (_thread !is null)
{
try
{
() @trusted { _thread.join(); }();
}
catch (Exception)
{
// Thread join failure (e.g. double-join) — harmless.
}
_thread = null;
}
// Clear the animation line: \r + enough spaces + \r.
() @trusted {
stdout.write("\r");
// Overwrite the widest possible spinner line (" \ " + label).
foreach (_; 0 .. _label.length + 10)
stdout.write(" ");
stdout.write("\r");
stdout.flush();
}();
logOk("%s", _label);
}
}
/// Convenience factory: create a `Spinner` and immediately call `start()`.
/// Returns the handle for later `stop()`.
Spinner startSpinner(string label) @safe
{
auto s = new Spinner(label);
s.start();
return s;
}
// ─── buildSeparator ─────────────────────────────────────────────────────────
/// Print a box-drawing separator for build progress.
///
/// Output: `──── building <name> (<index>/<total>) ────`
/// (U+2500 characters). This duplicates build.d's inline separator;
/// kept here as a standalone helper for future callers.
void buildSeparator(string name, int index, int total) @safe
{
auto line = format("──── building %s (%d/%d) ────", name, index, total);
() @trusted { stdout.writeln(line); }();
}
// ─── summaryTable ───────────────────────────────────────────────────────────
/// Package install summary entry.
struct InstallSummary
{
string name;
string ver;
string status;
}
/// Print a fixed-width summary table to stdout.
///
/// Columns: `PACKAGE` (20 chars, left-aligned), `VERSION` (16 chars,
/// left-aligned), `STATUS` (variable width). Empty array → no output.
void summaryTable(InstallSummary[] items) @safe
{
if (items.length == 0)
return;
() @trusted {
import std.stdio : writefln;
writefln("%-20s %-16s %s", "PACKAGE", "VERSION", "STATUS");
foreach (item; items)
writefln("%-20s %-16s %s", item.name, item.ver, item.status);
}();
}
// ─── Unittests ──────────────────────────────────────────────────────────────
version (unittest)
{
import std.algorithm.searching : canFind;
import std.file : exists, readText, remove, tempDir;
import std.path : buildPath;
import std.stdio : File;
import std.string : indexOf;
/// Swap stdout to a temp file, run `dg`, restore, return full file
/// contents. The file is cleaned up on scope exit.
private string captureStdout(void delegate() dg) @trusted
{
auto fname = buildPath(tempDir, "tofu-ui-capture.tmp");
scope (exit)
{
if (exists(fname))
remove(fname);
}
auto file = File(fname, "w+");
auto saved = stdout;
stdout = file;
scope (exit) stdout = saved;
scope (failure) stdout = saved;
dg();
stdout.flush();
file.close(); // release handle before read
return readText(fname);
}
}
// (1) Spinner with stdout redirected → no control characters leak.
@safe unittest
{
auto output = captureStdout({
auto s = startSpinner("downloading foo");
s.stop();
});
// Redirected output must contain NO carriage-return characters.
assert(output.indexOf("\r") < 0,
"redirected output must contain no \\r, got: `" ~ output ~ "`");
// The plain-text label was printed.
assert(canFind(output, "downloading foo"),
"expected 'downloading foo' in output, got: `" ~ output ~ "`");
}
// (2) buildSeparator output exact.
@safe unittest
{
auto output = captureStdout({
buildSeparator("foo", 1, 3);
});
assert(canFind(output, "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80 "
~ "building foo (1/3) "
~ "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80"),
"expected separator line, got: `" ~ output ~ "`");
}
// (3) summaryTable: 3 items → header + aligned rows.
@safe unittest
{
auto output = captureStdout({
summaryTable([
InstallSummary("libfoo", "1.2.3", "installed"),
InstallSummary("libbar", "0.9.0", "skipped"),
InstallSummary("libbaz", "2.0.0", "failed"),
]);
});
// Header present.
assert(canFind(output, "PACKAGE"), "expected PACKAGE header");
assert(canFind(output, "VERSION"), "expected VERSION header");
assert(canFind(output, "STATUS"), "expected STATUS header");
// All three rows present.
assert(canFind(output, "libfoo"), "expected libfoo row");
assert(canFind(output, "libbar"), "expected libbar row");
assert(canFind(output, "libbaz"), "expected libbaz row");
assert(canFind(output, "installed"), "expected installed status");
assert(canFind(output, "skipped"), "expected skipped status");
assert(canFind(output, "failed"), "expected failed status");
}
// (4) Spinner start/stop plain-path (non-TTY) — no hang, no ok line.
@safe unittest
{
auto output = captureStdout({
auto s = startSpinner("test operation");
s.stop();
});
// Non-TTY mode prints the plain label.
assert(canFind(output, "test operation"),
"expected label in plain output, got: `" ~ output ~ "`");
// Non-TTY stop() returns early — logOk is NOT called.
assert(output.indexOf(" ok ") < 0,
"non-TTY stop must not print 'ok' line, got: `" ~ output ~ "`");
}
+346
View File
@@ -0,0 +1,346 @@
/// tofu.vercmp — RPM-style version comparison and dependency constraint checking.
///
/// Direct port of ZETA's `vercmp.lua` (references/ZETA/lib/vercmp.lua).
/// Splits version strings into alternating digit and letter segments;
/// digit segments compare numerically (leading zeros ignored),
/// letter segments compare lexically.
/// Shorter versions (fewer segments) are considered older.
///
/// Note: named `vercmp` rather than `version` because `version` is a
/// reserved keyword in D.
module tofu.vercmp;
import tofu.types;
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
@safe @nogc pure nothrow
bool isDigit(char c) { return c >= '0' && c <= '9'; }
@safe @nogc pure nothrow
bool isLetter(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
}
// ─────────────────────────────────────────────────────────────────────────────
// Segment extraction
// ─────────────────────────────────────────────────────────────────────────────
/// Reads the next digit or letter segment from `s` starting at index `i`,
/// skipping separator characters (anything not digit/letter).
/// Sets `seg` and `ni` (next index). Returns `true` if a segment was found.
@safe pure
bool nextSegment(scope const string s, scope size_t i,
out string seg, out size_t ni) {
while (i < s.length) {
if (isDigit(s[i]) || isLetter(s[i]))
break;
i++;
}
if (i >= s.length) {
seg = null;
ni = i;
return false;
}
bool digit = isDigit(s[i]);
size_t j = i + 1;
while (j < s.length) {
if (digit && !isDigit(s[j]))
break;
if (!digit && !isLetter(s[j]))
break;
j++;
}
seg = s[i .. j];
ni = j;
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// Numeric segment comparison
// ─────────────────────────────────────────────────────────────────────────────
/// Compares two numeric segments.
/// Leading zeros are stripped before comparison.
/// Longer stripped string wins; equal-length strings compare lexicographically.
@safe pure
int cmpNumeric(scope const string a, scope const string b) {
// Strip leading zeros (Lua: gsub("^0+", ""), fallback to "0")
size_t ai = 0;
while (ai < a.length && a[ai] == '0')
ai++;
string x = ai < a.length ? a[ai .. $] : "0";
size_t bi = 0;
while (bi < b.length && b[bi] == '0')
bi++;
string y = bi < b.length ? b[bi .. $] : "0";
if (x.length > y.length) return 1;
if (x.length < y.length) return -1;
if (x > y) return 1;
if (x < y) return -1;
return 0;
}
// ─────────────────────────────────────────────────────────────────────────────
// Whitespace helper
// ─────────────────────────────────────────────────────────────────────────────
/// Remove all whitespace from a string. Iterates raw code units so
/// it only strips ASCII whitespace (space, tab, newline, carriage return).
/// This matches Lua's `gsub("%s+", "")` for version strings.
@safe
string removeWhitespace(string s) {
size_t newLen = 0;
for (size_t i = 0; i < s.length; i++) {
char c = s[i];
if (c != ' ' && c != '\t' && c != '\n' && c != '\r')
newLen++;
}
if (newLen == s.length)
return s; // no whitespace — return original
char[] buf = new char[newLen];
size_t pos = 0;
for (size_t i = 0; i < s.length; i++) {
char c = s[i];
if (c != ' ' && c != '\t' && c != '\n' && c != '\r')
buf[pos++] = c;
}
// Safe: buf is freshly allocated, no other references
return (() @trusted => cast(string) buf)();
}
// ─────────────────────────────────────────────────────────────────────────────
// Version comparison
// ─────────────────────────────────────────────────────────────────────────────
/// Compares two version strings using RPM-style semantics.
///
/// Returns -1 if a < b, 0 if a == b, 1 if a > b.
///
/// Whitespace is stripped from both inputs. Versions are split into
/// alternating digit and letter segments (separators like `.`, `-`, `_`,
/// `~` are skipped). Digit segments compare numerically (leading zeros
/// ignored), letter segments compare lexically. When one version runs
/// out of segments, the shorter version is considered older.
@safe
int compare(string a, string b) {
a = removeWhitespace(a);
b = removeWhitespace(b);
size_t ia = 0;
size_t ib = 0;
while (true) {
string sa;
string sb;
size_t na;
size_t nb;
bool hasA = nextSegment(a, ia, sa, na);
bool hasB = nextSegment(b, ib, sb, nb);
if (!hasA || !hasB) {
if (!hasA && !hasB) return 0; // both exhausted → equal
if (!hasA) return -1; // a exhausted first → a older
return 1; // b exhausted first → a newer
}
ia = na;
ib = nb;
int c;
if (isDigit(sa[0]) && isDigit(sb[0]))
c = cmpNumeric(sa, sb);
else {
if (sa < sb) c = -1;
else if (sa > sb) c = 1;
else c = 0;
}
if (c != 0) return c;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Dependency parsing
// ─────────────────────────────────────────────────────────────────────────────
/// Parses a dependency specification string into a `DepConstraint`.
///
/// Examples:
/// "libffi" → DepConstraint("libffi", DepOp.none, "")
/// "pcre2>=10.42" → DepConstraint("pcre2", DepOp.ge, "10.42")
/// "x=1.0" → DepConstraint("x", DepOp.eq, "1.0") ("=" normalized)
///
/// Throws `TypesException` or `Exception` on malformed input.
///
/// Delegates to `DepConstraint.parse` which provides the canonical
/// implementation (already ported from ZETA in types.d by task 4).
@safe
DepConstraint parseDep(scope const string spec) {
return DepConstraint.parse(spec);
}
// ─────────────────────────────────────────────────────────────────────────────
// Constraint satisfaction
// ─────────────────────────────────────────────────────────────────────────────
/// Checks whether `installed` version satisfies the given `constraint`.
///
/// When `constraint.op` is `DepOp.none` (unconstrained), always returns
/// `true`. Otherwise compares `installed` against `constraint.ver` using
/// the operator semantics:
///
/// DepOp.ge → compare >= 0 DepOp.le → compare <= 0
/// DepOp.eq → compare == 0 DepOp.ne → compare != 0
/// DepOp.gt → compare > 0 DepOp.lt → compare < 0
@safe
bool satisfies(scope const string installed, scope const DepConstraint constraint) {
int c;
final switch (constraint.op) {
case DepOp.none:
return true;
case DepOp.ge:
c = compare(installed, constraint.ver);
return c >= 0;
case DepOp.le:
c = compare(installed, constraint.ver);
return c <= 0;
case DepOp.eq:
c = compare(installed, constraint.ver);
return c == 0;
case DepOp.ne:
c = compare(installed, constraint.ver);
return c != 0;
case DepOp.gt:
c = compare(installed, constraint.ver);
return c > 0;
case DepOp.lt:
c = compare(installed, constraint.ver);
return c < 0;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Unittests — ported from references/ZETA/tests/suites/vercmp.lua
// ─────────────────────────────────────────────────────────────────────────────
@safe unittest {
// Equal versions
assert(compare("1.0", "1.0") == 0);
assert(compare("1.0.0", "1.0.0") == 0);
assert(compare("2.3.4", "2.3.4") == 0);
}
@safe unittest {
// Numeric ordering
assert(compare("1.10", "1.9") > 0);
assert(compare("2.0", "1.99") > 0);
assert(compare("1.0", "0.99") > 0);
assert(compare("1.1", "1.10") < 0);
}
@safe unittest {
// Extra segments win (rpm-style): longer is newer
assert(compare("1.0.0", "1.0") > 0);
assert(compare("1.0rc1", "1.0") > 0);
assert(compare("1.0a", "1.0") > 0);
}
@safe unittest {
// Letter segments compare lexically
assert(compare("1.0beta", "1.0alpha") > 0);
assert(compare("1.0alpha", "1.0beta") < 0);
}
@safe unittest {
// Whitespace is ignored
assert(compare(" 1.0 ", "1.0") == 0);
}
@safe unittest {
// Leading zeros are ignored in digit segments
assert(compare("01.05", "1.5") == 0);
assert(compare("1.000", "1.0") >= 0);
}
@safe unittest {
// Empty and single-segment strings
assert(compare("", "") == 0);
assert(compare("", "1") == -1);
assert(compare("1", "") == 1);
}
@safe unittest {
// parseDep: plain name (unconstrained)
auto d = parseDep("libffi");
assert(d.name == "libffi");
assert(d.op == DepOp.none);
assert(d.ver == "");
}
@safe unittest {
// parseDep: constrained with operator
auto d1 = parseDep("pcre2>=10.42");
assert(d1.name == "pcre2");
assert(d1.op == DepOp.ge);
assert(d1.ver == "10.42");
// Whitespace around operator
auto d2 = parseDep("pcre2 >= 10.42");
assert(d2.name == "pcre2");
assert(d2.op == DepOp.ge);
assert(d2.ver == "10.42");
auto d3 = parseDep("glib<=2.8");
assert(d3.name == "glib");
assert(d3.op == DepOp.le);
assert(d3.ver == "2.8");
}
@safe unittest {
// Single equals becomes == (Perl-style)
auto d = parseDep("x=1.0");
assert(d.op == DepOp.eq);
}
@safe unittest {
// parseDep rejects garbage
bool threw;
threw = false;
try { parseDep("!!!"); } catch (Exception) { threw = true; }
assert(threw, "expected parseDep(\"!!!\") to throw");
threw = false;
try { parseDep("foo bar baz"); } catch (Exception) { threw = true; }
assert(threw, "expected parseDep(\"foo bar baz\") to throw");
threw = false;
try { parseDep(""); } catch (Exception) { threw = true; }
assert(threw, "expected parseDep(\"\") to throw");
}
@safe unittest {
// satisfies: all operators matched against installed version
assert(satisfies("10.42", DepConstraint("", DepOp.ge, "10.42")));
assert(satisfies("10.43", DepConstraint("", DepOp.ge, "10.42")));
assert(!satisfies("10.2", DepConstraint("", DepOp.ge, "10.42")));
assert(satisfies("10.41", DepConstraint("", DepOp.le, "10.42")));
assert(satisfies("10.42", DepConstraint("", DepOp.eq, "10.42")));
assert(!satisfies("10.43", DepConstraint("", DepOp.eq, "10.42")));
assert(satisfies("10.43", DepConstraint("", DepOp.ne, "10.42")));
assert(satisfies("10.43", DepConstraint("", DepOp.gt, "10.42")));
assert(satisfies("10.41", DepConstraint("", DepOp.lt, "10.42")));
// Unconstrained (DepOp.none) — always satisfied
assert(satisfies("anything", DepConstraint("", DepOp.none, "")));
}
+354
View File
@@ -0,0 +1,354 @@
#!/usr/bin/env bash
# tofu smoketest — full e2e: install → search → upgrade → remove
# against a LOCAL mock ZUUR. No network access, no root required.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
TOFU_BIN="$REPO_ROOT/tofu"
RED='\033[31m'
GREEN='\033[32m'
NC='\033[0m'
PASS_COUNT=0
FAIL_COUNT=0
pass() { echo -e "${GREEN}PASS${NC}"; PASS_COUNT=$((PASS_COUNT + 1)); }
fail() { echo -e "${RED}FAIL${NC}"; FAIL_COUNT=$((FAIL_COUNT + 1)); exit 1; }
# ── Step 0: Build tofu binary ────────────────────────────────────────
echo "=== tofu smoketest ==="
echo ""
echo -n "step 0: build tofu binary... "
if [ -f "$TOFU_BIN" ]; then
pass # pre-built
else
(cd "$REPO_ROOT" && dub build 2>&1 >/dev/null) || fail
pass
fi
# ── Step 1: Create temp dir + hello "binary" ─────────────────────────
TMP="$(mktemp -d)"
trap 'kill $SERVER_PID 2>/dev/null; rm -rf "$TMP"' EXIT
echo -n "step 1: create temp workspace and hello binary... "
mkdir -p "$TMP/bin"
# Use a shell script as the "binary" — no compiler dependency
cat > "$TMP/hello" <<'HELLOEOF'
#!/bin/sh
echo "hello from tofu"
HELLOEOF
chmod +x "$TMP/hello"
mkdir -p "$TMP/source"
cp "$TMP/hello" "$TMP/source/hello"
pass
# ── Step 2: Create hello.recipe ──────────────────────────────────────
# NOTE: deps = {} is REQUIRED — a D bug in info.d's scanDepsArray()
# stores indexOf() return in size_t, causing an ArrayIndexError when
# "deps" is absent. See .omo/notepads/tofu-core/problems.md.
echo -n "step 2: create hello.recipe... "
cat > "$TMP/hello.recipe" <<'RECIPEEOF'
name = "hello"
version = "1.0"
summary = "A tiny demo package"
build_system = "make"
deps = {}
test = "test -f ${DESTDIR}/usr/bin/hello"
RECIPEEOF
pass
# ── Step 3: Create package.lua ───────────────────────────────────────
echo -n "step 3: create package.lua... "
cat > "$TMP/package.lua" <<'PKGLUAEOF'
return {
name = "hello",
version = "1.0",
url = "local",
sha256 = "any",
archive = { strip = 1 },
}
PKGLUAEOF
pass
# ── Step 4: Create fake zeta-makepkg and fake zeta ───────────────────
echo -n "step 4: create fake zeta-makepkg and zeta... "
# Fake zeta-makepkg: simulates building a package.
# Args: <recipePath> --output <dir> -j<N> --no-index --repo <url>
cat > "$TMP/bin/zeta-makepkg" <<'MKPKGEOF'
#!/usr/bin/env bash
# $1 = recipe path, $3 = output dir (after --output)
recipe_path="$1"
output_dir=""
i=1
while [[ $i -le $# ]]; do
if [[ "${!i}" == "--output" ]]; then
n=$((i + 1))
output_dir="${!n}"
break
fi
i=$((i + 1))
done
if [[ -z "$output_dir" ]]; then
echo "error: --output not specified" >&2
exit 1
fi
# Extract package name from recipe path
pkg_name=$(basename "$(dirname "$recipe_path")")
pkg_dir="$output_dir/packages/$pkg_name"
mkdir -p "$pkg_dir"
# Create the "built" binary inside a tarball
tmpd=$(mktemp -d)
cat > "$tmpd/hello" <<'BINEOF'
#!/bin/sh
echo "hello from tofu"
BINEOF
chmod +x "$tmpd/hello"
tar -czf "$pkg_dir/${pkg_name}-1.0.tar.gz" -C "$tmpd" hello
rm -rf "$tmpd"
# Copy package.lua from recipe cache directory
recipe_dir="$(dirname "$recipe_path")"
if [[ -f "$recipe_dir/package.lua" ]]; then
cp "$recipe_dir/package.lua" "$pkg_dir/package.lua"
else
echo "error: package.lua not found in $recipe_dir" >&2
exit 1
fi
exit 0
MKPKGEOF
chmod +x "$TMP/bin/zeta-makepkg"
# Fake zeta: simulates -LocalProvide and -Remove.
# Args: -LocalProvide <pkg> --pass OR -Remove <pkg> --pass [--force]
cat > "$TMP/bin/zeta" <<'ZETAEOF'
#!/usr/bin/env bash
cmd="$1"
pkg="$2"
ZETA_ROOT="${ZETA_ROOT:-}"
if [[ "$cmd" == "-LocalProvide" ]]; then
echo "installing $pkg-1.0"
if [[ -n "$ZETA_ROOT" ]]; then
mkdir -p "$ZETA_ROOT/usr/bin"
# Extract from tarball if available
cache_dir="${TOFU_CACHE_DIR:-}"
tarball="$cache_dir/built/packages/$pkg/${pkg}-1.0.tar.gz"
if [[ -f "$tarball" ]]; then
tar -xzf "$tarball" -C "$ZETA_ROOT/usr/bin/" hello 2>/dev/null || true
else
cat > "$ZETA_ROOT/usr/bin/$pkg" <<'HEOF'
#!/bin/sh
echo "hello from tofu"
HEOF
chmod +x "$ZETA_ROOT/usr/bin/$pkg"
fi
fi
exit 0
elif [[ "$cmd" == "-Remove" ]]; then
echo "removing $pkg"
if [[ -n "$ZETA_ROOT" ]]; then
rm -f "$ZETA_ROOT/usr/bin/$pkg"
fi
exit 0
else
echo "unknown zeta command: $cmd" >&2
exit 1
fi
ZETAEOF
chmod +x "$TMP/bin/zeta"
export PATH="$TMP/bin:$PATH"
pass
# ── Step 5: Set up mock ZUUR layout ──────────────────────────────────
echo -n "step 5: create mock ZUUR layout... "
MOCK_ZUUR="$TMP/zuur"
mkdir -p "$MOCK_ZUUR/recipes/hello"
mkdir -p "$MOCK_ZUUR/binary/packages"
# index.lua — single package entry
cat > "$MOCK_ZUUR/index.lua" <<'INDEXEOF'
return { { name = "hello", ver = "1.0", summary = "A tiny demo", pool = "recipes" } }
INDEXEOF
# Recipe in ZUUR
cp "$TMP/hello.recipe" "$MOCK_ZUUR/recipes/hello/hello.recipe"
# package.lua in ZUUR
cp "$TMP/package.lua" "$MOCK_ZUUR/recipes/hello/package.lua"
pass
# ── Step 6: Start http.server on ephemeral port ──────────────────────
echo -n "step 6: start mock ZUUR http server... "
# Pick a random high port and retry if busy
PORT=0
for attempt in $(seq 1 20); do
PORT=$((20000 + RANDOM % 10000))
if ! (echo >/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then
break
fi
done
(cd "$MOCK_ZUUR" && python3 -m http.server "$PORT" --bind 127.0.0.1 > /dev/null 2>&1) &
SERVER_PID=$!
# Wait for server to be ready
ZUUR_URL="http://127.0.0.1:$PORT"
for i in $(seq 1 20); do
if curl -s "$ZUUR_URL/index.lua" > /dev/null 2>&1; then
break
fi
sleep 0.2
done
# Verify server is up
if ! curl -s "$ZUUR_URL/index.lua" > /dev/null 2>&1; then
echo "ERROR: http server failed to start" >&2
exit 1
fi
pass
# ── Step 6b: Export env vars ─────────────────────────────────────────
export TOFU_ZUUR_URL="$ZUUR_URL"
export TOFU_CACHE_DIR="$TMP/cache"
export TOFU_ZETA_TOOLCHAIN_PATH="$TMP/bin/zeta-makepkg"
export TOFU_ZETA_PATH="$TMP/bin/zeta"
export ZETA_ROOT="$TMP/root"
mkdir -p "$TOFU_CACHE_DIR" "$ZETA_ROOT"
echo " ZUUR_URL=$TOFU_ZUUR_URL"
echo " CACHE_DIR=$TOFU_CACHE_DIR"
echo ""
# ── Step 7: Search (-Ss) ─────────────────────────────────────────────
# Capture output then grep — avoids pipefail false-negatives if tofu
# exits non-zero but still produces valid stdout.
echo -n "step 7: tofu -Ss hello (search)... "
SEARCH_OUT=$("$TOFU_BIN" -Ss hello 2>/dev/null) || true
if echo "$SEARCH_OUT" | grep -q "hello"; then
pass
else
fail
fi
# ── Step 8: Install (-S hello --noconfirm) ───────────────────────────
echo -n "step 8: tofu -S hello --noconfirm (install)... "
if "$TOFU_BIN" -S hello --noconfirm 2>&1; then
:
else
fail
fi
# Verify: installed.json contains hello
if [ -f "$TOFU_CACHE_DIR/installed.json" ] && grep -q '"hello"' "$TOFU_CACHE_DIR/installed.json"; then
echo -n " installed.json check... "
pass
else
echo -n " installed.json check... "
fail
fi
# Verify: $ZETA_ROOT/usr/bin/hello exists (fake zeta installed it)
if [ -x "$ZETA_ROOT/usr/bin/hello" ]; then
echo -n " binary in root check... "
pass
else
echo -n " binary in root check... "
fail
fi
# ── Step 9: Upgrade (-Syu --noconfirm) ───────────────────────────────
echo -n "step 9: tofu -Syu --noconfirm (upgrade — nothing to do)... "
UPGRADE_OUT=$("$TOFU_BIN" -Syu --noconfirm 2>&1) || true
if echo "$UPGRADE_OUT" | grep -qi "nothing to do"; then
pass
else
fail
fi
# ── Step 10: Info (-Si hello) ────────────────────────────────────────
echo -n "step 10: tofu -Si hello (info)... "
INFO_OUT=$("$TOFU_BIN" -Si hello 2>/dev/null) || true
if echo "$INFO_OUT" | grep -q "hello"; then
pass
else
fail
fi
# ── Step 11: Remove (-R hello --noconfirm) ───────────────────────────
echo -n "step 11: tofu -R hello --noconfirm (remove)... "
if "$TOFU_BIN" -R hello --noconfirm 2>&1; then
:
else
fail
fi
# Verify: $ZETA_ROOT/usr/bin/hello is GONE
if [ ! -f "$ZETA_ROOT/usr/bin/hello" ]; then
echo -n " binary removed check... "
pass
else
echo -n " binary removed check... "
fail
fi
# Verify: installed.json no longer has hello
if [ -f "$TOFU_CACHE_DIR/installed.json" ] && ! grep -q '"hello"' "$TOFU_CACHE_DIR/installed.json"; then
echo -n " installed.json cleared check... "
pass
else
# If installed.json was removed entirely, that's also fine
if [ ! -f "$TOFU_CACHE_DIR/installed.json" ]; then
echo -n " installed.json cleared check... "
pass
else
echo -n " installed.json cleared check... "
fail
fi
fi
# ── Step 12: Install nonexistent → exit 2 ────────────────────────────
echo -n "step 12: tofu -S nonexistent --noconfirm (expect exit 2)... "
set +e
"$TOFU_BIN" -S nonexistent --noconfirm 2>/dev/null
RC=$?
set -e
if [ "$RC" -eq 2 ]; then
pass
else
echo " got exit code $RC, expected 2"
fail
fi
# ── Step 13: Cleanup (trap handles it) ───────────────────────────────
echo -n "step 13: cleanup... "
# trap on EXIT will clean up $TMP and kill server
pass
# ── Summary ──────────────────────────────────────────────────────────
echo ""
echo "=== smoketest complete ==="
echo "PASS: $PASS_COUNT checks passed"
if [ "$FAIL_COUNT" -gt 0 ]; then
echo "FAIL: $FAIL_COUNT checks failed"
exit 1
fi
echo "All checks passed."
exit 0