Files
tofu/.omo/notepads/tofu-core/learnings.md
T

13 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

  • version is a D reserved keyword (conditional compilation). Module is named tofu.vercmp, file src/tofu/vercmp.d. The DepConstraint struct in types.d uses field ver (not version) 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: uses final switch on DepOp enum. Maps to compare result as expected (ge→c>=0, le→c<=0, eq→c==0, ne→c!=0, gt→c>0, lt→c<0).
  • parseDep: delegates to DepConstraint.parse (already ported in types.d by task 4). Both implementations are kept consistent.

whitespace stripping

  • Could not use filter+array because Phobos auto-decodes string to dchar range elements, returning dchar[] not string. Implemented a manual removeWhitespace helper using raw code-unit indexing, with a @trusted cast since the freshly-allocated char[] has no aliasing.

Test porting

  • All 10 ZETA test categories ported as separate @safe unittest blocks.
  • 4 modules pass unittests (types, vercmp, plus pre-existing ones).
  • dub build passes with warnings-as-errors enabled.

Future considerations

  • When task 12 (constraint resolution) imports tofu.vercmp, the compare and satisfies functions are ready. The parseDep integration with DepConstraint.parse ensures 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; → provides TOMLDocument, TOMLValue, parseTOML, TOMLException, TOMLParserException
  • TOMLDocument has alias table this — acts like TOMLValue[string] AA
  • Access sections: doc["core"] returns TOMLValue with type == TOML_TYPE.TABLE
  • Access string values: doc["core"]["key"].str (throws if type mismatch)
  • Check existence: "key" in doc returns TOMLValue* (null if absent)
  • parseTOML() is @system — must wrap in @trusted block when calling from @safe code
  • Keys without .str / .integer access will throw TOMLException

D language gotchas

  • Nested function declarations: D does NOT allow void foo() { ... } inside a function body. Use lambdas: auto foo = () { ... }; foo(); or anonymous () @trusted { ... }();
  • version is a keyword: Cannot use string version; as field name — use string version_; or initialize with = ""
  • parseTOML is @system: The toml package's parse function isn't marked @safe — must wrap in @trusted lambda
  • std.file.readText is @system: Same — wrap in @trusted
  • std.process.environment.get(): Returns empty string when env var is unset; "VAR" in environment checks existence but environment.get("VAR", "default") is cleaner
  • stderr.writefln is @safe in recent DMD (2.106+) — usable directly in @safe code
  • AA in operator: Returns pointer (V*) or null — 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 → TOMLParserException caught, warning to stderr, fall back to defaults
  • Testability: load() accepts optional envOverrides map and explicit configFile path to avoid real env/filesystem

Config design decisions

  • TOFU_CONFIG env var overrides default config file path (~/.config/tofu/config.toml)
  • ~ in default paths expanded with std.path.expandTilde
  • All path fields default to empty string when "find on PATH" — no hardcoded binary paths
  • defaultJobs typed as int (not size_t/ulong) since it's a user-facing count

buildOptions warningsAsErrors deprecation

  • DUB warns about warningsAsErrors in buildOptions — recommends buildRequirements instead
  • Not blocking, informational only

Task 3 — tofu.log (colored, NO_COLOR-aware logging)

  • dmd 2.112 gotcha: the std.stdio globals stdout/stderr are @system to access — makeGlobal uses __gshared File result, which fails @safe inference. An @safe function cannot call stdout.writeln(...) directly. Fix: route every write through a tiny @trusted helper (writeStdout(string)/writeStderr(string)), keep the public log functions @safe. Compiler hint: "using __gshared instead of shared makes it fail to infer @safe".
  • std.process.environment (dmd 2.112): it is an abstract final class of static methods — environment.get(name) is @safe and returns null when the var is unset (so "NO_COLOR set" == get !is null, even for an empty string). environment["K"] = v and environment.remove(name) are @trusted. NOT deprecated in 2.112.
  • Testable color decision: make colorEnabled() re-read the environment on every call instead of caching in static this(). Enables NO_COLOR/TERM tests without restarting the process. Costs one getenv per log line — negligible.
  • Env save/restore in unittests: a private struct ColorEnv (in version(unittest)) that snapshots NO_COLOR+TERM in its ctor and restores them in ~this() (destructor) — auto env = ColorEnv("1", "xterm") scopes the restore. null value = remove the var. Double-destruction from a copied temporary is harmless (restore is idempotent).
  • Capturing stdout/stderr in unittests: swap the global stdout/stderr File to a File(name, "w+"), write, flush(), swap back, then rewind() + readln(). Restore the global before the temp File's destructor runs.
  • zeta log format details: warn paints the whole prefix yellow (paint("yellow", "warn ") ~ msg), but error paints 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.
  • version is a D keyword — cannot be used as a struct field / identifier (trap for the parallel types.d work).
  • std.stdio.writeln flushes per call — already satisfies "no buffering"; no explicit flush() needed in production paths.
  • Verifying a single module without the full build: dmd -main -unittest -i src/tofu/log.d runs just that module's unittests when sibling modules (config.d, types.d) are mid-edit by parallel agents. -i pulls imports in automatically.

Task 4 — tofu.types (core data structures)

  • version is a D keyword — all struct fields named version must be renamed to ver. The D compiler treats version as a conditional-compilation directive and rejects it as a field name (see task 3 learnings above).
  • Struct field defaults in D: string fields initialise to null by default. To enforce empty-string semantics (""), every string field must be explicitly = "".
  • final switch for exhaustive enum handling: using final switch on enum types forces the compiler to verify all cases are covered — catches missing branches at compile time. Used for poolToString().
  • Manual character scanning preferred over std.regex: the dep-spec parser uses simple char-by-char scanning with isNameChar() / isWhite() helpers instead of regex. This avoids the std.regex dependency, 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 table OPS = { ">=", "<=", "==", "~=", ">", "<", "=" } — we replicate this with if/else chains.
  • = normalises to ==: the Lua code has if op == "=" then op = "==" end; we map both to DepOp.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 test includes all .d files 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 removing private from a nested function (access specifiers not allowed on local functions in D).
  • BuildResult.failed as BuildFailure[]: the plan describes it as struct {string name; string reason}[] — in D this is a named struct BuildFailure with array field BuildFailure[] failed. The failedNames() helper extracts just the names for caller convenience.
  • DepConstraint.parse is a static factory method: returns a new DepConstraint by value, never allocates on the heap. @safe throughout.
  • All structs are @safe: no methods do I/O, no @system calls. 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) via opCall, or HTTP() + .url = url setter. The .url setter auto-prepends http:// if no scheme is present.
  • Timeouts (all are @property setters taking core.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; sets low_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, takes uint). Defaults to 10. Set to 0 to disable, uint.max for infinite. Internally sets CurlOption.followlocation + CurlOption.maxredirs.
  • User-Agent: setUserAgent(string) — instance method (not property).
  • onReceive: void delegate(ubyte[]) returning size_t. Must accept all bytes or the request aborts. Use return data.length;.
  • onReceiveStatusLine: Not a direct property on HTTP struct — must be set via the onReceiveHeader property setter which wraps the delegate and internally fires onReceiveStatusLine when it detects an HTTP status line. After perform() the final status is available via http.statusLine.
  • statusLine: HTTP.StatusLine struct with .code (ushort), .majorVersion, .minorVersion, .reason. Reset to zero before each perform().
  • 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-level perform() does NOT throw on HTTP status codes — you must check statusLine.code yourself.
  • Exception isolation pattern: catch CurlTimeoutException and CurlException in @trusted helpers, re-throw as HttpException (which extends Exception, not CurlException). 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, and downloadFileImpl helpers are @trusted — they are the ONLY places std.net.curl (which is @system-heavy) is used.

Download pattern (.part temp file + rename)

  • Ported from ZETA lib/fetch.lua: write to dest.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; explicit f.close() needed before rename.

Unittest local server

  • Used std.concurrency.spawn + std.socket.TcpSocket for a self-contained one-shot HTTP server (no external dependency on Python3).
  • Pattern: bind ephemeral port (InternetAddress.PORT_ANY), spawn thread that accept()s one connection, sends canned HTTP response, exits. cast(shared) the listener to pass to spawn. Mark test helpers @trusted.
  • HTTP response must include Content-Length header 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 as HttpException.

Build verified

  • dub build passes with warnings-as-errors.
  • dub test passes — all 5 modules (config, log, types, vercmp, http).