diff --git a/.omo/evidence/task-22-tofu-core.log b/.omo/evidence/task-22-tofu-core.log new file mode 100644 index 0000000..9000673 --- /dev/null +++ b/.omo/evidence/task-22-tofu-core.log @@ -0,0 +1,297 @@ + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Generating test runner configuration 'tofu-test-application' for 'application' (executable). + Warning Excluding main source file src/main.d from test. + Starting Performing "unittest" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Building tofu ~main: building configuration [tofu-test-application] + Linking tofu-test-application + Finished To force a rebuild of up-to-date targets, run again with --force + Running tofu-test-application +zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA: +zeta-makepkg: building depA... +zeta-makepkg: configure phase +zeta-makepkg: build phase +zeta-makepkg: install phase +zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-249455/nonexistent.recipe +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +error package 'nonexistent' not found in ZUUR +error package 'ghost' not found in ZUUR +error dependency cycle: A -> B -> A +error build failed for bfp: build failed for bfp: +(no stderr output) +error build failed: bfp — build failed for bfp: +(no stderr output) +error install phase error: install failed for ifp: + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> ──── building depA (1/3) ──── + ok depA +==> ──── building depB (2/3) ──── + ok depB +==> ──── building target (3/3) ──── + ok target +==> ──── building depA (1/3) ──── + - nothing to build +==> ──── building pkg (1/1) ──── + - pkg already built, skipping +==> ──── building pkg (1/1) ──── + ok pkg +neovim + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch +neovim + version: 0.9.5 + summary: Text editor + pool: both + build system: cmake + deps: libluv>=1.30, msgpack-c + url: https://github.com/neovim/neovim +neovim + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch + installed: 0.9.5 + status: up to date +ripgrep + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 13.0.0 + status: outdated (zuur has 14.1.0) +firefox + version: 120.0 + summary: Web browser + pool: binary +firefox + version: 120.0 + summary: Web browser + pool: binary +ripgrep + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 15.0.0 + status: newer than zuur +==> generating build plan + - + happypkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): happypkg +==> ──── building happypkg (1/1) ──── + ok happypkg + ok installed happypkg with 0 dependencies + ok installed 1 package(s) +==> generating build plan + - + bfp (recipe) + ok build plan: 1 packages + - will build 1 package(s): bfp +==> ──── building bfp (1/1) ──── +==> generating build plan + - + ifp (recipe) + ok build plan: 1 packages + - will build 1 package(s): ifp +==> ──── building ifp (1/1) ──── + ok ifp +==> generating build plan + - + drp (recipe) + ok build plan: 1 packages + - will build 1 package(s): drp + - dry run — nothing built +==> generating build plan + - + ncp (recipe) + ok build plan: 1 packages + - will build 1 package(s): ncp +==> ──── building ncp (1/1) ──── + ok ncp + ok installed ncp with 0 dependencies + ok installed 1 package(s) +==> generating build plan + - + abp (recipe) + ok build plan: 1 packages + - will build 1 package(s): abp +Proceed? [y/N] warn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway + - aborted by user + ok removed testpkg +removed ok ok removed foreignpkg +error cannot remove libfoo: still required by libbar (use --force to override)error cannot remove libfoo (use --force to override) + ok removed testpkg +removed without prompt ok removed testpkg +Remove testpkg? [y/N] warn package 'ghost' no longer in ZUUR — skipping + - aborted by user + - nothing to do + . package 'foo' is up to date (2.0) + - nothing to do + - will upgrade 1 package(s): oldpkg: 1.0 → 2.0 + - upgrading oldpkg (1.0 → 2.0)... +==> generating build plan + - + oldpkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): oldpkg +==> ──── building oldpkg (1/1) ──── + ok oldpkg + ok installed oldpkg with 0 dependencies + ok installed 1 package(s) + ok 1 package(s) upgraded + - will upgrade 1 package(s): realpkg: 1.0 → 2.0 + - upgrading realpkg (1.0 → 2.0)... +==> generating build plan + - + realpkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): realpkg +==> ──── building realpkg (1/1) ──── + ok realpkg + ok installed realpkg with 0 dependencies + ok installed 1 package(s) + ok 1 package(s) upgraded, 1 no longer in ZUUR + - will upgrade 2 package(s): goodpkg: 1.0 → 2.0, badpkg: 1.0 → 2.0 + - upgrading goodpkg (1.0 → 2.0)... +==> generating build plan + - + goodpkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): goodpkg +==> ──── building goodpkg (1/1) ──── + ok goodpkg + ok installed goodpkg with 0 dependencies + ok installed 1 package(s) + - upgrading badpkg (1.0 → 2.0)... +==> generating build plan + - + badpkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): badpkg +==> ──── building badpkg (1/1) ──── + ok badpkg +install failederror install phase error: install failed for badpkg: install failed +error upgrade failed for badpkg +warn 1 upgrade(s) failed + ok 1 package(s) upgraded + - will upgrade 1 package(s): abortpkg: 1.0 → 2.0 +Proceed? [y/N] Warning: malformed TOML config at /tmp/tofu-test-config-bad-249455.toml: Invalid table key declaration (2:0) +Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1 +warn removing stale lock (PID 99999999 not alive) +127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:15] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:42:15] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:42:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:42:16] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' + - aborted by user +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:43045//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:54983//index.lua + . fetching index http://127.0.0.1:49387//index.lua + . fetching index http://127.0.0.1:41617//index.lua + . fetching index http://127.0.0.1:35363//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:60717//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:40975//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:41091//index.lua + . fetching index http://127.0.0.1:33219//index.lua + ok index loaded: 1 packages +installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping + ok installed A with 2 dependencies + - nothing to install +install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +23 modules passed unittests + - already installed — skipping + ok installed A with 0 dependencies + - binary libfoo-2.1 satisfies libfoo>=2.0 + - libfoo: binary 1.9 too old, building from recipe + - libfoo: binary 1.9 too old, building from recipe + - binary libbar-3.0 satisfies libbar (unconstrained) + - libbaz: no binary available, building from recipe + - binary libfoo-1.5 satisfies libfoo>=1.0 + - libfoo: binary 1.5 too old, building from recipe + - binary parentA-1.5 satisfies parentA (unconstrained) + - binary parentB-1.5 satisfies parentB (unconstrained) +==> generating build plan + - + C (recipe) + - + A (recipe) + ok build plan: 2 packages +==> generating build plan + - recipe libfoo not in cache, re-fetching + - + libfoo (recipe) + ok build plan: 1 packages +==> generating build plan +==> generating build plan + - nothing to build (all binary) +==> generating build plan + - + mypkg (recipe) + ok build plan: 1 packages +==> generating build plan + - + libfoo (recipe) + - + mypkg (recipe) + ok build plan: 2 packages + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Starting Performing "debug" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Up-to-date tofu ~main: target for configuration [application] is up to date. + Finished To force a rebuild of up-to-date targets, run again with --force diff --git a/.omo/notepads/tofu-core/decisions.md b/.omo/notepads/tofu-core/decisions.md index cb9028c..b5f549a 100644 --- a/.omo/notepads/tofu-core/decisions.md +++ b/.omo/notepads/tofu-core/decisions.md @@ -86,3 +86,39 @@ Rather than adding build/install delegate seams to `installCommand`, the tests r **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 `. + +### 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. diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index 3fa7277..9425d0c 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -1276,3 +1276,71 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o - `dub test` passes — 21 modules, all 8 install.d unittests pass. - `dub build` passes with `warningsAsErrors`. - Evidence logged to `.omo/evidence/task-21-tofu-core.log`. + +--- + +## Task 22 — `tofu.commands.upgrade` (`-Syu` full-system upgrade) + +### Architecture +- Module `tofu.commands.upgrade` — file `src/tofu/commands/upgrade.d`. +- Single public function: `upgradeCommand(ParsedArgs flags, Config cfg, PackageIndex[] delegate(Config) @safe indexFetcher = null)` — returns int exit code 0 (success/no-op) or 1 (upgrade failure). +- Depends on: `tofu.config`, `tofu.cli`, `tofu.types`, `tofu.index`, `tofu.state`, `tofu.vercmp`, `tofu.log`, `tofu.commands.install`, `tofu.errors`. + +### Algorithm — 7-step pipeline +1. **Fetch index**: via `indexFetcher(cfg)` seam or real `fetchIndex(cfg)`. +2. **List installed**: `listInstalled(cfg)` from `tofu.state`. Empty → `logInfo("nothing to do")` + return 0. +3. **Compare versions**: For each installed pkg: find in index by name. NOT found → `logWarn` + skip. Found with `pool == Pool.binary` → `logDetail` + skip (binary-only packages are not tofu-upgradable). Compare `installed.ver` vs `index.ver` via `tofu.vercmp.compare`: `>= 0` → up to date (`logDetail`); `< 0` → outdated, add to upgrade list. +4. **No outdated check**: Empty upgrade list → `logInfo("nothing to do")` + return 0. +5. **Show plan + confirm**: Format `"will upgrade N package(s): : → , ..."`. Prompt `"Proceed? [y/N] "` unless `flags.noconfirm`. No → `logInfo("aborted by user")` + return 0. +6. **Execute upgrades** (continue-on-failure): + - Create flags copy with `force=true` (ensure rebuild over stale output) + `noconfirm=true` (already confirmed at upgrade level). + - Create installCommand-compatible index closure: `delegate PackageIndex[]() @safe { return index; }` (closure captures the already-fetched index to avoid re-fetch). + - Call `installCommand(pkgName, pkgFlags, cfg, installIdxFetcher, null)` for each outdated package. + - Non-zero exit → `logError("upgrade failed for ")`, increment failure counter, CONTINUE. +7. **Summary**: `logOk("N package(s) upgraded, M up to date, ...")`. If failures > 0 → `logWarn("K upgrade(s) failed")` + return 1. + +### Delegate type mismatch between upgrade and install +- **upgradeCommand** indexFetcher: `PackageIndex[] delegate(Config) @safe` (takes Config param — matches search/info pattern). +- **installCommand** indexFetcher: `PackageIndex[] delegate() @safe` (closure, no params — captures state). +- **Fix**: Fetch index once in upgradeCommand, then create a zero-arg closure `delegate PackageIndex[]() @safe { return index; }` to pass to installCommand. The closure captures the already-fetched index array. + +### Force + noconfirm override on per-package flags +- `flags.force = true` — ensures stale build output is overwritten (Zeta -ReProvide handles overwrite). This is the upgrade semantic: the old package's build output may still exist. +- `flags.noconfirm = true` — the overall upgrade plan was already confirmed; individual installCommand calls should not re-prompt. This avoids double-confirmation UX. +- D struct copy: `auto pkgFlags = flags;` creates a full value copy of `ParsedArgs`. + +### Binary-only package exclusion +- Packages with `pool == Pool.binary` are skipped with `logDetail`. Binary packages are managed by ZETA directly, not tofu. The upgrade command only handles recipe-available packages (pool ∈ {recipes, both}). + +### `installCommand` re-entrancy safety +- `installCommand` sets module-level `_installCfg = cfg` and clears it via `scope(exit)`. Since tofu is single-threaded (lock-based), calling `installCommand` multiple times from upgrade is safe. Each call manages its own `_installCfg` lifecycle. + +### `write` name conflict (std.file vs std.stdio) +- With both `import std.file;` and `import std.stdio;` in scope, bare `write(...)` is ambiguous. Fix: use `std.stdio.write(string)` for stdout writes and `std.file.write(path, content)` for file writes. +- Same issue as encountered in `install.d` (task 21 learnings). + +### Test strategy (6 unittests) +- Reused test infrastructure patterns from `install.d`: `makeTempDir`, `removeDir`, `sWrite`, `makeFakeMakepkg`, `makeFakeZeta`. +- New helper: `makeConditionalZeta(dir, failName, failCode)` — fake zeta that fails for a specific package name (needed for test 5 multi-package upgrade-with-failure). +- `makeRecipeDir(cacheDir, name, ver)` — creates recipe cache directory + .recipe file in one call. + +### Test cases (6/6 pass) +1. No installed → `logInfo("nothing to do")` + exit 0. +2. Installed all up to date → `logDetail` for each + `logInfo("nothing to do")` + exit 0. +3. One outdated (1.0→2.0) → upgrade via installCommand path, zeta invoked, state updated to 2.0, exit 0. +4. Package removed from index (ghost) + realpkg outdated → ghost gets `logWarn` skip, realpkg upgraded, exit 0, ghost state preserved. +5. goodpkg upgrades, badpkg zeta fails → goodpkg upgraded (state 2.0), badpkg stays at 1.0, exit 1 with `logWarn("1 upgrade(s) failed")`. +6. Confirmation denied (stdin="n\n") → `logInfo("aborted by user")`, exit 0, nothing upgraded, zeta NOT invoked. + +### `stdin` redirection caveat +- Test 6 (confirmation denied) must be LAST in the file because `stdin` reassignment affects all subsequent test blocks. Same restriction as `install.d` tests 7 and 8. + +### `main.d` integration +- Import: `import tofu.commands.upgrade : upgradeCommand;` (selective). +- Dispatch: `case Command.upgrade: return upgradeCommand(pa, cfg);`. +- Replaced stub `logError("command 'upgrade' not implemented yet")` + `return 1`. + +### Build verified +- `dub test` passes — 23 modules, including upgrade.d's 6 unittests. +- `dub build` passes with `warningsAsErrors`. +- Evidence logged to `.omo/evidence/task-22-tofu-core.log`. diff --git a/src/main.d b/src/main.d index 637aef8..c00343a 100644 --- a/src/main.d +++ b/src/main.d @@ -24,6 +24,7 @@ import tofu.commands.install; // installCommand import tofu.commands.search; // searchCommand import tofu.commands.info : infoCommand; import tofu.commands.remove; // removeCommand +import tofu.commands.upgrade : upgradeCommand; import std.stdio; // writeln, stderr @@ -110,8 +111,7 @@ int main(string[] args) return searchCommand(pa.arg, cfg); case Command.upgrade: - logError("command 'upgrade' not implemented yet"); - return 1; + return upgradeCommand(pa, cfg); case Command.remove_: return removeCommand(pa.arg, pa, cfg); diff --git a/src/tofu/commands/upgrade.d b/src/tofu/commands/upgrade.d new file mode 100644 index 0000000..fc87a84 --- /dev/null +++ b/src/tofu/commands/upgrade.d @@ -0,0 +1,545 @@ +/// tofu.commands.upgrade — The `-Syu` upgrade command. +/// +/// Full pipeline (plan task 22): +/// 1. Fetch latest ZUUR index (indexFetcher seam, null → tofu.index.fetchIndex) +/// 2. List tofu-installed packages (tofu.state.listInstalled) +/// 3. Compare installed versions against index via tofu.vercmp.compare +/// 4. Show upgrade plan + confirm prompt (unless --noconfirm) +/// 5. For each outdated package: REUSE installCommand with force=true +/// (continue-on-failure for multi-package upgrades) +/// 6. Summary: N upgraded, M up to date, K failures +/// +/// Note: Only tofu-installed recipe packages are upgraded — binary packages +/// are outside tofu's purview. +/// +/// Testability: +/// `upgradeCommand` accepts optional `indexFetcher` delegate seam. +/// When null, real `fetchIndex(cfg)` is used. +module tofu.commands.upgrade; + +import tofu.config; // Config +import tofu.cli; // ParsedArgs, Command +import tofu.types; // PackageIndex, DepConstraint, BinaryCheckResult, Pool +import tofu.index; // fetchIndex, IndexException +import tofu.state; // listInstalled, installedVersion, InstalledPkg, recordInstall +import tofu.vercmp; // compare +import tofu.log; // logInfo, logOk, logError, logWarn, logDetail +import tofu.commands.install; // installCommand +import tofu.errors; // exitCodeFor + +import std.stdio; // write, stdout, readln, stdin, File, writeln +import std.file; // exists, mkdirRecurse, rmdirRecurse, readText, write, tempDir +import std.string; // strip +import std.format; // format +import std.path : buildPath; + +// ─── @trusted I/O wrappers (stdout/stdin are @system in DMD 2.112) ─────────── + +private @trusted string trustedReadLine() +{ + try { return readln(); } + catch (Exception) { return ""; } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/// Run the -Syu upgrade command. +/// +/// Params: +/// flags = Parsed command-line args (noconfirm honored for overall plan) +/// cfg = Tofu configuration +/// indexFetcher = Testability seam — when null, fetchIndex(cfg) is used +/// +/// Returns: 0 on success/no-op, 1 if any upgrade failed. +@safe +int upgradeCommand(ParsedArgs flags, Config cfg, + PackageIndex[] delegate(Config) @safe indexFetcher = null) +{ + // ── 1. Fetch index ────────────────────────────────────────────── + PackageIndex[] index; + try + { + if (indexFetcher !is null) + index = indexFetcher(cfg); + else + index = fetchIndex(cfg); + } + catch (Exception e) + { + logError("%s", e.msg); + return exitCodeFor(e); + } + + // ── 2. List installed packages ────────────────────────────────── + auto installed = listInstalled(cfg); + if (installed.length == 0) + { + logInfo("nothing to do"); + return 0; + } + + // ── 3. Compare installed vs index versions ────────────────────── + string[] outdated; + PackageIndex[] outdatedEntries; // matching index entries + string[] upToDate; + string[] removed; + size_t skippedCount = 0; + + foreach (pkg; installed) + { + // Find in index by name + PackageIndex* foundEntry = null; + for (size_t i = 0; i < index.length; i++) + { + if (index[i].name == pkg.name) + { + foundEntry = &index[i]; + break; + } + } + + if (foundEntry is null) + { + logWarn("package '%s' no longer in ZUUR — skipping", pkg.name); + removed ~= pkg.name; + continue; + } + + // Only upgrade recipe-available packages (pool == recipes or both) + if (foundEntry.pool == Pool.binary) + { + logDetail("package '%s' is binary-only — skipping", pkg.name); + skippedCount++; + continue; + } + + int cmp = compare(pkg.ver, foundEntry.ver); + if (cmp >= 0) + { + logDetail("package '%s' is up to date (%s)", pkg.name, pkg.ver); + upToDate ~= pkg.name; + } + else + { + // cmp < 0 → installed version is older + outdated ~= pkg.name; + outdatedEntries ~= *foundEntry; + } + } + + // ── 4. No outdated packages ───────────────────────────────────── + if (outdated.length == 0) + { + logInfo("nothing to do"); + return 0; + } + + // ── 5. Show plan + confirm ────────────────────────────────────── + { + string pkgList; + foreach (i, name; outdated) + { + if (i > 0) pkgList ~= ", "; + pkgList ~= format("%s: %s → %s", + name, installedVersion(name, cfg), outdatedEntries[i].ver); + } + logInfo("will upgrade %d package(s): %s", outdated.length, pkgList); + + if (!flags.noconfirm) + { + () @trusted { std.stdio.write("Proceed? [y/N] "); stdout.flush(); }(); + auto response = trustedReadLine().strip(); + if (response != "y" && response != "Y" + && response != "yes" && response != "YES") + { + logInfo("aborted by user"); + return 0; + } + } + } + + // ── 6. Run installCommand for each outdated package ───────────── + // Create flags copy with force=true (ensure rebuild over stale + // output — Zeta -ReProvide handles overwrite). + // noconfirm=true on the copy because we already confirmed at + // the upgrade level. + int failures = 0; + int succeeded = 0; + + // Build a closure that captures the already-fetched index so + // installCommand doesn't re-fetch. + auto installIdxFetcher = delegate PackageIndex[]() @safe + { + return index; + }; + + foreach (i, name; outdated) + { + // Copy flags and override force + noconfirm + auto pkgFlags = flags; // struct copy + pkgFlags.force = true; // ensure rebuild + pkgFlags.noconfirm = true; // already confirmed at upgrade level + + logInfo("upgrading %s (%s → %s)...", + name, installedVersion(name, cfg), outdatedEntries[i].ver); + + int ec = installCommand(name, pkgFlags, cfg, installIdxFetcher, null); + + if (ec != 0) + { + logError("upgrade failed for %s", name); + failures++; + } + else + { + succeeded++; + } + } + + // ── 7. Summary ────────────────────────────────────────────────── + { + import std.conv : to; + + string summary = format("%d package(s) upgraded", succeeded); + if (upToDate.length > 0) + summary ~= format(", %d up to date", upToDate.length); + if (removed.length > 0) + summary ~= format(", %d no longer in ZUUR", removed.length); + if (skippedCount > 0) + summary ~= format(", %d binary-only skipped", skippedCount); + + logOk("%s", summary); + + if (failures > 0) + { + logWarn("%d upgrade(s) failed", failures); + return 1; + } + } + + return 0; +} + +// ──────────────────────────────────────────────────────────── +// Unittests +// ──────────────────────────────────────────────────────────── + +@trusted string makeTempDir(string suffix) +{ + import std.conv : to; + import std.process : thisProcessID; + auto path = buildPath(tempDir, "tofu-upgcmd-" ~ suffix ~ "-" ~ thisProcessID.to!string); + if (exists(path)) rmdirRecurse(path); + mkdirRecurse(path); + return path; +} + +@trusted void removeDir(string path) +{ + try { rmdirRecurse(path); } catch (Exception) {} +} + +@trusted void sWrite(string path, string content) +{ + std.file.write(path, content); +} + +@trusted string makeFakeMakepkg(string dir, string exitCode = "0") +{ + import core.sys.posix.sys.stat : chmod; + import std.string : toStringz; + import std.conv : octal; + auto path = buildPath(dir, "fake-makepkg"); + string script = "#!/bin/bash\nset -e\n"; + script ~= "RECIPE_PATH=\"$1\"\n"; + script ~= "RECIPE_FILE=$(basename \"$RECIPE_PATH\")\n"; + script ~= "PKG_NAME=\"${RECIPE_FILE%.recipe}\"\n"; + script ~= "OUTPUT_DIR=.\n"; + script ~= "while [[ $# -gt 0 ]]; do case \"$1\" in --output) OUTPUT_DIR=\"$2\"; shift 2;; *) shift;; esac; done\n"; + script ~= "mkdir -p \"${OUTPUT_DIR}/packages/${PKG_NAME}\"\n"; + script ~= "echo 'return { version = \"1.0.0\" }' > \"${OUTPUT_DIR}/packages/${PKG_NAME}/package.lua\"\n"; + script ~= "exit " ~ exitCode ~ "\n"; + std.file.write(path, script); + chmod(toStringz(path), octal!755); + return path; +} + +@trusted string makeFakeZeta(string dir, string exitCode = "0") +{ + import core.sys.posix.sys.stat : chmod; + import std.string : toStringz; + import std.conv : octal; + auto path = buildPath(dir, "fake-zeta"); + std.file.write(path, "#!/bin/bash\necho \"$2\" > '" ~ dir ~ "/zeta-args.txt'\nexit " ~ exitCode ~ "\n"); + chmod(toStringz(path), octal!755); + return path; +} + +/// Create a conditional fake zeta that fails for a specific package name. +@trusted string makeConditionalZeta(string dir, string failName, string failCode = "1") +{ + import core.sys.posix.sys.stat : chmod; + import std.string : toStringz; + import std.conv : octal; + auto path = buildPath(dir, "fake-zeta"); + string script = "#!/bin/bash\n"; + script ~= "if [ \"$2\" = \"" ~ failName ~ "\" ]; then\n"; + script ~= " echo \"install failed\" >&2\n"; + script ~= " exit " ~ failCode ~ "\n"; + script ~= "fi\n"; + script ~= "echo \"$2\" > '" ~ dir ~ "/zeta-args.txt'\n"; + script ~= "exit 0\n"; + std.file.write(path, script); + chmod(toStringz(path), octal!755); + return path; +} + +/// Create a recipe directory + .recipe file. +@trusted void makeRecipeDir(string cacheDir, string name, string ver) +{ + auto d = buildPath(cacheDir, "recipes", name); + if (!exists(d)) mkdirRecurse(d); + sWrite(buildPath(d, name ~ ".recipe"), + `return { name = "` ~ name ~ `", version = "` ~ ver ~ `" }`); +} + +// ════════════════════════════════════ +// Test (1): no installed → "nothing to do" + exit 0 +// ════════════════════════════════════ + +@safe unittest +{ + auto tmp = makeTempDir("noinst"); + scope (exit) removeDir(tmp); + + Config cfg; + cfg.cacheDir = tmp; + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, "", true), + cfg, + delegate PackageIndex[](Config _) @safe { + PackageIndex e; e.name = "foo"; e.ver = "1.0"; e.pool = Pool.recipes; + return [e]; + }); + + assert(ec == 0, "expected 0, got " ~ ec.to!string); + // State should be empty (nothing installed, nothing recorded) + assert(installedVersion("foo", cfg) == ""); +} + +// ════════════════════════════════════ +// Test (2): installed all up to date → "nothing to do" + exit 0 +// ════════════════════════════════════ + +@safe unittest +{ + auto tmp = makeTempDir("uptodate"); + scope (exit) removeDir(tmp); + + Config cfg; + cfg.cacheDir = tmp; + + recordInstall("foo", "2.0", cfg); + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, "", true), + cfg, + delegate PackageIndex[](Config _) @safe { + PackageIndex e; e.name = "foo"; e.ver = "2.0"; e.pool = Pool.recipes; + return [e]; + }); + + assert(ec == 0, "expected 0, got " ~ ec.to!string); + assert(installedVersion("foo", cfg) == "2.0"); +} + +// ════════════════════════════════════ +// Test (3): one outdated → upgraded via installCommand path +// ════════════════════════════════════ + +@safe unittest +{ + auto tmp = makeTempDir("outdated3"); + scope (exit) removeDir(tmp); + + makeRecipeDir(tmp, "oldpkg", "2.0"); + + Config cfg; + cfg.cacheDir = tmp; + cfg.zuurUrl = "http://127.0.0.1:1"; // dead port — won't be used + cfg.zetaToolchainPath = makeFakeMakepkg(tmp); + cfg.zetaPath = makeFakeZeta(tmp); + cfg.defaultJobs = 1; + + // Pre-install old version + recordInstall("oldpkg", "1.0", cfg); + assert(installedVersion("oldpkg", cfg) == "1.0"); + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, "", true), + cfg, + delegate PackageIndex[](Config _) @safe { + PackageIndex e; e.name = "oldpkg"; e.ver = "2.0"; e.pool = Pool.recipes; + return [e]; + }); + + assert(ec == 0, "expected 0, got " ~ ec.to!string); + + // State should be updated + assert(installedVersion("oldpkg", cfg) == "2.0", + "expected 2.0, got " ~ installedVersion("oldpkg", cfg)); + + // Zeta should have been invoked + string za; + () @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }(); + assert(za.strip() == "oldpkg", + "expected zeta invoked with 'oldpkg', got '" ~ za.strip() ~ "'"); +} + +// ════════════════════════════════════ +// Test (4): package removed from index → warning, skipped +// ════════════════════════════════════ + +@safe unittest +{ + auto tmp = makeTempDir("removed4"); + scope (exit) removeDir(tmp); + + makeRecipeDir(tmp, "realpkg", "2.0"); + + Config cfg; + cfg.cacheDir = tmp; + cfg.zuurUrl = "http://127.0.0.1:1"; + cfg.zetaToolchainPath = makeFakeMakepkg(tmp); + cfg.zetaPath = makeFakeZeta(tmp); + cfg.defaultJobs = 1; + + // ghost is installed but not in index; realpkg needs upgrade + recordInstall("ghost", "1.0", cfg); + recordInstall("realpkg", "1.0", cfg); + assert(installedVersion("ghost", cfg) == "1.0"); + assert(installedVersion("realpkg", cfg) == "1.0"); + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, "", true), + cfg, + delegate PackageIndex[](Config _) @safe { + // Note: ghost is missing from index + PackageIndex e; e.name = "realpkg"; e.ver = "2.0"; e.pool = Pool.recipes; + return [e]; + }); + + assert(ec == 0, "expected 0, got " ~ ec.to!string); + + // realpkg should be upgraded + assert(installedVersion("realpkg", cfg) == "2.0"); + + // ghost stays at old version (not upgraded, still in state) + assert(installedVersion("ghost", cfg) == "1.0"); + + // Zeta should have been invoked for realpkg only + string za; + () @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }(); + assert(za.strip() == "realpkg", + "expected zeta invoked with 'realpkg', got '" ~ za.strip() ~ "'"); +} + +// ════════════════════════════════════ +// Test (5): upgrade failure → failure collected, others continue, exit 1 +// ════════════════════════════════════ + +@safe unittest +{ + auto tmp = makeTempDir("failure5"); + scope (exit) removeDir(tmp); + + makeRecipeDir(tmp, "goodpkg", "2.0"); + + // Need a separate recipe cache dir for badpkg explicitly + auto badDir = buildPath(tmp, "recipes", "badpkg"); + () @trusted { mkdirRecurse(badDir); }(); + sWrite(buildPath(badDir, "badpkg.recipe"), + `return { name = "badpkg", version = "2.0" }`); + + Config cfg; + cfg.cacheDir = tmp; + cfg.zuurUrl = "http://127.0.0.1:1"; + cfg.zetaToolchainPath = makeFakeMakepkg(tmp); + cfg.zetaPath = makeConditionalZeta(tmp, "badpkg", "1"); // zeta fails for badpkg + cfg.defaultJobs = 1; + + recordInstall("goodpkg", "1.0", cfg); + recordInstall("badpkg", "1.0", cfg); + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, "", true), + cfg, + delegate PackageIndex[](Config _) @safe { + PackageIndex g; g.name = "goodpkg"; g.ver = "2.0"; g.pool = Pool.recipes; + PackageIndex b; b.name = "badpkg"; b.ver = "2.0"; b.pool = Pool.recipes; + return [g, b]; + }); + + assert(ec == 1, "expected 1 for failure, got " ~ ec.to!string); + + // goodpkg should be upgraded + assert(installedVersion("goodpkg", cfg) == "2.0", + "goodpkg should be upgraded"); + + // badpkg should remain at old version (install failed) + assert(installedVersion("badpkg", cfg) == "1.0", + "badpkg should remain at 1.0, got " ~ installedVersion("badpkg", cfg)); +} + +// ════════════════════════════════════ +// Test (6): confirmation denied → aborted, nothing upgraded +// ════════════════════════════════════ +// NOTE: This test must be LAST because stdin redirection +// affects subsequent tests. + +@safe unittest +{ + auto tmp = makeTempDir("abort6"); + scope (exit) removeDir(tmp); + + makeRecipeDir(tmp, "abortpkg", "2.0"); + + Config cfg; + cfg.cacheDir = tmp; + cfg.zuurUrl = "http://127.0.0.1:1"; + cfg.zetaToolchainPath = makeFakeMakepkg(tmp); + cfg.zetaPath = makeFakeZeta(tmp); + cfg.defaultJobs = 1; + + recordInstall("abortpkg", "1.0", cfg); + + // Redirect stdin to "n\n" + File savedStdin; + () @trusted + { + savedStdin = stdin; + auto stdinFile = tmp ~ "/stdin-n.txt"; + sWrite(stdinFile, "n\n"); + stdin = File(stdinFile, "r"); + }(); + scope (exit) () @trusted { stdin = savedStdin; }(); + + auto ec = upgradeCommand( + ParsedArgs(Command.upgrade, ""), // noconfirm=false + cfg, + delegate PackageIndex[](Config _) @safe { + PackageIndex e; e.name = "abortpkg"; e.ver = "2.0"; e.pool = Pool.recipes; + return [e]; + }); + + assert(ec == 0, "expected 0 after abort, got " ~ ec.to!string); + + // State should be unchanged + assert(installedVersion("abortpkg", cfg) == "1.0", + "abortpkg should remain at 1.0, got " ~ installedVersion("abortpkg", cfg)); + + // Zeta args file should NOT exist (no install was attempted) + assert(!() @trusted { return exists(tmp ~ "/zeta-args.txt"); }(), + "zeta should NOT have been invoked"); +}