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
This commit is contained in:
@@ -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 phase[31merror[0m 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 phase[31merror[0m build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-249455/nonexistent.recipe
|
||||||
|
[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
[31merror[0m package 'nonexistent' not found in ZUUR
|
||||||
|
[31merror[0m package 'ghost' not found in ZUUR
|
||||||
|
[31merror[0m dependency cycle: A -> B -> A
|
||||||
|
[31merror[0m build failed for bfp: build failed for bfp:
|
||||||
|
(no stderr output)
|
||||||
|
[31merror[0m build failed: bfp — build failed for bfp:
|
||||||
|
(no stderr output)
|
||||||
|
[31merror[0m install phase error: install failed for ifp:
|
||||||
|
[2m . checking binary firefox ...[0m
|
||||||
|
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||||
|
[2m . checking binary libfoo ...[0m
|
||||||
|
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||||
|
[2m . checking binary noexist ...[0m
|
||||||
|
[2m . checking binary brokenpkg ...[0m
|
||||||
|
[2m . checking binary badpkg ...[0m
|
||||||
|
[2m . checking binary testpkg ...[0m
|
||||||
|
- binary testpkg-2.1.0 satisfies testpkg
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
[32m ok depA[0m
|
||||||
|
[36m==> ──── building depB (2/3) ────[0m
|
||||||
|
[32m ok depB[0m
|
||||||
|
[36m==> ──── building target (3/3) ────[0m
|
||||||
|
[32m ok target[0m
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
- nothing to build
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
- pkg already built, skipping
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
[32m ok pkg[0m
|
||||||
|
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
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + happypkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): happypkg
|
||||||
|
[36m==> ──── building happypkg (1/1) ────[0m
|
||||||
|
[32m ok happypkg[0m
|
||||||
|
[32m ok installed happypkg with 0 dependencies[0m
|
||||||
|
[32m ok installed 1 package(s)[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + bfp (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): bfp
|
||||||
|
[36m==> ──── building bfp (1/1) ────[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + ifp (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): ifp
|
||||||
|
[36m==> ──── building ifp (1/1) ────[0m
|
||||||
|
[32m ok ifp[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + drp (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): drp
|
||||||
|
- dry run — nothing built
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + ncp (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): ncp
|
||||||
|
[36m==> ──── building ncp (1/1) ────[0m
|
||||||
|
[32m ok ncp[0m
|
||||||
|
[32m ok installed ncp with 0 dependencies[0m
|
||||||
|
[32m ok installed 1 package(s)[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + abp (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): abp
|
||||||
|
Proceed? [y/N] [33mwarn package 'foreignpkg' was not installed by tofu — removing via Zeta anyway[0m
|
||||||
|
- aborted by user
|
||||||
|
[32m ok removed testpkg[0m
|
||||||
|
removed ok[32m ok removed foreignpkg[0m
|
||||||
|
error cannot remove libfoo: still required by libbar (use --force to override)[31merror[0m cannot remove libfoo (use --force to override)
|
||||||
|
[32m ok removed testpkg[0m
|
||||||
|
removed without prompt[32m ok removed testpkg[0m
|
||||||
|
Remove testpkg? [y/N] [33mwarn package 'ghost' no longer in ZUUR — skipping[0m
|
||||||
|
- aborted by user
|
||||||
|
- nothing to do
|
||||||
|
[2m . package 'foo' is up to date (2.0)[0m
|
||||||
|
- nothing to do
|
||||||
|
- will upgrade 1 package(s): oldpkg: 1.0 → 2.0
|
||||||
|
- upgrading oldpkg (1.0 → 2.0)...
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + oldpkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): oldpkg
|
||||||
|
[36m==> ──── building oldpkg (1/1) ────[0m
|
||||||
|
[32m ok oldpkg[0m
|
||||||
|
[32m ok installed oldpkg with 0 dependencies[0m
|
||||||
|
[32m ok installed 1 package(s)[0m
|
||||||
|
[32m ok 1 package(s) upgraded[0m
|
||||||
|
- will upgrade 1 package(s): realpkg: 1.0 → 2.0
|
||||||
|
- upgrading realpkg (1.0 → 2.0)...
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + realpkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): realpkg
|
||||||
|
[36m==> ──── building realpkg (1/1) ────[0m
|
||||||
|
[32m ok realpkg[0m
|
||||||
|
[32m ok installed realpkg with 0 dependencies[0m
|
||||||
|
[32m ok installed 1 package(s)[0m
|
||||||
|
[32m ok 1 package(s) upgraded, 1 no longer in ZUUR[0m
|
||||||
|
- will upgrade 2 package(s): goodpkg: 1.0 → 2.0, badpkg: 1.0 → 2.0
|
||||||
|
- upgrading goodpkg (1.0 → 2.0)...
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + goodpkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): goodpkg
|
||||||
|
[36m==> ──── building goodpkg (1/1) ────[0m
|
||||||
|
[32m ok goodpkg[0m
|
||||||
|
[32m ok installed goodpkg with 0 dependencies[0m
|
||||||
|
[32m ok installed 1 package(s)[0m
|
||||||
|
- upgrading badpkg (1.0 → 2.0)...
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + badpkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
- will build 1 package(s): badpkg
|
||||||
|
[36m==> ──── building badpkg (1/1) ────[0m
|
||||||
|
[32m ok badpkg[0m
|
||||||
|
install failed[31merror[0m install phase error: install failed for badpkg: install failed
|
||||||
|
[31merror[0m upgrade failed for badpkg
|
||||||
|
[33mwarn 1 upgrade(s) failed[0m
|
||||||
|
[32m ok 1 package(s) upgraded[0m
|
||||||
|
- 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
|
||||||
|
[33mwarn removing stale lock (PID 99999999 not alive)[0m
|
||||||
|
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 -
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||||
|
- aborted by user
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[2m . custom build system: fetching scripts/build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:43045//index.lua[0m
|
||||||
|
[32m ok index loaded: 3 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:54983//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:49387//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:41617//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:35363//index.lua[0m
|
||||||
|
[32m ok index loaded: 0 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:60717//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:40975//index.lua[0m
|
||||||
|
[32m ok index loaded: 2 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:41091//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:33219//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
|
||||||
|
[32m ok installed A with 2 dependencies[0m
|
||||||
|
- nothing to install
|
||||||
|
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-249455/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
23 modules passed unittests
|
||||||
|
- already installed — skipping
|
||||||
|
[32m ok installed A with 0 dependencies[0m
|
||||||
|
- 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)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + C (recipe)
|
||||||
|
- + A (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- recipe libfoo not in cache, re-fetching
|
||||||
|
- + libfoo (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- nothing to build (all binary)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + libfoo (recipe)
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
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
|
||||||
@@ -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.
|
**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.
|
||||||
|
|||||||
@@ -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 test` passes — 21 modules, all 8 install.d unittests pass.
|
||||||
- `dub build` passes with `warningsAsErrors`.
|
- `dub build` passes with `warningsAsErrors`.
|
||||||
- Evidence logged to `.omo/evidence/task-21-tofu-core.log`.
|
- 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): <name>: <old> → <new>, ..."`. 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 <name>")`, 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`.
|
||||||
|
|||||||
+2
-2
@@ -24,6 +24,7 @@ import tofu.commands.install; // installCommand
|
|||||||
import tofu.commands.search; // searchCommand
|
import tofu.commands.search; // searchCommand
|
||||||
import tofu.commands.info : infoCommand;
|
import tofu.commands.info : infoCommand;
|
||||||
import tofu.commands.remove; // removeCommand
|
import tofu.commands.remove; // removeCommand
|
||||||
|
import tofu.commands.upgrade : upgradeCommand;
|
||||||
|
|
||||||
import std.stdio; // writeln, stderr
|
import std.stdio; // writeln, stderr
|
||||||
|
|
||||||
@@ -110,8 +111,7 @@ int main(string[] args)
|
|||||||
return searchCommand(pa.arg, cfg);
|
return searchCommand(pa.arg, cfg);
|
||||||
|
|
||||||
case Command.upgrade:
|
case Command.upgrade:
|
||||||
logError("command 'upgrade' not implemented yet");
|
return upgradeCommand(pa, cfg);
|
||||||
return 1;
|
|
||||||
|
|
||||||
case Command.remove_:
|
case Command.remove_:
|
||||||
return removeCommand(pa.arg, pa, cfg);
|
return removeCommand(pa.arg, pa, cfg);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user