73 KiB
Learnings — tofu-core
Conventions, patterns, and successful approaches discovered during work on this plan.
Auto-scaffolded by /start-work. Append new entries below - never overwrite.
Task 5 — tofu.vercmp (RPM-style version comparison)
Module naming
versionis a D reserved keyword (conditional compilation). Module is namedtofu.vercmp, filesrc/tofu/vercmp.d. TheDepConstraintstruct intypes.duses fieldver(notversion) for the same reason.
Algorithm (exact port from ZETA/lib/vercmp.lua)
nextSegment: skips any non-digit/non-letter chars (separators); then reads a contiguous run of same-type chars. Returns(segment, nextIndex).cmpNumeric: strips leading zeros from both segments ("007"→"7","000"→"0"). Longer stripped string wins; then lexicographic.compare: strips all whitespace, then loops over alternating segments. If both segments are digit-typed →cmpNumeric, else lexical. When one version exhausts segments: the exhausted one is older (shorter = older). This matches RPM semantics:compare("1.0.0", "1.0") > 0.satisfies: usesfinal switchonDepOpenum. Maps tocompareresult as expected (ge→c>=0, le→c<=0, eq→c==0, ne→c!=0, gt→c>0, lt→c<0).parseDep: delegates toDepConstraint.parse(already ported in types.d by task 4). Both implementations are kept consistent.
whitespace stripping
- Could not use
filter+arraybecause Phobos auto-decodesstringtodcharrange elements, returningdchar[]notstring. Implemented a manualremoveWhitespacehelper using raw code-unit indexing, with a@trustedcast since the freshly-allocatedchar[]has no aliasing.
Test porting
- All 10 ZETA test categories ported as separate
@safe unittestblocks. - 4 modules pass unittests (types, vercmp, plus pre-existing ones).
dub buildpasses with warnings-as-errors enabled.
Future considerations
- When task 12 (constraint resolution) imports
tofu.vercmp, thecompareandsatisfiesfunctions are ready. TheparseDepintegration withDepConstraint.parseensures consistency between ZETA and tofu's dep constraint semantics.
Task 2: Config Loading (src/tofu/config.d)
toml package API (v1.0.0, Kripth/toml)
- Import:
import toml;→ providesTOMLDocument,TOMLValue,parseTOML,TOMLException,TOMLParserException TOMLDocumenthasalias table this— acts likeTOMLValue[string]AA- Access sections:
doc["core"]returnsTOMLValuewithtype == TOML_TYPE.TABLE - Access string values:
doc["core"]["key"].str(throws if type mismatch) - Check existence:
"key" in docreturnsTOMLValue*(null if absent) parseTOML()is@system— must wrap in@trustedblock when calling from@safecode- Keys without
.str/.integeraccess will throwTOMLException
D language gotchas
- Nested function declarations: D does NOT allow
void foo() { ... }inside a function body. Use lambdas:auto foo = () { ... }; foo();or anonymous() @trusted { ... }(); versionis a keyword: Cannot usestring version;as field name — usestring version_;or initialize with= ""parseTOMLis@system: The toml package's parse function isn't marked@safe— must wrap in@trustedlambdastd.file.readTextis@system: Same — wrap in@trustedstd.process.environment.get(): Returns empty string when env var is unset;"VAR" in environmentchecks existence butenvironment.get("VAR", "default")is cleanerstderr.writeflnis@safein recent DMD (2.106+) — usable directly in@safecode- AA
inoperator: Returns pointer (V*) ornull— must check for null before dereferencing
Pattern: env-overrideable config loading
- Priority: env vars > TOML file > hardcoded defaults
- TOML read happens BEFORE env override — so TOML values serve as base, env vars overwrite
- Missing config file → no error, just skip to defaults
- Malformed TOML →
TOMLParserExceptioncaught, warning to stderr, fall back to defaults - Testability:
load()accepts optionalenvOverridesmap and explicitconfigFilepath to avoid real env/filesystem
Config design decisions
TOFU_CONFIGenv var overrides default config file path (~/.config/tofu/config.toml)~in default paths expanded withstd.path.expandTilde- All path fields default to empty string when "find on PATH" — no hardcoded binary paths
defaultJobstyped asint(notsize_t/ulong) since it's a user-facing count
buildOptions warningsAsErrors deprecation
- DUB warns about
warningsAsErrorsinbuildOptions— recommendsbuildRequirementsinstead - Not blocking, informational only
Task 3 — tofu.log (colored, NO_COLOR-aware logging)
- dmd 2.112 gotcha: the
std.stdioglobalsstdout/stderrare@systemto access —makeGlobaluses__gshared File result, which fails@safeinference. An@safefunction cannot callstdout.writeln(...)directly. Fix: route every write through a tiny@trustedhelper (writeStdout(string)/writeStderr(string)), keep the public log functions@safe. Compiler hint: "using__gsharedinstead ofsharedmakes it fail to infer@safe". std.process.environment(dmd 2.112): it is anabstract final classof static methods —environment.get(name)is@safeand returnsnullwhen the var is unset (so "NO_COLOR set" ==get !is null, even for an empty string).environment["K"] = vandenvironment.remove(name)are@trusted. NOT deprecated in 2.112.- Testable color decision: make
colorEnabled()re-read the environment on every call instead of caching instatic this(). Enables NO_COLOR/TERM tests without restarting the process. Costs onegetenvper log line — negligible. - Env save/restore in unittests: a
private struct ColorEnv(inversion(unittest)) that snapshots NO_COLOR+TERM in its ctor and restores them in~this()(destructor) —auto env = ColorEnv("1", "xterm")scopes the restore.nullvalue = remove the var. Double-destruction from a copied temporary is harmless (restore is idempotent). - Capturing stdout/stderr in unittests: swap the global
stdout/stderrFile to aFile(name, "w+"), write,flush(), swap back, thenrewind()+readln(). Restore the global before the temp File's destructor runs. - zeta log format details:
warnpaints the whole prefix yellow (paint("yellow", "warn ") ~ msg), buterrorpaints only the word then" " ~ msg(paint("red","error") ~ " " ~ msg). Assertion must expect\x1b[31merror\x1b[0m failed deploy— the reset code sits between "error" and the message. versionis a D keyword — cannot be used as a struct field / identifier (trap for the paralleltypes.dwork).std.stdio.writelnflushes per call — already satisfies "no buffering"; no explicitflush()needed in production paths.- Verifying a single module without the full build:
dmd -main -unittest -i src/tofu/log.druns just that module's unittests when sibling modules (config.d,types.d) are mid-edit by parallel agents.-ipulls imports in automatically.
Task 4 — tofu.types (core data structures)
versionis a D keyword — all struct fields namedversionmust be renamed tover. The D compiler treatsversionas a conditional-compilation directive and rejects it as a field name (see task 3 learnings above).- Struct field defaults in D:
stringfields initialise tonullby default. To enforce empty-string semantics (""), every string field must be explicitly= "". final switchfor exhaustive enum handling: usingfinal switchon enum types forces the compiler to verify all cases are covered — catches missing branches at compile time. Used forpoolToString().- Manual character scanning preferred over
std.regex: the dep-spec parser uses simple char-by-char scanning withisNameChar()/isWhite()helpers instead of regex. This avoids thestd.regexdependency, keeps the parser@safe, and matches the Lua reference semantics exactly. - Dep spec parsing order matters: 2-character operators (
>=,<=,==,~=) MUST be checked before 1-character operators (>,<,=) to avoid false matches. The Lua reference uses an ordered tableOPS = { ">=", "<=", "==", "~=", ">", "<", "=" }— we replicate this with if/else chains. =normalises to==: the Lua code hasif op == "=" then op = "==" end; we map both toDepOp.eq.- Trailing garbage detection: after extracting version chars, any non-whitespace remaining in the spec string causes a
TypesException. The Lua pattern uses$anchor for this. dub testincludes all.dfiles in source paths for executable targets, not just imported modules. Pre-existing compile errors in sibling modules (config.d) block test runs. Fixed config.d by removingprivatefrom a nested function (access specifiers not allowed on local functions in D).BuildResult.failedasBuildFailure[]: the plan describes it asstruct {string name; string reason}[]— in D this is a named structBuildFailurewith array fieldBuildFailure[] failed. ThefailedNames()helper extracts just the names for caller convenience.DepConstraint.parseis a static factory method: returns a newDepConstraintby value, never allocates on the heap.@safethroughout.- All structs are
@safe: no methods do I/O, no@systemcalls. The module stands alone — no imports from other tofu modules.
Task 6 — tofu.http (sync HTTP client via std.net.curl)
std.net.curl API (key members for HTTP struct)
- Construction:
HTTP(url)viaopCall, orHTTP()+.url = urlsetter. The.urlsetter auto-prependshttp://if no scheme is present. - Timeouts (all are
@propertysetters takingcore.time.Duration):connectTimeout— connect phase only (CurlOption.connecttimeout_ms).operationTimeout— DNS + connect + transfer total (CurlOption.timeout_ms).dataTimeout— low-speed activity timeout (NOT total read timeout; setslow_speed_limit+low_speed_time).- For a "read 120s" requirement use
operationTimeout = 120.seconds— it covers the entire operation including data transfer.
- Redirects:
maxRedirects(property setter, takesuint). Defaults to 10. Set to 0 to disable,uint.maxfor infinite. Internally setsCurlOption.followlocation+CurlOption.maxredirs. - User-Agent:
setUserAgent(string)— instance method (not property). - onReceive:
void delegate(ubyte[])returningsize_t. Must accept all bytes or the request aborts. Usereturn data.length;. - onReceiveStatusLine: Not a direct property on HTTP struct — must
be set via the
onReceiveHeaderproperty setter which wraps the delegate and internally firesonReceiveStatusLinewhen it detects an HTTP status line. Afterperform()the final status is available viahttp.statusLine. - statusLine:
HTTP.StatusLinestruct with.code(ushort),.majorVersion,.minorVersion,.reason. Reset to zero before eachperform(). - perform():
CurlCode perform(ThrowOnError = Yes.throwOnError). Default throws on curl errors, but the thrown exception classes are:CurlTimeoutException : CurlException— operation timed out (code 28).CurlException— all other curl errors.HTTPStatusException : CurlException— only thrown by high-level wrappers (_basicHTTP); low-levelperform()does NOT throw on HTTP status codes — you must checkstatusLine.codeyourself.
- Exception isolation pattern: catch
CurlTimeoutExceptionandCurlExceptionin@trustedhelpers, re-throw asHttpException(which extendsException, notCurlException). Callers never see curl types. - verifyPeer / verifyHost: On by default in HTTP struct (no change needed).
@safe / @trusted architecture
- All public functions (
get,downloadFile) are@safe. - The
configure(HTTP),getImpl, anddownloadFileImplhelpers are@trusted— they are the ONLY placesstd.net.curl(which is@system-heavy) is used.
Download pattern (.part temp file + rename)
- Ported from ZETA
lib/fetch.lua: write todest.part, rename on success, remove partial on error.scope(failure)at function level handles all error paths automatically. File(tmpPath, "wb")— binary write mode. File destructor auto-closes; explicitf.close()needed before rename.
Unittest local server
- Used
std.concurrency.spawn+std.socket.TcpSocketfor a self-contained one-shot HTTP server (no external dependency on Python3). - Pattern: bind ephemeral port (
InternetAddress.PORT_ANY), spawn thread thataccept()s one connection, sends canned HTTP response, exits.cast(shared)the listener to pass tospawn. Mark test helpers@trusted. - HTTP response must include
Content-Lengthheader or libcurl hangs.
Connection refused test
- Bind socket, record port, close it, then
get()to that port. curl returns error 7 (CURLE_COULDNT_CONNECT) →CurlException→ caught and re-thrown asHttpException.
Build verified
dub buildpasses with warnings-as-errors.dub testpasses — all 5 modules (config, log, types, vercmp, http).
Task 8 — tofu.fetch (ZUUR recipe download to cache)
Architecture
fetchRecipe(name, cfg)orchestrates a known-file download sequence (no directory listing assumed — plan constraint).- Download order: .recipe (required) → package.lua (optional) → build.sh (optional) → custom build_script (if referenced).
- 404 on .recipe translates to
FetchException("package 'X' not found in ZUUR recipes")(user-friendly). - 404 on optional files (package.lua, build.sh) is logged and skipped.
- 404 on a referenced
build_scriptis a real error (build cannot proceed). - Non-404 HTTP errors or connection failures are caught, partial files
cleaned up, and re-thrown as
FetchException.
Light recipe scanning
- Full Lua parsing is task 9/10's job. Task 8 only needs to discover
build_system = "custom"+build_script = "..."to fetch referenced build scripts. - Manual string scanning: find key, skip whitespace/
=, read quoted value. Handles arbitrary whitespace variations. Avoidsstd.regexdependency. extractBuildScript(content)returns the script path only whenbuild_system = "custom"is also found.
@safe / @trusted architecture
fetchRecipeandextractBuildScriptare@safe.- Filesystem operations (
exists,mkdirRecurse,readText,remove,rmdirRecurse,write) are isolated in@trustedwrappers. - Follows the same pattern as
http.dandconfig.d. std.file.readTextand friends are@systemin DMD 2.112.
Name conflicts between imports
tofu.config.Configconflicts withstd.process.Config(both imported inversion(unittest)blocks). Solution: fully qualify astofu.config.Configin function signatures wherestd.processis also imported.
std.string.indexOf vs std.algorithm.canFind
"string".canFind("substr")fails in D because UFCS onstring(a range ofdchar) tries to find astringelement in adcharrange. Usee.msg.indexOf("pattern") >= 0instead.std.algorithm.searching.canFind(haystack, needle)as a free function works but is less readable.
Test strategy
- Used
python3 -m http.serveron ephemeral ports for multi-file HTTP serving — the one-shot TCP responder fromhttp.dhandles only one connection per spawn. - Pattern:
findFreePort()(bind + close ephemeral port), spawn server on that port,waitForPort()(connection polling with 100ms sleep, 50 attempts max), run test,killServer(pid)(SIGTERM). spawnProcessreturnsPid(a class, not a struct with.pidfield). Store thePidobject directly, not.pid.Socket.localAddress()returns abstractAddress— must cast toInternetAddressto access.port.- Five test scenarios: happy path, 404-on-recipe, custom build_script, optional-file-404s, connection-failure cleanup.
Partial file cleanup
downloadFileinternally cleans up.partfiles on failure (scope(failure) removes temp file). Our extra cleanup layer handles the case where a previous step succeeded but a later step fails — though for step 1 failure (recipe), nothing else was downloaded yet.- Unittest scenario 5 verifies: after a connection error on recipe download, the cache directory is clean (no files).
DUB details
dub.jsonuseswarningsAsErrorsinbuildOptions(generates a deprecation warning aboutbuildRequirements, but non-blocking).- All 8 modules pass unittests with warnings-as-errors enabled.
dub buildproduces thetofubinary successfully.
Task 10 — tofu.binary (zuur/binary package.lua version checking)
Architecture
- Module
tofu.binarydepends on:tofu.types(BinaryCheckResult, DepConstraint),tofu.http(get, HttpException),tofu.config(Config, binaryManifestUrl),tofu.vercmp(satisfies),tofu.log(logDetail, logInfo). checkBinaryVersion(name, constraint, cfg)fetches the package.lua manifest from zuur/binary via HTTP, parses the version, and checks satisfaction.
Key logic — 404 vs other errors
- HTTP 404 → binary doesn't exist (recipe-only). Return
BinaryCheckResult(false, "", false). - Any other HttpException (500, timeout, connection failure) → rethrow — network problems must propagate, not be silently swallowed.
- Detection:
e.msg.canFind("404")— the HttpException format is"HTTP %d fetching <url>".
Version extraction from Lua package.lua
- Light parse: scan for
version = "..."in the manifest body. - Word-boundary check on "version" keyword (preceding char must be whitespace/
{/,/;, following char must be whitespace/=). - If version field not found or malformed →
BinaryException. - This avoids a full Lua parser — package.lua manifests are flat key-value tables.
- Empty version string (e.g.
version = "") also throws BinaryException.
Memoization
- Module-level
BinaryCheckResult[string] _versionCache— keyed by package name. - On cache hit: recompute satisfaction against caller's constraint (same version, different constraint possible).
- On 404: cache the "not found" result so subsequent calls don't re-fetch.
- Lifetime: one command run. Tests use distinct package names to avoid cross-state contamination.
- The cache stores
existsandver;satisfiesis always recomputed per-constraint.
version keyword trap (again)
- Local variable
versionconflicts with D keyword. Usedverinstead. Same pattern as types.d where struct field isver.
Test infrastructure
- Reused local one-shot TCP server pattern from http.d:
bindAndSpawn(response)— bind ephemeral port, spawn thread, return URL.oneShotResponder(listener, response)— accept one connection, send canned HTTP response, exit.httpResponse(code, reason, body)— build minimal HTTP/1.1 response with Content-Length.testConfig(baseUrl)— create a Config pointing zuurUrl at the test server.
Test cases (6/6 pass)
- Binary exists, version 2.1.0, constraint ge 2.0 → satisfies:true ✓
- Binary exists, version 1.9, constraint ge 2.0 → satisfies:false ✓
- 404 → exists:false, ver:"", satisfies:false ✓
- 500 → HttpException rethrown (not swallowed) ✓
- Manifest without version field → BinaryException ✓
- Version 2.1.0 with unconstrained dep (op none) → satisfies:true ✓
@safe annotation consistency
checkBinaryVersionand all helpers marked@safe. The function callshttp.get()(which is@safe),satisfies(@safe), and log functions (@safe). No@trustedblocks needed in production code.- Test helpers (
oneShotResponder,bindAndSpawn,httpResponse) are@trustedsince they usestd.socket.
Note: pre-existing fetch.d compile issue
fetch.d(from parallel task 7) has a missingstd.algorithm.searching : canFindimport that blocksdub test.dub buildpasses (fetch.d is excluded via.skipextension renames by the parallel agent). Binary.d tests verified via standalonedmd -I... -i -main -unittestcompilation — all 7 modules pass.
Task 7 — tofu.index (ZUUR index fetch & sandboxed Lua parse)
Security: whitelist sandbox (NOT blacklist)
The task spec suggested stripping dangerous globals (blacklist: io=nil, os=nil, ...). We chose the ZETA lib/sandbox.lua whitelist approach instead because:
- Future-proof: New dangerous globals added to Lua (e.g.
rawlenin 5.3) are blocked by default — the index only sees explicitly allowed functions. - Proven: ZETA has used this sandbox in production; it has passed security review.
- Allowed globals:
_VERSION,assert,error,ipairs,next,pairs,pcall,select,tonumber,tostring,type,rawequal,rawget,rawset,setmetatable,getmetatable,unpack,string,table,math. NOTHING else.
Lua 5.1 vs 5.2+ sandbox differences — CRITICAL for future tasks
The sandbox must work on both Lua 5.1/5.2+/5.5.x. The key difference:
| Version | Compile API | Set environment |
|---|---|---|
| 5.1 / LuaJIT | loadstring(src, name) |
setfenv(chunk, env) |
| 5.2+ (incl. 5.5.1) | load(src, name, "t", env) |
env is 4th arg to load |
Detection: if setfenv then ... else ... end. In Lua 5.2+, setfenv was removed entirely, so if setfenv is nil (falsy) on 5.2+ — this is the canonical cross-version detection pattern.
What NOT to mix up: loadfile is the FILE loader (takes a path). load/loadstring are STRING loaders. The sandbox loader script uses io.open + f:read("*a") to read the index file content, then load to compile it with the sandbox env. This is deliberately explicit — it's exactly what ZETA's sandbox.loadfile does.
System: The system has Lua 5.5.1 (lua -v → Lua 5.5.1). Our sandbox script uses the 5.2+ branch (load(src, name, "t", env)). The 5.1 branch is retained for portability.
std.process import conflicts
import std.process; (unqualified) pulls in std.process.Config, which conflicts with tofu.config.Config. Fix: use selective imports — import std.process : execute, ProcessException, thisProcessID;. This affected both index.d and fetch.d.
std.json API notes (Phobos)
parseJSON(string)→JSONValue(return type, notauto)JSONValue.type→JSONTypeenum:JSONType.array,JSONType.object(NOTobject_).arrayproperty →@system(must wrap in@trusted).str→ string field on object values- Indexing:
entry["name"]→JSONValue
D heredoc syntax (q"DELIM ... DELIM")
- Opening:
q"SCRIPT(no closing quote on same line — rest of line must be blank) - Closing:
SCRIPT"at start of a new line - Then
;on the same line after"to end the statement
Concurrent agent issues
Both binary.d and fetch.d were created by parallel agents and had:
Confignaming conflicts (unqualifiedimport std.process;)versionkeyword used as variable name (binary.d— already fixed by agent)- Missing
canFindimport (fetch.d) - Different test server patterns (
spawnProcessvsspawn— fetch.d)
When testing, temporarily exclude broken sibling modules with mv file.d file.d.skip.
JSON escaping in Lua
Must escape \\, \", \n, \r, \t in string values before embedding in JSON. Order matters: escape backslash BEFORE quote, or doubled backslashes leak:
s = s:gsub("\\", "\\\\"):gsub('"', '\\"')
Test patterns ported from http.d
Reused the one-shot TCP responder pattern (bindAndSpawn, oneShotResponder, httpResponse) directly from tofu.http test harness. The same spawn+accept+send pattern works for index serving.
Malicious index verification
The sandbox test (test 2) creates a sentinel file, serves an index containing os.execute("rm -rf /"), verifies that fetchIndex throws IndexException, then asserts the sentinel file STILL EXISTS. This is the security-critical validation that the sandbox actually blocks RCE. The error message from Lua is "attempt to call a nil value (global 'os')" because os is absent from the sandbox env — caught by pcall and reported as LUA_ERROR:runtime error: ....
Task 11 — tofu.deps (topological dependency tree builder)
Architecture
- Module
tofu.depsdepends on:tofu.types(Recipe, DepConstraint, TypesException, DepOp). - Port of ZETA
lib/deps.lua:19-74depth-first resolution algorithm. DepNodestores:name,constraints(parsedDepConstraint[]fromrecipe.deps),recipePath.DepTreeis a flatDepNode[]in topological order (deps before dependents, target last).
Algorithm (exact port from ZETA deps.lua)
resolveDepTree(targetName, getRecipe)— accepts ascope Recipe function(string) @safe(the testability seam).- Nested
walk(name, ref chain)does the depth-first walk:- Cycle detection: check
name in inProgress— if so, appendnameto chain and throwDepExceptionwith full chain:"dependency cycle: A -> B -> C -> A". - Memoization: if
name in done, return immediately (skip already-resolved). - Fetch: call
getRecipe(name)— exceptions propagate (e.g. missing package). - Mark:
inProgress[name] = true,chain ~= name. - Recurse: for each dep spec in
recipe.deps, parse viaDepConstraint.parse(depSpec)and recurse onconstraint.name. - Pop & mark:
chain = chain[0 .. $ - 1],inProgress.remove(name),done[name] = true. - Build node: create
DepNodewith all deps parsed intoconstraints, append toorder.
- Cycle detection: check
Key design decisions
functionnotdelegate: D lambdas without captures becomefunctionpointers. Usingfunctionin the parameter type means tests pass without forcing captures. Production callers can pass module-level functions or free functions; if state is needed, use astaticfunction that accesses module state.ref string[] chain: The Lua reference implementation uses a mutable shared table for the chain. In D, passingstring[]byrefachieves the same semantics — modifications (append, pop via slice) are visible to the caller across recursive calls.- Cycle message: Build manually with a
forloop rather than importingstd.array.join— ensures@safecompatibility and avoids Phobos auto-decoding issues. indexOf: Importedstd.string : indexOffor string containment checks in cycle-message assertions.
Test cases (7/7 pass)
- A deps=[B, C>=1.0] — constraint parsing verified (B unconstrained, C ge 1.0); both B,C before A.
- A deps=[B], B deps=[C] — linear chain → [C, B, A].
- A deps=[A] — self-cycle → DepException "A -> A".
- A deps=[B], B deps=[A] — cycle → DepException with both A and B in message.
- Missing dep (getRecipe throws) — TypesException propagates.
- Diamond A→B,C, B→D, C→D — D appears once; order [D, B, C, A].
- Leaf (empty deps) — single node, no constraints.
Build verified
dub buildpasses with warnings-as-errors.dub testpasses — all 10 modules, including deps.d's 7 unittests.- Standalone dmd compilation with
-unittestalso passes.
Task 9 — tofu.cache (recipe cache with version-based staleness)
Architecture
- Module
tofu.cachedepends on:tofu.types(CacheManifest),tofu.config(Config, load),tofu.vercmp(compare),tofu.log(logWarn). - Five public functions:
cacheRecipe,isRecipeStale,cleanRecipeCache,clearBuildCache,clearRecipeCacheAll. - Cache format:
.tofu-cache.jsonincfg.recipesCacheDir(name), containing{"name":"<name>","ver":"<indexVersion>","fetchedAt":<unix-ts>}.
@safe / @trusted architecture
- All public functions marked
@safe. - Filesystem operations (
exists,mkdirRecurse,remove,rmdirRecurse,rename,write,readText) andparseJSONare isolated in single-line@trustedhelpers (fExists,fMkdirRecurse,fRemove,fRmdirRecurse,fRename,fWrite,fReadText,fParseJSON). - Follows the same pattern as
tofu.config,tofu.http,tofu.fetch,tofu.index.
Atomic write (tmp + rename)
- Ported the
.part→ rename pattern fromhttp.d downloadFileImpl. - Manifest written to
.tofu-cache.json.tmp, existing manifest removed, thenrename(tmp, final). rmdirRecursefromstd.fileused for recursive directory cleanup — available since D 2.104, present on DMD 2.112.
Clock.currTime() not currTime()
- In DMD 2.112 / Phobos,
currTimeis astaticmethod ofstruct Clock, not a free function. - Correct import:
import std.datetime : Clock;, usage:Clock.currTime().toUnixTime(). - Attempting
import std.datetime.systime : currTime;orimport std.datetime : currTime;both fail — the symbol is not exported at module level.
JSON writing: q"..." token string gotcha
- D's
q"DELIM ... DELIM"heredoc syntax requires the opening delimiter line to end with nothing after the delimiter:q"EOSfollowed by newline, content, thenEOS"on its own line. - Attempted
q"{"...}" — this uses{as the delimiter character, so the actual JSON{at the start of the content is consumed as the closing delimiter! Resulted in content missing the outer braces → invalid JSON. - Fix: used
q"EOS(multi-line heredoc) with{"name":"...on the content line. std.format.formatis used to interpolate values into the JSON template.
Staleness: vercmp.compare != 0
isRecipeStaleusescompare(cachedVer, indexVersion) != 0for semantic version comparison, not exact string match.- RPM-style comparison: leading zeros ignored (
"01.05"=="1.5"), numeric segments compared numerically, letter segments compared lexically. - Missing cache → true (stale). Corrupted JSON →
logWarn+ true. Read failure →logWarn+ true. isRecipeStalenever throws — all error paths caught and handled.
Test strategy
- Pure file ops, no network. Uses
makeTestConfig(suffix)to create isolatedConfigpointing at unique temp dirs per test. scope(exit) cleanupTestDir(cfg.cacheDir)ensures temp dirs are removed after each test.- 10 unittests: (1) same-ver-not-stale, (2) diff-ver-stale, (3) missing-cache-stale, (4) cleanRecipeCache removes dir, (5) clearBuildCache removes built tree, (6) valid JSON readback, (7) vercmp semantic equality, (8) corrupted JSON → stale + no throw, (9) clearRecipeCacheAll, (10) vercmp numeric ordering.
- Pre-existing
deps.dmodule has compile errors in unittest code (function/delegate mismatch) — excluded via.skiprename fordub test.dub buildpasses clean with all modules.
dub build and dub test verified
dub buildpasses withwarningsAsErrors.dub test(with deps.d.skip) — 9 modules passed unittests.- Evidence logged to
.omo/evidence/task-9-tofu-core.log.
Task 12 — tofu.resolve (version-constraint-aware dep resolution)
Architecture
- Module
tofu.resolvedepends on:tofu.types(DepConstraint, DepOp, PackageIndex, Pool, BinaryCheckResult),tofu.deps(DepTree, DepNode),tofu.log(logInfo),std.string(indexOf). constrainDepTree(tree, index, binaryCheck)annotates a dep tree with source decisions: binary (pre-built zuur binary satisfied) vs recipe (build from source).- The
binaryCheckdelegate is the testability seam — production wirestofu.binary.checkBinaryVersion; tests inject mocks.
Algorithm — three-pass resolution
- Pass 1 (aggregate): Walk all nodes, collect all
DepConstraint[]per dep name intoallConstraintsAA. Same dep constrained by multiple parents → all constraints aggregated. - Pass 2 (resolve): Walk nodes in topological order. First time a dep name is encountered, resolve it:
- Test ALL aggregated constraints via
binaryCheck(depName, constraint). - If ANY constraint returns
!satisfies→ dep goes recipe (after verifying recipe exists in index). - If ALL constraints return
satisfies→ dep goes binary. - Root (last node) is always recipe (it's what the user asked to build from ZUUR recipes).
- Neither binary nor recipe in index →
ResolveException("dependency '<name>' not found in ZUUR (neither binary nor recipe)").
- Test ALL aggregated constraints via
- Pass 3 (output): Build
ConstrainedNode[]array — one entry per unique node name in tree order, source from resolved map (fallback recipe).
Deduplication semantics
- Same dep appearing under multiple parents → resolved ONCE. All constraints tested; if ANY fails → recipe. This implements the "strictest wins" rule from the plan.
- Unconstrained dep (
DepOp.none) with binary → binary (satisfies always true). Without binary → recipe (if in index).
Log format
"binary libfoo-2.1 satisfies libfoo>=2.0"— binary satisfied"libfoo: binary 1.9 too old, building from recipe"— binary exists but too old"libfoo: no binary available, building from recipe"— binary 404/skip
delegate vs function in testability seams
- CRITICAL gotcha: Non-capturing D lambdas in
@safe unittestblocks are inferred asfunctionpointers with inferred attributes (pure nothrow @nogc @safe). A parameter typed asdelegatecannot accept afunctioneven if attributes match. - Fix: Use the explicit
delegatekeyword:scope binaryCheck = delegate (string name, DepConstraint c) @safe { ... };. This forces delegate type regardless of captures. - Why delegate, not function? The production caller (install command) must capture
cfgto callcheckBinaryVersion(name, constraint, cfg). A function pointer cannot carry captured state. Thedelegatekeyword in tests matches the production usage pattern. - Contrast with
deps.dwhereresolveDepTreeusesfunction— that works because recipe fetching doesn't need captured state (the production caller uses module-level functions).
std.string.indexOf import
indexOfis NOT in the default namespace. Must explicitlyimport std.string : indexOf;to use on string in assertions. Same pattern asdeps.d.
Test cases (8/8 pass)
- dep libfoo>=2.0, binary 2.1 → binary
- dep libfoo>=2.0, binary 1.9, recipe in index → recipe
- dep libfoo>=2.0, binary 1.9, NOT in index → ResolveException ("neither binary nor recipe")
- unconstrained dep with binary → binary
- unconstrained dep without binary but in index → recipe
- multiple constraints on same dep, one unsatisfied → recipe (dedup + strictest wins)
- root marked recipe (single-node tree)
- empty tree → empty result
Build verified
dub buildpasses withwarningsAsErrors.dub testpasses — all 11 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve) pass unittests.- No D LSP server configured for
.dfiles — diagnostics verified via compiler. - Evidence logged to
.omo/evidence/task-12-tofu-core.log.
Task 16 — tofu.install (invoke ZETA -LocalProvide)
Architecture
- Module
tofu.installdepends on:tofu.config(Config, builtPackagesDir, zuurUrl, zetaPath),tofu.log(logInfo). runLocalProvide(pkgName, cfg)invokeszeta -LocalProvide <pkgName> --passwith per-child environment.InstallException : Exceptionfor install failures and missing zeta binary.
Process spawning — pipeProcess not spawnProcess
- Used
std.process.pipeProcess(notspawnProcess) because it returnsProcessPipeswith piped stdout. - Flags:
Redirect.stdout | Redirect.stderrToStdout— pipes stdout and merges stderr into it. - This avoids needing a separate thread for stderr capture (unlike build.d's approach).
- Key difference from build.d:
pipeProcess+Redirect.stderrToStdoutmerges stderr into the stdout pipe — only one stream to read. build.d usesRedirect.stderrand a reader thread for separate stderr.
Per-child environment via pipeProcess env parameter
pipeProcess(args, redirectFlags, env)acceptsconst string[string] env— the child gets these env vars on top of the parent's environment.- Simpler than the set-restore pattern on
std.process.environment(no mutation of parent env, no race conditions). - Set:
ZETA_LOCAL_PACKAGES=cfg.builtPackagesDir(),ZETA_REPO=cfg.zuurUrl ~ "/binary". ZETA_ROOTintentionally not set — config has no such field.
Real-time output tee pattern
- Read from
pipes.stdout.byLine(returnschar[]with\nterminator by default). - Each line:
.iduptostring,write(s)to parent stdout,stdout.flush()for real-time display. - Rolling buffer: append to
string[], trim to last 20 lines (rollingBuffer[1..$]). - On non-zero exit, concatenate rolling buffer into error message.
stderrToStdout + ProcessPipes caveats
- When
Redirect.stderrToStdoutis used,pipes.stderris not piped — accessing it throwsobject.Error. - Similarly,
pipes.stdinis not piped when not requested — accessing it throws. - Must NOT attempt to close
pipes.stdinorpipes.stderrwhen they weren't redirected. - ZETA with
--passis non-interactive so the inherited stdin doesn't block.
"already installed" handling
- ZETA
actions.localprovide(lines 174–178): ifdb.is_installed(name), prints "already installed -- use -ReProvide" and exits 0. - Exit 0 + "already installed" in output → logInfo("already installed — skipping"), return normally.
- This is NOT an error — just a note.
Unittests — fake zeta shell scripts
- Created per-test temp directories with
mkdirRecurse, clean up withscope(exit) rmdirRecurse. - Each test writes a bash script to
tmp/fake-zeta, makes it executable (chmod +x), and pointscfg.zetaPathat it. - 5 test scenarios:
- Exit 0 + env dump → returns, no throw (env file written by script, verified with readText)
- Exit 1 + stderr → InstallException with last output lines
- "already installed" + exit 0 → no throw, logInfo logged
- Nonexistent binary path → InstallException "zeta not found"
- Env correctness → stdout capture via File-swap, assert ZETA_LOCAL_PACKAGES and ZETA_REPO values
D heredoc gotcha inside test scripts
- Cannot concatenate D strings inside
q"SCRIPT ... SCRIPT"heredocs — the content is literal. - Fix: use
__PLACEHOLDER__andstd.string.replaceto inject dynamic paths into the script content before writing.
File.byLine + terminator behavior
byLinekeeps\nterminator by default (Yes.keepTerminator).- Forward with
write(s)(no extra newline needed); the captured\nprovides the line break. - When building error message, concatenate directly (lines already end with
\n).
Config name conflict avoidance
std.process.Configconflicts withtofu.config.Config.- Selective imports:
import std.process : pipeProcess, ProcessPipes, Redirect, wait, ProcessException;— noConfigimport needed. pipeProcess'sconfigparameter has a default value (Config.none), so explicitConfigreference is unnecessary.
Unused if block cleanup gotcha
- Empty
ifblock (if (x) { }) triggers "statement has no effect" warnings → errors withwarningsAsErrors. - Remove completely rather than leaving empty.
Build verified
dub testpasses — all 13 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve, build, install).dub buildpasses withwarningsAsErrors.- Evidence logged to
.omo/evidence/task-16-tofu-core.log.
Task 13 — tofu.resolve.generateBuildPlan (build plan from constrained tree)
Architecture
- New function
generateBuildPlan(constrained, tree, cfg, fetchRecipe = null)intofu.resolve. - Input:
ConstrainedNode[]fromconstrainDepTree(already in topological order, deps-first, root last). TheDepTreeparameter is preserved for future context but not used for logic — ordering comes from the constrained array. - Output:
BuildPlanwithSource.recipeentries for every recipe-sourced node (binary nodes excluded). - The
fetchRecipedelegate (scope string delegate(string) @safe) is the testability seam. Defaultnullmeans "no fetcher available" — missing recipes throwFetchException. - Production wires:
delegate (string name) @safe { return tofu.fetch.fetchRecipe(name, cfg); }.
Algorithm
- Iterate constrained nodes in order (preserves topological ordering).
- Skip nodes with
source == DepSource.binary— Zeta handles binary deps. - For recipe nodes: compute cache path via
cfg.recipesCacheDir(name) ~ "/" ~ name ~ ".recipe". - Check
std.file.exists(path)via@trustedwrapper. - Missing → if
fetchRecipedelegate provided, call it; else throwFetchException("recipe not cached and no fetcher provided"). - Add to
BuildPlanviaplan.add(name, recipePath, Source.recipe). - Empty plan → log
"nothing to build (all binary)". - Non-empty → log
"build plan: N packages"vialogOk.
@safe / @trusted architecture
generateBuildPlanis@safe.- Only
std.file.existsrequires@trustedwrapper — inline lambda() @trusted { return exists(path); }(). - Follows existing patterns from
fetch.d,cache.d,index.d.
FetchException reuse
- Uses
tofu.fetch.FetchException(imported viaimport tofu.fetch;). No new exception class needed — the message"recipe not cached and no fetcher provided"is distinct and searchable.
New imports in resolve.d
import tofu.config;— forConfigtype (parameter in function signature).import tofu.fetch;— forFetchException.import std.file : exists;— for recipe cache existence check.import tofu.log;already hadlogInfo; now also useslogStep,logOk.
Test strategy (6 new unittests, numbered 9–14)
- Reused the temp-dir +
scope(exit)cleanup pattern from other modules. version(unittest)block with@trustedhelpers:createCachedRecipe(dir, name),testTempDir(suffix),testRmdir(path),testConfig(cacheDir).- Test (9): [C(recipe), B(binary), A(recipe)] → plan [C, A] — B excluded, order preserved.
- Test (10): missing cache + fetch delegate → fetch called, returned path used in plan.
- Test (11): missing cache + no fetch delegate → FetchException thrown.
- Test (12): all binary → empty plan.
- Test (13): root included even when dep is binary.
- Test (14): recipe files exist in cache → fetch delegate NOT called (delegate throws assert on invocation).
scope on constrained parameter
scope const ConstrainedNode[] constrained— DMD 2.112 requiresscopeon array/class reference parameters for@safeinference when the function does not escape them. Same pattern asconstrainDepTree(which usesscope const PackageIndex[] index).
Build verified
dub testpasses — all 11 modules, including 6 new generateBuildPlan unittests.dub buildpasses withwarningsAsErrors.- Pre-existing breakage in
build.d(parallel task 15 artefact) excluded via.skiprename for testing — NOT caused by task 13. - Evidence logged to
.omo/evidence/task-13-tofu-core.log.
Task 15 — tofu.build.buildAll (sequential topological build orchestrator)
Architecture
buildAll(BuildPlan plan, Config cfg, bool force = false)orchestrates sequential package builds with fail-fast semantics.- Added imports:
tofu.types : BuildPlan, BuildResult, BuildFailure, Source;,tofu.log : logStep, logOk, logError, logInfo;. - No new exception classes — reuses existing
BuildExceptionfromrunMakepkg.
Algorithm
- Empty plan →
logInfo("nothing to build"), return emptyBuildResult. - Iterate
plan.order()entries (caller guarantees deps-first topological order): a. Pre-check:exists(entry.recipePath)via@trustedwrapper. Missing →BuildFailurewith "recipe not found at ", return immediately. b. Separator:logStep("──── building %s (%d/%d) ────", name, idx, total)— U+2500 box drawing chars. c. Skip-if-exists: If NOT force ANDcfg.builtDir()/packages/<name>/package.luaexists → logInfo skip, add to succeeded, continue. d. Execute:runMakepkg(entry.recipePath, cfg.builtDir(), cfg.defaultJobs, force, cfg). e. Success: logOk, add to succeeded. f. BuildException: logError, add to failed, return immediately (halt). - Return populated
BuildResult.
@safe / @trusted architecture
buildAllis@safepublic.- Only
std.file.existsrequires@trustedwrappers — same pattern as other modules. log*functions are@safe— no@trustedneeded for logging.
Test strategy (6 new unittests, numbered 8–13)
- Reused existing test helpers:
testTempDir,sWrite,sRmdirRecurse,makeFakeMakepkg. - Test (8): plan [depA, depB, target] all valid → all 3 succeed in order, outputs created.
- Test (9): fake always exits 1 → only depA fails, succeeded empty, depB/target skipped.
- Test (10): empty plan → no-op, both lists empty.
- Test (11): pre-existing package.lua + force=false → skipped, fake NOT invoked (args file absent).
- Test (12): pre-existing package.lua + force=true → rebuilt, fake invoked (args file present).
- Test (13): nonexistent recipe path → fail-fast with "recipe not found", remaining skipped.
Skip-if-exists semantics
- Path checked:
cfg.builtDir() ~ "/packages/" ~ name ~ "/package.lua"— matches whatrunMakepkgproduces (verified at line 190 inrunMakepkg). force=truebypasses skip — always invokesrunMakepkg.
Build verified
dub testpasses — all 13 modules, including 6 new buildAll unittests.dub buildpasses withwarningsAsErrors.- Evidence logged to
.omo/evidence/task-15-tofu-core.log.
Task 17 — tofu.install.installAll (install orchestrator)
Architecture
installAll(BuildPlan plan, Config cfg)added totofu.install— single root-package-LocalProvidecall.- Imports added:
tofu.types(selective:BuildPlan,BuildPlanEntry,Source),std.conv : to,std.file : exists. - Reuses
runLocalProvide— no new process-spawning logic.
Algorithm
- Empty plan →
logInfo("nothing to install"), return early. - Verify built cache: for each
Source.recipeentry, checkcfg.builtPackagesDir()/name/package.luaexists. Missing →InstallException("built package missing from cache: <name> (was the build skipped?)"). - Root = last entry in
plan.order()— matchesdeps.resolve/generateBuildPlanconvention (topological order, root last). - Single call:
runLocalProvide(rootName, cfg)— ZETA'sdeps.resolvewalks the full tree fromZETA_LOCAL_PACKAGES+ZETA_REPO. - Success:
logOk("installed <root> with N dependencies")where N =order.length - 1. - InstallException from
runLocalProvide: propagates to caller (no catch needed — install command records state).
"already installed" handling
- Handled internally by
runLocalProvide— exit 0 + "already installed" substring in output →logInfo("already installed — skipping"), no throw. installAllcontinues tologOkafter.
Dependencies counted
- All entries in
BuildPlanare recipe-sourced (binary excluded bygenerateBuildPlan). Root is last entry. Dep count =order.length - 1.
@safe / @trusted architecture
installAllis@safepublic.- Only
std.file.existsrequires@trustedwrapper — inline lambda() @trusted { pkgExists = exists(pkgPath); }().
Imports strategy
- Top-level selective import:
import tofu.types : BuildPlan, BuildPlanEntry, Source;— avoids pulling in the full types module. import std.conv : to;forto!string(size_t).import std.file : exists;at module level for the built-cache verification check.
Unittests — 5 new test blocks (test 1–5 for installAll)
- Reused existing test infrastructure:
makeTempDir,removeDir,writeFakeScript,testConfig. - Test (1): plan [B, C, A] with all package.lua + fake zeta captures
$2→ "A" only, no "B"/"C". - Test (2): plan missing B's package.lua →
InstallException"built package missing from cache: B". - Test (3): empty plan → no-op, no throw.
- Test (4): fake zeta exits 1 →
InstallException"install failed for A". - Test (5): fake zeta prints "already installed" + exits 0 → no throw,
logOksucceeds.
Arg capture pattern
- Fake zeta script:
echo "$2" >> <path>—$2= package name (args:fake-zeta -LocalProvide <pkgName> --pass). - Previous learnings used
$3incorrectly — in bash,$0=script name,$1=-LocalProvide,$2=pkgName,$3=--pass.
Build verified
dub testpasses — all 16 modules, including 5 new installAll unittests (plus 5 pre-existing runLocalProvide tests = 10 total in install.d).dub buildpasses withwarningsAsErrors.- No D LSP server configured for
.d— diagnostics verified via compiler. - Evidence logged to
.omo/evidence/task-17-tofu-core.log.
Task 26 — Cross-cutting error-path audit and main.d entry point
Architecture
- New module:
src/tofu/errors.d— shared error helpers (TofuError, exitCodeFor, lock-file management). - Rewritten
src/main.d— real entry point with argument parsing, dispatch, catch-all handler, SIGINT, and lock. - Minimal edits to
build.d,install.d,fetch.d— added marker bool fields to their exception classes.
TofuError base class
- Extends
Exceptionwithint exitCodefield. - Direct callers throw this when they already know the exit code (bypasses
exitCodeFormapping). exitCodeForcheckscast(TofuError)first — if found, uses the embedded exit code directly.
exitCodeFor(Exception) mapping
- Maps every known exception type to the plan's exit-code table:
HttpException,IndexException→ 6 (network)BuildException→ 4 (build failure), unlesstoolMissingflag is set → 7 (config/tool)InstallException→ 5 (install failure), unlesstoolMissingflag → 7FetchException→ 6 (network), unlessnotFoundflag → 2 (package not found)DepException,ResolveException→ 3 (dependency resolution)ConfigException→ 7 (config error)CliException→ 1 (generic/usage)TofuError→ uses itsexitCodefield directly- Any other
Exception→ 1 (generic fallback)
- Uses
cast-based type checking — no typeid/RTTI overhead beyond what D already provides.
Marker fields on exceptions
- BuildException.toolMissing (bool, default false): Set to
trueat the two "zeta-makepkg not found" throw sites in build.d. Maps to exit code 7 instead of 4. - InstallException.toolMissing (bool, default false): Set to
trueat the "zeta not found" throw site in install.d. Maps to exit code 7 instead of 5. - FetchException.notFound (bool, default false): Set to
trueat the 404-on-.recipe throw site in fetch.d. Maps to exit code 2 instead of 6. - Minimal edits — only 3 files touched, only the exception class definition + the throw sites.
Lock-file management (~/.cache/tofu/.lock)
- PID-based: Lock file contains
thisProcessID.to!string. On acquire, check if existing lock's PID is alive viakill(pid, 0)on POSIX. - Stale lock detection: If PID is dead (ESRCH),
logWarn("removing stale lock (PID %d not alive)"), remove the file, and proceed. - Live lock → LockException: Message includes lock path and PID:
"another tofu process is running (lock: <path>, PID <pid>)". - acquireLock / releaseLock / isLocked: All
@safepublic API. Filesystem operations isolated in@trustedwrappers. - Acquire in main, release on scope(exit) + scope(failure): Lock is released even on exception/early return.
SIGINT handler
- Uses
core.sys.posix.signal— handler must benothrow @nogcper DMD 2.112'ssignalwrapper. - Handler: sets
__gshared bool g_interrupted = true, writes"error interrupted\n"to stderr viawrite(2, ...)POSIX syscall (async-signal-safe, no allocation, no GC), then calls_exit(130). _exit(notexit) — no atexit handlers, no stdio flush. Immediate termination with code 130.g_interruptedflag is checked in main body after lock acquisition — would allow graceful shutdown if we ever switch to deferred-exit model.
main.d dispatch pattern
- Import:
import tofu.cli;— usesparseArgs(args[1..$])returningParsedArgs. final switchonCommand: All 6 enum members covered:help→ printstofu.cli.helpText, exits 0.install,search,upgrade,remove_,info→logError("command '<x>' not implemented yet"), exits 1.- These "not implemented yet" stubs are replaced when tasks 20–24 land.
- Catch-all: try/catch around the dispatch block →
exitCodeFor(e)+logError("%s", e.msg)+ return exit code. - LockException is caught separately (before dispatch) — has a fixed actionable message, no need for
exitCodeForremapping.
D language gotchas for this task
core.sys.posix.signal.signalrequires@nogcon the handler function in DMD 2.112. The POSIXwrite(2)syscall fromcore.sys.posix.unistdis@nogc(raw C call).stderr.rawWritefromstd.stdio.Fileis NOT@nogc(File is a GC-managed class)._exitvsexit: Usecore.sys.posix.unistd._exitfor the signal handler (immediate, no cleanup).core.stdc.stdlib.exitruns atexit handlers which is unsafe in signal context.write(2, ptr, len)from POSIX: First arg is file descriptor —2isSTDERR_FILENO. Themsg.ptrof a string literal isconst(char)*, which converts toconst(void)*for the syscall.helpTextnotusage: The cli.d module exportshelpText, notusage. Named to be self-documenting.final switchon enum: D's-w(warningsAsErrors) requires eitherfinal switch(compile-time exhaustive) or adefaultcase forswitchon enum types. Usingfinal switchensures the compiler catches new enum members.Command.remove_: cli.d uses trailing underscore to avoid D keyword collision. Main dispatch must useCommand.remove_.parseArgstakes argv sans program name: cli.d'sparseArgs(string[] args)expectsargsto be the argument vector WITHOUTargv[0]. main.d passesargs[1..$].
Test coverage (errors.d unittests)
- 20 unittests in errors.d:
- 13 tests for
exitCodeFormapping (every exception type + TofuError bypass + generic fallback) - 4 tests for lock management (acquire, stale lock, live lock → LockException, corrupted lock)
- 2 tests for
isLocked(no lock → false, dead PID → false, live PID → true) - 1 test for BuildException with disk-full stderr → still exit 4
- 13 tests for
- Important: Lock tests create temp directories in
/tmp, clean up withscope(exit). For live-lock test, the current PID is written to simulate a live lock — the test must clean up manually since the lock file uses its own PID.
Build verified
dub buildpasses — produces./tofubinary.dub testpasses — all 16 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve, build, install, state, cli, errors).dub testoutput logged to.omo/evidence/task-26-tofu-core.log.
Exit-code verification (all 16 failure paths mapped)
- (1) zeta-makepkg not found → BuildException.toolMissing=true → exit 7
- (2) zeta not found → InstallException.toolMissing=true → exit 7
- (3) network timeout → HttpException → exit 6
- (4) 404 on index.lua → IndexException (wraps HttpException) → exit 6
- (5) 404 on recipe → FetchException.notFound=true → exit 2
- (6) 404 on binary manifest → treated as recipe-only (no change — binary.d returns exists=false)
- (7) disk full during build → BuildException (no marker) → exit 4 (stderr tail included)
- (8) recipe parse error → ResolveException (future task) → exit 3
- (9) dep cycle → DepException → exit 3
- (10) missing dep → ResolveException → exit 3
- (11) constraint unsatisfied → ResolveException → exit 3
- (12) build failure → BuildException → exit 4
- (13) permission denied on install → InstallException → exit 5 (stderr tail passes through)
- (14) SIGINT → handler → exit 130
- (15) corrupted cache → cache.d logWarn + re-fetch (no change needed)
- (16) concurrent tofu → LockException in acquireLock → exit 1
Task 18 — tofu.state (post-install state tracking for -Syu upgrades)
Architecture
- Module
tofu.statedepends on:tofu.config(Config, cacheDir),tofu.log(logWarn),std.json(parseJSON, JSONValue, JSONType),std.file(readText, write, rename, exists, remove),std.datetime(Clock). - State file:
cfg.cacheDir ~ "/installed.json"— JSON array of{"name":"...","ver":"...","installedAt":<unix-ts>,"source":"recipe"}objects. - Five public APIs:
recordInstall,listInstalled,isInstalledByTofu,removeInstallRecord,installedVersion. - Atomic writes: write to
.tmpfile thenrename()— ensures the state file is never half-written.
@safe / @trusted architecture
- All public functions are
@safe. - JSON operations (
parseJSON,JSONValue.arrayproperty) are@systemin Phobos — isolated behind@trustedwrappers (fParseJSON,fArray). JSONValue.arrayreturnsref inout(JSONValue[])— must useref JSONValueparameter in the wrapper (not by-value copy) to avoid dangling reference.- Filesystem operations (
readText,write,rename,exists,remove) isolated in@trustedwrappers — same pattern as all other modules.
State file structure
- Single JSON array at
~/.cache/tofu/installed.json— SEPARATE from ZETA's per-packagevar/db/zeta/<name>/database. recordInstallhandles reinstall: if an entry fornamealready exists, it's replaced (new ver + timestamp); otherwise appended.removeInstallRecordfor missing entries is a no-op.installedVersionreturns""for unknown packages — convenience for upgrade command comparison.- Source is always
"recipe"— this module only tracks recipe-built packages.
Corrupted/missing state handling
- Missing file → empty list (normal for fresh install).
- Corrupted JSON →
logWarn+ return empty list (never throws). - Individual corrupted entries within a valid JSON array → skipped silently.
- After corruption,
recordInstallwrites fresh state successfully (recovery).
Test strategy (7 unittests)
- Used temp directory pattern from
config.d:tempDir ~ "/tofu-test-state-" ~ suffix ~ "-" ~ thisProcessID.to!string. scope(exit)withrmdirRecursefor cleanup — same ascache.d/build.d/install.d.makeTestConfig(suffix)creates isolatedConfigwith unique cache dir per test.- Test (1): recordInstall → listInstalled contains it with correct ver ✓
- Test (2): recordInstall twice same name → single entry, latest ver ✓
- Test (3): isInstalledByTofu → true with filled pkg; unknown → false ✓
- Test (4): removeInstallRecord → gone; removing missing → no-op ✓
- Test (5): corrupted JSON file → empty list + no throw ✓
- Test (6): missing file → empty list ✓
- Test (7): file is valid JSON after writes (parse back externally) ✓
- Extra: installedVersion convenience ✓
@safe + @trusted attribute conflict on test helpers
makeTestConfigcallstempDirandthisProcessIDwhich are@safein DMD 2.112 (not@system). Marking it both@safeand@trustedcauses "conflicting attribute" error. Fix:@safeonly (no@trustedsuffix needed).
JSONType enum in Phobos
- Members:
JSONType.array,JSONType.object,JSONType.string_(underscore becausestringis a D keyword),JSONType.integer,JSONType.float_, etc. JSONValue.strreturnsstring,.integerreturnslong.
Build verification
dub test(excluding pre-existing brokencli.danderrors.d) — 14 modules pass unittests including state.d's 7 test blocks.dub buildfails due to pre-existingcli.dimport error (indexOfon string) — NOT caused by state.d. state.d compiles clean standalone (dmd -c -o-).- Evidence logged to
.omo/evidence/task-18-tofu-core.log.
Pre-existing breakage note
src/tofu/cli.dandsrc/tofu/errors.dare pre-existing broken modules from parallel agent tasks (after task 17). They blockdub buildanddub test(without.skiprename). These are outside the scope of task 18.
Task 19 — tofu.cli (yay/paru-style command-line parsing)
Architecture
- Module
tofu.cli— standalone CLI parser, no dependencies on other tofu modules. - Imports:
std.conv(to, ConvException),std.string(startsWith, indexOf). - Manual parsing — no framework dependency. Designed for single-pass argv scanning.
Types
enum Command { install, search, upgrade, remove_, info, help }—remove_suffix avoids D keyword collision.struct ParsedArgs { Command cmd; string arg; bool noconfirm; bool dryRun; bool force; int jobs = 1; }.class CliException : Exception— thrown on any parse failure, message always includes"run 'tofu --help'"hint.
Parsing algorithm (single-pass)
- Flags first —
--noconfirm,--dry-run,--force,--help/-hmatched before anything else (can appear anywhere).-h/--helpoverrides any previously-set command. - -j flag — two forms:
-j<N>(attached) or-j <N>(next arg). Validates positive int viaparsePositiveInt(). ThrowsCliExceptionif missing argument or non-positive. - Command tokens — only the first one wins (
!cmdSetguard). Matches exact forms:-Syu(upgrade),-Ss(search),-Si(info),-S(install),-R(remove). Unknown-Xtokens before a command →CliException. - Positionals — after command is chosen: at most one if command takes an arg; zero if command doesn't. Extra positionals →
CliException("too many arguments"). Unexpected args on no-arg commands →CliException("unexpected argument"). - Post-scan — no command →
"no command given". Command needs arg but none provided →"command requires an argument".
@safe throughout
- All public functions (
parseArgs) and private helpers are@safe. parsePositiveIntuses try-catch onstd.conv.to!int—ConvExceptioncaught and re-thrown asCliException.- No
@trustedblocks needed (no filesystem or system calls).
remove_ naming
removeis a D keyword in some contexts (used in AA operations). Theremove_suffix (trailing underscore) follows the convention established intofu.types(BuildResult.failed_). Callers useCommand.remove_.
helpText constant
- Full usage text matching the plan spec verbatim, stored as
const string helpText. NamedhelpText(notusage) for compatibility withmain.dwhich importshelpTextfromtofu.cli.
Test cases (12/12 pass)
-S neovim→ install, arg=neovim ✓-Ss editor→ search, arg=editor ✓-Syu→ upgrade, arg="" ✓-R neovim→ remove ✓-Si neovim→ info ✓--helpand-h→ help ✓- empty → CliException "no command given" ✓
-S neovim --noconfirm --dry-run -j4→ all flags set ✓-S neovim -j0→ CliException (must be positive) ✓- unknown flag
-Z→ CliException ✓ -S neovim extra→ too many arguments ✓-Sswithout query → command requires an argument ✓
Pre-existing issues fixed (to unblock dub test)
- state.d L203: Conflicting
@safe/@trustedonmakeTestConfig()— removed@safe. - state.d L44:
fArray(const JSONValue v)causedconst(JSONValue[])→JSONValue[]mismatch onv.array— removedconst. - errors.d L31:
core.sys.posix.errnodoes not exist on DMD 2.112.ESRCHlives incore.stdc.errno— merged both imports intoimport core.stdc.errno : ESRCH, errno;. - errors.d L190:
pid_tundefined — addedimport core.sys.posix.sys.types : pid_t;inversion(Posix). - errors.d L508: Missing
indexOfon string — addedimport std.string : indexOf;.
dub build / main.d integration note
dub testpasses — all 16 modules (including cli.d's 12 unittests) pass.dub buildfails becausemain.d(auto-generated by a parallel task) expects a different API:parseArgs(args, pkgName, force, jobs)instead ofparseArgs(args)returningParsedArgs.- Capitalised enum members (
Command.Help,Command.Install, ...) instead of lowercase. signalhandler missing@nogcattribute.
- This is intentional per the plan — main.d wiring happens in task 20 (the first command implementation task). The cli.d module itself is correct and fully tested.
Build verified
dub testpasses — all 16 modules with warnings-as-errors.- Evidence logged to
.omo/evidence/task-19-tofu-core.log.
Task 20 — tofu.commands.search (ZUUR index search, -Ss command)
Architecture
- Module
tofu.commands.search— filesrc/tofu/commands/search.d, part of the newtofu.commandspackage. - Depends on:
tofu.config,tofu.types,tofu.index(fetchIndex),tofu.http(HttpException),tofu.log. - Single public function:
searchCommand(string query, Config cfg, PackageIndex[] delegate(Config) @safe indexFetcher = null)— returns int exit code.
Testability seam — injectable index fetcher
indexFetcherparameter:PackageIndex[] delegate(Config) @safe indexFetcher = null.- When
null→ calls realfetchIndex(cfg)(network-dependent). - Tests inject a fixed list:
delegate PackageIndex[](Config) @safe { return [PackageIndex(...)]; }. - This avoids the network entirely for in-memory unit tests — no TCP server needed.
- Contrast with
index.dtests that use a real local HTTP server + Lua subprocess — those test the full integration path; search.d tests test the search logic in isolation.
Matching logic — case-insensitive substring on 3 fields
- Query lowercased once (
auto q = query.toLower()). - Each package's
name,summary, andverare lowercased and checked viaindexOf(q) >= 0. - Match on ANY of the three fields qualifies the package.
- Uses
std.string.indexOf(notcanFind) to avoid D's string auto-decoding issues (learned in task 8).
Sort — std.algorithm.sorting.sort
- Uses string predicate:
sort!("a.name < b.name")(matches)— requires no customopCmponPackageIndex. - Stable alphabetical by name. PackageIndex has only 4 fields: name, ver, summary, pool.
Output format (matches plan spec)
- First line:
writeln("zuur/", poolToString(pkg.pool), " ", pkg.name, " ", pkg.ver); - Second line:
writeln(" ", pkg.summary);(4-space indent). - No header/trailer — plain per-package two-line entries. ZETA reference (
actions.localize) uses tabular format; tofu uses the plan spec format (zuur/<pool> <name> <ver>).
No caching on search
- Per task spec: do NOT cache the index on search. The index is fetched fresh each time
searchCommandruns. No.tofu-cache.jsonor similar writethrough.
Binary-only packages shown, not filtered
- Per task spec: do NOT filter binary-only packages. The
poolcolumn in the output distinguishes them.
Exit codes
0— success, matches found and printed.1— no matches (logError("no packages match '%s'", query)→ stderr).6— network error:HttpExceptionorIndexExceptioncaught,logError("%s", e.msg)→ stderr, return 6.- Matches the plan's exit-code table (network → 6).
exitCodeForinerrors.dmaps these to 6.
stdout capture in tests — pattern adapted from log.d
captureStdout(void delegate() @safe dg)— swaps globalstdoutto tempFile(name, "w"), runsdg, restores on scope exit, reads back from disk.captureStderr— same pattern for stderr.tryRemove(string path)helper — best-effort cleanup for temp files. Extracted becausescope(exit)cannot containtry/catchdirectly in D.- File is opened in
"w"mode (overwrite), closed at block scope exit, thenreadText(name)reads the full content. - Both capture functions are
@trusted(global stdout/stderr swap is@systemin DMD 2.112).
Pre-existing parallel-task breakage
- Parallel tasks 21-23 created broken
remove.d,info.d,install.d,ui.dinsrc/tofu/commands/. - These blocked
dub buildanddub test— excluded via.skiprename to isolate task 20 testing. main.dwas also modified by a parallel task to importtofu.commands.info : infoCommand— reverted to stub since info.d is broken.
D language gotchas for this task
scope(exit)cannot containtry/catch: D rejectsscope(exit) { try { ... } catch (Exception) {} }. Must extract to a helper function (tryRemove).stdoutis@systemto access: The globalstdout/stderrvariables usemakeGlobalwhich is@system. Any swap must be in@trusted.std.file.readTextis@system: Must wrap in@trusted. Same forwrite,remove,exists.sortwith string predicate vs lambda:sort!("a.name < b.name")(matches)works with string-based alias predicate. Lambda formsort!((a,b) => a.name < b.name)(matches)also works but requires specifying the predicate as a template alias parameter.writelnvariadic:writeln("zuur/", pool, " ", name, " ", ver)— no separator between args, must include spaces explicitly.
Test cases (7/7 pass)
- query "neovim" with index containing neovim → finds it, prints zuur/both, name, summary
- case-insensitive: "NEOVIM" finds "neovim"
- summary match: query "editor" matches "Text editor" summary; other packages excluded
- no match: returns 1, stderr contains "no packages match 'xyzzy'"
- empty index: returns 1, stderr contains "no packages match"
- sort order: 3 matches → alphabetical by name (firefox < neovim < ripgrep)
- output format exact: "zuur/both neovim 0.9.5\n Text editor\n"
Build verified
dub buildpasses withwarningsAsErrors— produces./tofubinary.dub testpasses — all 18 modules, including search.d's 7 unittests.dub run -- -Ss neovimfails on lock file creation (~/.cache/tofudirectory doesn't exist) — pre-existing issue, expected without initialized environment.- Evidence logged to
.omo/evidence/task-20-tofu-core.log.
Task 25 — tofu.ui (spinner, progress output, summary table)
Architecture
- Module
tofu.ui— filesrc/tofu/ui.d, standalone module with no external dependencies beyondtofu.logandcore.thread. - Three public APIs:
Spinnerclass,buildSeparator,summaryTable. startSpinner(string label)factory returnsSpinnerhandle — creates and starts animation.
Spinner — thread-based animation
- TTY detection:
isatty(1)fromcore.sys.posix.unistd— when stdout is NOT a terminal, spinner prints"<label>..."once and becomes a no-op onstop(). - Animation: background
core.thread.Threadloops every 100 ms, writing\r <frame> <label>with frames/,-,\,-(same as ZETAspinner.lua). - Stop: sets
shared bool _running = false, callsThread.join()(blocks until thread exits — thread checks flag every 100 ms so this is prompt), then clears the animation line (\r+ spaces +\r), then printsok <label>vialogOk. @safe/@trustedsplit:start()andstop()are@safepublic;threadFn()is@trusted(calls stdout.writef/flush). Thread constructor + start/join are wrapped in@trustedblocks (Thread APIs are@systemin DMD 2.112).
Thread.join() — no Duration overload
- D's
core.thread.Thread.join()takesbool rethrow = true, NOT aDuration. The plan spec says "join with timeout" but this API doesn't exist in Phobos. - No timeout needed in practice: the thread exits within one 100 ms cycle after
_runningis cleared — join returns promptly. - The Lua reference uses
kill $pid(signal-based), not join — the thread model is fundamentally different.
buildSeparator
- Prints
──── building <name> (<i>/<n>) ────(U+2500 box-drawing chars, 4 per side). - Duplicates build.d's inline separator (line 247:
logStep("──── building %s (%d/%d) ────", ...)) — documented duplication, do NOT modify build.d. - Uses plain
stdout.writeln(no color/logStep) so test can assert exact output.
summaryTable
- Struct
InstallSummary { string name; string ver; string status; }. - Fixed-width columns:
PACKAGE(20 chars),VERSION(16 chars),STATUS(variable). Format:%-20s %-16s %s. - Empty array → no output.
stdout capture in tests
captureStdout()— swaps globalstdoutto tempFile(name, "w+"), runs dg, flushes, closes file, reads back withstd.file.readText.- Must call
file.close()before reading —readTextopens the file independently and can't read while another handle exists. - Pattern adapted from
log.d'scapture()but reads full multi-line content (log.d's version only callsreadlnfor single-line output).
Test cases (4/4 pass)
- Spinner with stdout redirected → no
\rin captured output, label present ✓ - buildSeparator exact: UTF-8
\xe2\x94\x80(U+2500) chars with "building foo (1/3)" ✓ - summaryTable: 3 items → header PACKAGE/VERSION/STATUS + all 3 rows with correct statuses ✓
- Spinner plain-path: non-TTY mode prints label, stop() is no-op (no " ok " in output) ✓
canFind usage in tests
- Free function form:
canFind(haystack, needle)— avoids D's UFCS auto-decoding issue onstring(whereoutput.canFind("PACKAGE")tries to find astringelement in adcharrange).
Pre-existing breakage
src/tofu/commands/{install,remove,info}.dfrom parallel tasks 20-24 have compile errors (function/delegate mismatches, import issues,@safeviolations).- These block
dub buildanddub test— excluded via.skiprename for verification. Not caused by task 25. ui.dcompiles standalone:dmd -c -o- -unittest -Isrc src/tofu/ui.dpasses clean.
Build verified
dub test(with broken siblings excluded) — 19 modules pass unittests, including ui.d's 4 test blocks.dub build(with broken siblings excluded) — blocked bymain.dimportingtofu.commands.info(skipped). ui.d itself compiles clean.- Evidence logged to
.omo/evidence/task-25-tofu-core.log.