Files
tofu/.omo/notepads/tofu-core/decisions.md
T
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

10 KiB
Raw Blame History

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.