- 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
125 lines
10 KiB
Markdown
125 lines
10 KiB
Markdown
# 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.
|