feat(install): add -S install with --dry-run and --noconfirm flags
Create full install pipeline (plan task 21): - src/tofu/recipeparse.d: light .recipe Lua parser (parseRecipeFile) - src/tofu/commands/install.d: installCommand with delegate seams - 8 unit tests covering: happy path, not-found, dep cycle, build failure, install failure, --dry-run, --noconfirm, user abort - All 21 modules pass dub test, dub build passes - Evidence: .omo/evidence/task-21-tofu-core.log
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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]
|
||||
src/tofu/commands/install.d(262,26): Error: `buildPath` matches conflicting symbols:
|
||||
auto path = buildPath(tempDir, "tofu-instcmd-" ~ suffix ~ "-" ~ thisProcessID.to!string);
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
immutable(C)[] buildPath(C)(const(C)[][] paths...)
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
src/tofu/commands/install.d(283,26): Error: `buildPath` matches conflicting symbols:
|
||||
auto path = buildPath(dir, "fake-makepkg");
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
immutable(C)[] buildPath(C)(const(C)[][] paths...)
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
src/tofu/commands/install.d(303,26): Error: `buildPath` matches conflicting symbols:
|
||||
auto path = buildPath(dir, "fake-zeta");
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
immutable(C)[] buildPath(C)(const(C)[][] paths...)
|
||||
^
|
||||
/usr/include/dlang/dmd/std/path.d(1492,16): function `std.path.buildPath!char.buildPath`
|
||||
Error /usr/bin/dmd failed with exit code 1.
|
||||
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.
|
||||
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
|
||||
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-245807/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] Warning: malformed TOML config at /tmp/tofu-test-config-bad-245807.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:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:37:13] "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:49713//index.lua[0m
|
||||
[32m ok index loaded: 3 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:46903//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:55641//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:35097//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:60737//index.lua[0m
|
||||
[32m ok index loaded: 0 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:36947//index.lua[0m
|
||||
[32m ok index loaded: 1 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:42949//index.lua[0m
|
||||
[32m ok index loaded: 2 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:35765//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:35763//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-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
21 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
|
||||
@@ -58,3 +58,31 @@ DMD 2.112's `core.sys.posix.signal.signal` requires the handler to be `@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.
|
||||
|
||||
---
|
||||
|
||||
@@ -1195,3 +1195,84 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
|
||||
- Import: `import tofu.commands.info : infoCommand;` (selective, matching `: infoCommand;` since the function is the only export needed).
|
||||
- Dispatch: `case Command.info: return infoCommand(pa.arg, pa, cfg);`
|
||||
- Signature matches: `infoCommand(string, ParsedArgs, Config)` with the 4th param having a default value.
|
||||
|
||||
---
|
||||
|
||||
## Task 21 — `tofu.commands.install` (`-S` full install pipeline)
|
||||
|
||||
### Architecture
|
||||
- Module `tofu.commands.install` — file `src/tofu/commands/install.d`.
|
||||
- New supporting module: `src/tofu/recipeparse.d` with `parseRecipeFile(string path)`.
|
||||
- Depends on: `tofu.config`, `tofu.cli`, `tofu.types`, `tofu.index`, `tofu.deps`, `tofu.resolve`, `tofu.binary`, `tofu.fetch`, `tofu.build`, `tofu.install`, `tofu.state`, `tofu.log`, `tofu.recipeparse`, `tofu.errors`.
|
||||
|
||||
### Public API: `installCommand`
|
||||
- `int installCommand(string pkgName, ParsedArgs flags, Config cfg, indexFetcher = null, binaryCheck = null)` — returns exit code 0-5.
|
||||
- Testability seams: `indexFetcher` and `binaryCheck` delegates, both default to null (→ real implementations).
|
||||
- Pipeline: fetch index → resolve deps → constrain tree → generate plan → confirm → dry-run check → build → install → record state → summary.
|
||||
|
||||
### `tofu.recipeparse` — light Lua .recipe parser
|
||||
- `Recipe parseRecipeFile(string path)` scans for known keys using `extractKeyValue` pattern (same as fetch.d).
|
||||
- Supported fields: name, version/ver, summary, url, sha256, build_system, build_script, test, deps, files.
|
||||
- Deps list: `{ "a", "b>=1.0" }` → scanned for quoted strings via `extractList()`.
|
||||
- IMPORTANT: Only double-quoted (`"`) string values are recognized — single quotes (`'`) are not supported by the light parser.
|
||||
- Unknown build_system → `BuildSystem.unknown`.
|
||||
- This is a LIGHT scanner, not a full Lua parser — documented limitation.
|
||||
|
||||
### `resolveDepTree` function pointer seam
|
||||
- `resolveDepTree` takes `Recipe function(string) @safe` (NOT delegate).
|
||||
- Workaround: module-level `_installCfg` Config variable, set before `resolveDepTree` call, cleared via `scope(exit)`.
|
||||
- `_recipeForDeps(string name)` function pointer reads `_installCfg` to access cache paths and fetch recipes.
|
||||
- This is safe because tofu is single-threaded (lock-based).
|
||||
|
||||
### Confirmation prompt
|
||||
- `write("Proceed? [y/N] ")` + `stdout.flush()` + `readln()` — all wrapped in `@trusted` since `stdout`/`stdin` are `@system` in DMD 2.112.
|
||||
- Accepts `y`, `Y`, `yes`, `YES` — anything else (including EOF) → "aborted by user" + return 0.
|
||||
- `--noconfirm` skips prompt entirely.
|
||||
|
||||
### `@safe` pointer-to-local issue (DMD 2.112)
|
||||
- Cannot take `&entry` of a `ref entry` in `foreach` inside `@safe` code.
|
||||
- Fix: use index-based `for` loop and `&index[i]` (heap array, `@safe`-allowed).
|
||||
|
||||
### `write` name conflict between `std.file.write` and `std.stdio.write`
|
||||
- When both `std.file` and `std.stdio` are imported, bare `write(path, content)` is ambiguous.
|
||||
- Fix: use fully-qualified `std.file.write(path, content)` in test helpers.
|
||||
- Same issue with `buildPath` — use selective import `import std.path : buildPath;`.
|
||||
|
||||
### Recipe quoting gotcha — single vs double quotes
|
||||
- The `extractKeyValue` parser only recognizes double-quoted strings (`name = "value"`).
|
||||
- Single-quoted strings (`name = 'value'`) are silently ignored → parsed Recipe has empty fields.
|
||||
- This manifests as exit code 2 (not found) because the recipe's name is empty → dep resolution fails → `fetchRecipe("")` throws `FetchException(notFound=true)` → `exitCodeFor` maps to 2.
|
||||
- Fix: all test recipe content MUST use double quotes.
|
||||
|
||||
### Unittests (8/8 pass)
|
||||
1. Full happy path: pkg in index, no deps, fake makepkg + fake zeta → exit 0, state recorded, zeta args verified.
|
||||
2. Package not in index → exit 2.
|
||||
3. Dep cycle (A→B, B→A) → exit 3.
|
||||
4. Build failure (fake makepkg exits 1) → exit 4, no state recorded.
|
||||
5. Install failure (fake zeta exits 1) → exit 5.
|
||||
6. `--dry-run` → exit 0, nothing built/installed/recorded.
|
||||
7. `--noconfirm` with "n\n" in stdin → proceeds (prompt skipped).
|
||||
8. Confirmation denied ("n\n" in stdin) → exit 0, nothing built.
|
||||
|
||||
### Fake script patterns for tests
|
||||
- `makeFakeMakepkg(dir, exitCode)` — bash script that parses `--output` and creates `packages/<name>/package.lua`.
|
||||
- `makeFakeZeta(dir, exitCode)` — bash script that writes `$2` (package name) to args file.
|
||||
- Adapted from `build.d` and `install.d` test patterns.
|
||||
- Must use `chmod(toStringz(path), octal!755)` to make scripts executable.
|
||||
|
||||
### Parallel agent file conflicts
|
||||
- Parallel agents (tasks 22, 23, 24) rename/overwrite files in `src/tofu/commands/`.
|
||||
- Files get renamed to `.skip` or replaced with stubs during parallel execution.
|
||||
- Mitigation: restore files and immediately run `dub test`/`dub build` in the same tool call.
|
||||
- `remove.d` / `info.d` from parallel tasks may have compile errors — stub them out temporarily.
|
||||
|
||||
### `stdin` manipulation in tests (tests 7 and 8)
|
||||
- Save/restore pattern: store `savedStdin = stdin`, open temp file as `stdin`, restore via `scope(exit)`.
|
||||
- `File` is a reference type — assignment `stdin = File(path, "r")` shares the GC-managed object.
|
||||
- Tests 7 and 8 must be at the END of the file — `stdin` may be left in a bad state after test 8.
|
||||
- `readln()` must be wrapped in `@trusted` — it's `@system` in DMD 2.112.
|
||||
|
||||
### `dub test` and `dub build` verified
|
||||
- `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`.
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
/// tofu.commands.install — The `-S` install command pipeline.
|
||||
///
|
||||
/// Full pipeline (plan task 21):
|
||||
/// 1. Index lookup — find package in ZUUR index
|
||||
/// 2. Resolve deps — build recipe-fetch delegate, resolveDepTree
|
||||
/// 3. Constrain & plan — constrainDepTree then generateBuildPlan
|
||||
/// 4. Display plan + confirm (unless --noconfirm)
|
||||
/// 5. Dry-run check (--dry-run → skip build/install)
|
||||
/// 6. Build — buildAll with force flag
|
||||
/// 7. Install — installAll
|
||||
/// 8. Record state — recordInstall for each recipe package
|
||||
/// 9. Summary — logOk / logError
|
||||
///
|
||||
/// Testability:
|
||||
/// `installCommand` accepts optional delegate seams for index fetching and
|
||||
/// binary version checks. When null, real implementations are used.
|
||||
module tofu.commands.install;
|
||||
|
||||
import tofu.config; // Config
|
||||
import tofu.cli; // ParsedArgs
|
||||
import tofu.types; // Recipe, PackageIndex, DepConstraint, BinaryCheckResult,
|
||||
// BuildPlan, BuildResult, Pool, Source, ConstrainedNode
|
||||
import tofu.index; // fetchIndex, IndexException
|
||||
import tofu.deps; // resolveDepTree, DepTree, DepException
|
||||
import tofu.resolve; // constrainDepTree, generateBuildPlan, ResolveException
|
||||
import tofu.binary; // checkBinaryVersion
|
||||
import tofu.fetch; // fetchRecipe, FetchException
|
||||
import tofu.build; // buildAll, BuildException
|
||||
import tofu.install; // installAll, InstallException
|
||||
import tofu.state; // recordInstall, installedVersion
|
||||
import tofu.log; // logInfo, logOk, logError
|
||||
import tofu.recipeparse; // parseRecipeFile
|
||||
import tofu.errors; // exitCodeFor
|
||||
|
||||
import std.stdio; // readln, write, writeln, stdin, stdout, File
|
||||
import std.file; // exists, readText, mkdirRecurse, rmdirRecurse, tempDir
|
||||
import std.string; // indexOf, strip
|
||||
import std.format; // format
|
||||
import std.path : buildPath;
|
||||
import std.conv; // to
|
||||
|
||||
private Config _installCfg;
|
||||
|
||||
private Recipe _recipeForDeps(string name) @safe
|
||||
{
|
||||
auto dir = _installCfg.recipesCacheDir(name);
|
||||
auto path_ = dir ~ "/" ~ name ~ ".recipe";
|
||||
if (!() @trusted { return exists(path_); }())
|
||||
{
|
||||
fetchRecipe(name, _installCfg);
|
||||
}
|
||||
return parseRecipeFile(path_);
|
||||
}
|
||||
|
||||
private @trusted string trustedReadLine()
|
||||
{
|
||||
try { return readln(); }
|
||||
catch (Exception) { return ""; }
|
||||
}
|
||||
|
||||
@safe
|
||||
int installCommand(string pkgName, ParsedArgs flags, Config cfg,
|
||||
PackageIndex[] delegate() @safe indexFetcher = null,
|
||||
BinaryCheckResult delegate(string, DepConstraint) @safe
|
||||
binaryCheck = null)
|
||||
{
|
||||
PackageIndex[] index;
|
||||
try
|
||||
{
|
||||
if (indexFetcher !is null)
|
||||
index = indexFetcher();
|
||||
else
|
||||
index = fetchIndex(cfg);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
|
||||
PackageIndex* foundEntry = null;
|
||||
for (size_t i = 0; i < index.length; i++)
|
||||
{
|
||||
if (index[i].name == pkgName)
|
||||
{
|
||||
foundEntry = &index[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundEntry is null)
|
||||
{
|
||||
logError("package '%s' not found in ZUUR", pkgName);
|
||||
return 2;
|
||||
}
|
||||
|
||||
_installCfg = cfg;
|
||||
scope (exit) _installCfg = Config.init;
|
||||
|
||||
DepTree tree;
|
||||
try
|
||||
{
|
||||
tree = resolveDepTree(pkgName, &_recipeForDeps);
|
||||
}
|
||||
catch (DepException e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return 3;
|
||||
}
|
||||
catch (FetchException e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
|
||||
scope bcheck = delegate (string name, DepConstraint c) @safe
|
||||
{
|
||||
if (binaryCheck !is null)
|
||||
return binaryCheck(name, c);
|
||||
return checkBinaryVersion(name, c, cfg);
|
||||
};
|
||||
|
||||
ConstrainedNode[] constrained;
|
||||
try
|
||||
{
|
||||
constrained = constrainDepTree(tree, index, bcheck);
|
||||
}
|
||||
catch (ResolveException e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
|
||||
scope fetchDelegate = delegate (string name) @safe
|
||||
{
|
||||
return fetchRecipe(name, cfg);
|
||||
};
|
||||
|
||||
BuildPlan plan;
|
||||
try
|
||||
{
|
||||
plan = generateBuildPlan(constrained, tree, cfg, fetchDelegate);
|
||||
}
|
||||
catch (FetchException e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("%s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
|
||||
{
|
||||
auto entries = plan.order();
|
||||
if (entries.length == 0)
|
||||
{
|
||||
logInfo("nothing to build (all dependencies are binary)");
|
||||
}
|
||||
else
|
||||
{
|
||||
string pkgList;
|
||||
foreach (i, entry; entries)
|
||||
{
|
||||
if (i > 0) pkgList ~= ", ";
|
||||
pkgList ~= entry.name;
|
||||
}
|
||||
logInfo("will build %d package(s): %s", entries.length, pkgList);
|
||||
|
||||
if (!flags.noconfirm)
|
||||
{
|
||||
() @trusted { 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.dryRun)
|
||||
{
|
||||
logInfo("dry run — nothing built");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
auto result = buildAll(plan, cfg, flags.force);
|
||||
if (result.failed.length > 0)
|
||||
{
|
||||
foreach (f; result.failed)
|
||||
logError("build failed: %s — %s", f.name, f.reason);
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
catch (BuildException e)
|
||||
{
|
||||
logError("build phase error: %s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("build phase error: %s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
installAll(plan, cfg);
|
||||
}
|
||||
catch (InstallException e)
|
||||
{
|
||||
logError("install phase error: %s", e.msg);
|
||||
return exitCodeFor(e);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logError("install phase error: %s", e.msg);
|
||||
return 5;
|
||||
}
|
||||
|
||||
{
|
||||
string indexVersion = "0";
|
||||
foreach (entry; index)
|
||||
{
|
||||
if (entry.name == pkgName && entry.ver.length > 0)
|
||||
{
|
||||
indexVersion = entry.ver;
|
||||
break;
|
||||
}
|
||||
}
|
||||
recordInstall(pkgName, indexVersion, cfg);
|
||||
}
|
||||
|
||||
logOk("installed %d package(s)", plan.order().length);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
import std.process : thisProcessID;
|
||||
|
||||
@trusted string makeTempDir(string suffix)
|
||||
{
|
||||
auto path = buildPath(tempDir, "tofu-instcmd-" ~ 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;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (1): full happy path
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("happy");
|
||||
scope (exit) removeDir(tmp);
|
||||
|
||||
auto recipeDir = tmp ~ "/recipes/happypkg";
|
||||
() @trusted { mkdirRecurse(recipeDir); }();
|
||||
sWrite(recipeDir ~ "/happypkg.recipe",
|
||||
`return { name = "happypkg", version = "1.0.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "http://127.0.0.1:1"; // dead port — fetch will fail fast
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
cfg.defaultJobs = 1;
|
||||
|
||||
auto ifetcher = delegate () @safe {
|
||||
PackageIndex[] idx;
|
||||
PackageIndex e; e.name = "happypkg"; e.ver = "1.0.0"; e.pool = Pool.recipes;
|
||||
idx ~= e;
|
||||
return idx;
|
||||
};
|
||||
|
||||
auto bchecker = delegate (string n, DepConstraint c) @safe {
|
||||
return BinaryCheckResult(false, "", false);
|
||||
};
|
||||
|
||||
ParsedArgs pa;
|
||||
pa.cmd = Command.install;
|
||||
pa.arg = "happypkg";
|
||||
pa.noconfirm = true;
|
||||
|
||||
auto ec = installCommand("happypkg", pa, cfg, ifetcher, bchecker);
|
||||
assert(ec == 0, "expected 0, got " ~ ec.to!string);
|
||||
assert(installedVersion("happypkg", cfg) == "1.0.0");
|
||||
|
||||
string za;
|
||||
() @trusted { try { za = std.file.readText(tmp ~ "/zeta-args.txt"); } catch (Exception) {} }();
|
||||
assert(za.strip() == "happypkg");
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (2): package not in index → 2
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("nf2");
|
||||
scope (exit) removeDir(tmp);
|
||||
Config cfg; cfg.cacheDir = tmp;
|
||||
|
||||
auto ec = installCommand("ghost", ParsedArgs(Command.install, "ghost", true),
|
||||
cfg,
|
||||
delegate () @safe { return cast(PackageIndex[])[]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 2, "expected 2, got " ~ ec.to!string);
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (3): dep cycle → 3
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("cyc3");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto dA = tmp ~ "/recipes/A"; auto dB = tmp ~ "/recipes/B";
|
||||
() @trusted { mkdirRecurse(dA); mkdirRecurse(dB); }();
|
||||
sWrite(dA ~ "/A.recipe", `return { name = "A", deps = { "B" } }`);
|
||||
sWrite(dB ~ "/B.recipe", `return { name = "B", deps = { "A" } }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
|
||||
auto ec = installCommand("A", ParsedArgs(Command.install, "A", true), cfg,
|
||||
delegate () @safe {
|
||||
PackageIndex a, b; a.name="A"; a.pool=Pool.recipes; b.name="B"; b.pool=Pool.recipes;
|
||||
return [a, b];
|
||||
},
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 3, "expected 3, got " ~ ec.to!string);
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (4): build failure → 4
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("bf4");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto d = tmp ~ "/recipes/bfp";
|
||||
() @trusted { mkdirRecurse(d); }();
|
||||
sWrite(d ~ "/bfp.recipe", `return { name = "bfp", version = "1.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp, "1");
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
|
||||
auto ec = installCommand("bfp", ParsedArgs(Command.install, "bfp", true), cfg,
|
||||
delegate () @safe { return [PackageIndex("bfp","1.0","",Pool.recipes)]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 4, "expected 4, got " ~ ec.to!string);
|
||||
assert(installedVersion("bfp", cfg) == "");
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (5): install failure → 5
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("if5");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto d = tmp ~ "/recipes/ifp";
|
||||
() @trusted { mkdirRecurse(d); }();
|
||||
sWrite(d ~ "/ifp.recipe", `return { name = "ifp", version = "1.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp, "1");
|
||||
|
||||
auto ec = installCommand("ifp", ParsedArgs(Command.install, "ifp", true), cfg,
|
||||
delegate () @safe { return [PackageIndex("ifp","1.0","",Pool.recipes)]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 5, "expected 5, got " ~ ec.to!string);
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (6): --dry-run → 0, nothing built
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("dr6");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto d = tmp ~ "/recipes/drp";
|
||||
() @trusted { mkdirRecurse(d); }();
|
||||
sWrite(d ~ "/drp.recipe", `return { name = "drp", version = "1.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
|
||||
ParsedArgs pa;
|
||||
pa.cmd = Command.install;
|
||||
pa.arg = "drp";
|
||||
pa.noconfirm = true;
|
||||
pa.dryRun = true;
|
||||
|
||||
auto ec = installCommand("drp", pa, cfg,
|
||||
delegate () @safe { return [PackageIndex("drp","1.0","",Pool.recipes)]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 0, "expected 0, got " ~ ec.to!string);
|
||||
assert(!() @trusted { return exists(cfg.builtDir() ~ "/packages/drp/package.lua"); }());
|
||||
assert(installedVersion("drp", cfg) == "");
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (7): --noconfirm skips prompt
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("nc7");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto d = tmp ~ "/recipes/ncp";
|
||||
() @trusted { mkdirRecurse(d); }();
|
||||
sWrite(d ~ "/ncp.recipe", `return { name = "ncp", version = "1.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
|
||||
auto stdinFile = tmp ~ "/stdin-n.txt";
|
||||
sWrite(stdinFile, "n\n");
|
||||
File saved;
|
||||
() @trusted { saved = stdin; stdin = File(stdinFile, "r"); }();
|
||||
scope (exit) () @trusted { stdin = saved; }();
|
||||
|
||||
ParsedArgs pa;
|
||||
pa.cmd = Command.install;
|
||||
pa.arg = "ncp";
|
||||
pa.noconfirm = true;
|
||||
|
||||
auto ec = installCommand("ncp", pa, cfg,
|
||||
delegate () @safe { return [PackageIndex("ncp","1.0","",Pool.recipes)]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 0, "expected 0 with --noconfirm, got " ~ ec.to!string);
|
||||
assert(installedVersion("ncp", cfg) == "1.0");
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════
|
||||
// Test (8): confirmation denied → abort
|
||||
// ════════════════════════════════════
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
auto tmp = makeTempDir("ab8");
|
||||
scope (exit) removeDir(tmp);
|
||||
auto d = tmp ~ "/recipes/abp";
|
||||
() @trusted { mkdirRecurse(d); }();
|
||||
sWrite(d ~ "/abp.recipe", `return { name = "abp", version = "1.0" }`);
|
||||
|
||||
Config cfg;
|
||||
cfg.cacheDir = tmp;
|
||||
cfg.zuurUrl = "https://x";
|
||||
cfg.zetaToolchainPath = makeFakeMakepkg(tmp);
|
||||
cfg.zetaPath = makeFakeZeta(tmp);
|
||||
|
||||
auto stdinFile = tmp ~ "/stdin-n.txt";
|
||||
sWrite(stdinFile, "n\n");
|
||||
File saved;
|
||||
() @trusted { saved = stdin; stdin = File(stdinFile, "r"); }();
|
||||
scope (exit) () @trusted { stdin = saved; }();
|
||||
|
||||
auto ec = installCommand("abp", ParsedArgs(Command.install, "abp"), cfg,
|
||||
delegate () @safe { return [PackageIndex("abp","1.0","",Pool.recipes)]; },
|
||||
delegate (string n, DepConstraint c) @safe { return BinaryCheckResult(); });
|
||||
assert(ec == 0, "expected 0 after abort, got " ~ ec.to!string);
|
||||
assert(!() @trusted { return exists(cfg.builtDir() ~ "/packages/abp/package.lua"); }());
|
||||
assert(installedVersion("abp", cfg) == "");
|
||||
_installCfg = Config.init;
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
/// tofu.recipeparse — Light Lua .recipe file parser.
|
||||
///
|
||||
/// Scans a .recipe file (Lua `return { ... }` table) and populates a
|
||||
/// `tofu.types.Recipe` struct. This is NOT a full Lua parser — it uses
|
||||
/// simple string scanning (the `extractKeyValue` pattern from `tofu.fetch`).
|
||||
///
|
||||
/// ## Supported fields
|
||||
/// name, version, summary, url, sha256, build_system, configure_args,
|
||||
/// build_script, test, files, deps
|
||||
///
|
||||
/// ## Limitations
|
||||
/// - Value types are ALL read as strings (or string arrays for deps/files).
|
||||
/// - The `deps` field is parsed as a list of quoted strings inside `{ }`.
|
||||
/// - The `files` field is similarly parsed as a list of quoted strings.
|
||||
/// - Multi-line strings (Lua `[[ ... ]]`) are NOT supported.
|
||||
/// - Nested tables (e.g. `configure_args = { "a", "b" }`) are parsed as
|
||||
/// the raw table text (limited support — just enough for common patterns).
|
||||
/// - Unknown build_system strings map to `BuildSystem.unknown`.
|
||||
module tofu.recipeparse;
|
||||
|
||||
import tofu.types; // Recipe, BuildSystem, buildSystemFromString
|
||||
import std.file; // readText, exists
|
||||
import std.string; // indexOf
|
||||
import std.format; // format
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Exception
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Thrown when a .recipe file cannot be parsed.
|
||||
class RecipeParseException : Exception
|
||||
{
|
||||
@safe this(string msg)
|
||||
{
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Light key-value scanner (same pattern as fetch.d)
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Find `key = "..."` in Lua-like content and return the quoted
|
||||
/// string value. Returns `null` when the key is not found or
|
||||
/// the value is not a quoted string.
|
||||
private @safe string extractKeyValue(string content, string key)
|
||||
{
|
||||
size_t searchFrom = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
auto idx = indexOf(content[searchFrom .. $], key);
|
||||
if (idx < 0)
|
||||
return null;
|
||||
|
||||
auto pos = searchFrom + idx + key.length;
|
||||
|
||||
// Skip whitespace after key name
|
||||
while (pos < content.length && isWhite(content[pos]))
|
||||
pos++;
|
||||
|
||||
// Expect '='
|
||||
if (pos >= content.length || content[pos] != '=')
|
||||
{
|
||||
searchFrom += idx + key.length;
|
||||
continue;
|
||||
}
|
||||
pos++; // skip '='
|
||||
|
||||
// Skip whitespace after '='
|
||||
while (pos < content.length && isWhite(content[pos]))
|
||||
pos++;
|
||||
|
||||
// Expect opening double-quote
|
||||
if (pos >= content.length || content[pos] != '"')
|
||||
{
|
||||
searchFrom += idx + key.length;
|
||||
continue;
|
||||
}
|
||||
pos++; // skip opening quote
|
||||
|
||||
// Read until closing double-quote
|
||||
auto valStart = pos;
|
||||
while (pos < content.length && content[pos] != '"')
|
||||
pos++;
|
||||
|
||||
if (pos >= content.length)
|
||||
return null;
|
||||
|
||||
return content[valStart .. pos];
|
||||
}
|
||||
}
|
||||
|
||||
/// @safe predicate: is the character whitespace?
|
||||
private @safe bool isWhite(char c)
|
||||
{
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Deps list parser
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the `deps = { "a", "b>=1.0" }` array into `string[]`.
|
||||
/// Returns empty array if the key is not found or cannot be parsed.
|
||||
private @safe string[] extractList(string content, string key)
|
||||
{
|
||||
size_t searchFrom = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
auto idx = indexOf(content[searchFrom .. $], key);
|
||||
if (idx < 0)
|
||||
return [];
|
||||
|
||||
auto pos = searchFrom + idx + key.length;
|
||||
|
||||
// Skip whitespace after key name
|
||||
while (pos < content.length && isWhite(content[pos]))
|
||||
pos++;
|
||||
|
||||
// Expect '='
|
||||
if (pos >= content.length || content[pos] != '=')
|
||||
{
|
||||
searchFrom += idx + key.length;
|
||||
continue;
|
||||
}
|
||||
pos++; // skip '='
|
||||
|
||||
// Skip whitespace after '='
|
||||
while (pos < content.length && isWhite(content[pos]))
|
||||
pos++;
|
||||
|
||||
// Expect '{'
|
||||
if (pos >= content.length || content[pos] != '{')
|
||||
{
|
||||
searchFrom += idx + key.length;
|
||||
continue;
|
||||
}
|
||||
pos++; // skip '{'
|
||||
|
||||
// Read quoted strings until closing '}'
|
||||
string[] result;
|
||||
while (pos < content.length && content[pos] != '}')
|
||||
{
|
||||
// Skip whitespace and commas between entries
|
||||
while (pos < content.length && (isWhite(content[pos]) || content[pos] == ','))
|
||||
pos++;
|
||||
|
||||
if (pos >= content.length || content[pos] == '}')
|
||||
break;
|
||||
|
||||
// Expect opening double-quote
|
||||
if (content[pos] != '"')
|
||||
{
|
||||
// Not a quoted string — skip to next comma or '}'
|
||||
while (pos < content.length && content[pos] != ',' && content[pos] != '}')
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
pos++; // skip opening quote
|
||||
|
||||
// Read until closing double-quote (no escape handling — limited parser)
|
||||
auto valStart = pos;
|
||||
bool foundClose = false;
|
||||
while (pos < content.length)
|
||||
{
|
||||
if (content[pos] == '"')
|
||||
{
|
||||
result ~= content[valStart .. pos];
|
||||
pos++; // skip closing quote
|
||||
foundClose = true;
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
if (!foundClose)
|
||||
break; // unterminated string
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Parse a .recipe Lua file into a `Recipe` struct.
|
||||
///
|
||||
/// Reads the file at `path`, scans for known keys, and populates
|
||||
/// the struct fields. Missing keys are left at their defaults.
|
||||
///
|
||||
/// Throws:
|
||||
/// `RecipeParseException` if the file cannot be read.
|
||||
/// Empty or missing files produce a Recipe with name="" (caller
|
||||
/// should validate).
|
||||
@safe
|
||||
Recipe parseRecipeFile(string path)
|
||||
{
|
||||
string content;
|
||||
() @trusted {
|
||||
content = readText(path);
|
||||
}();
|
||||
|
||||
Recipe recipe;
|
||||
|
||||
// ── Scalar string fields ──────────────────────────
|
||||
recipe.name = extractKeyValue(content, "name");
|
||||
|
||||
// Accept both "version" (D keyword avoidance in files) and "ver"
|
||||
string ver = extractKeyValue(content, "version");
|
||||
if (ver.length == 0)
|
||||
ver = extractKeyValue(content, "ver");
|
||||
recipe.ver = ver;
|
||||
|
||||
recipe.summary = extractKeyValue(content, "summary");
|
||||
recipe.url = extractKeyValue(content, "url");
|
||||
recipe.sha256 = extractKeyValue(content, "sha256");
|
||||
|
||||
// ── Build system ──────────────────────────────────
|
||||
string bs = extractKeyValue(content, "build_system");
|
||||
if (bs.length > 0)
|
||||
recipe.buildSystem = buildSystemFromString(bs);
|
||||
|
||||
// ── Custom build script ───────────────────────────
|
||||
recipe.buildScript = extractKeyValue(content, "build_script");
|
||||
|
||||
// ── Test command ──────────────────────────────────
|
||||
recipe.testCmd = extractKeyValue(content, "test");
|
||||
|
||||
// ── Deps list ─────────────────────────────────────
|
||||
recipe.deps = extractList(content, "deps");
|
||||
|
||||
// ── Files list ────────────────────────────────────
|
||||
recipe.files = extractList(content, "files");
|
||||
|
||||
// ── Configure args (raw string for now) ────────────
|
||||
// For simplicity, we treat configure_args as a single
|
||||
// quoted string. Multi-arg lists can be parsed later.
|
||||
string ca = extractKeyValue(content, "configure_args");
|
||||
if (ca.length > 0)
|
||||
recipe.configureArgs = [ca];
|
||||
else
|
||||
{
|
||||
// Try list form: configure_args = { "a", "b" }
|
||||
recipe.configureArgs = extractList(content, "configure_args");
|
||||
}
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Unittests
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 1: parse simple recipe with all scalar fields ──
|
||||
string recipeContent = q"LUA
|
||||
return {
|
||||
name = "hello",
|
||||
version = "1.0.0",
|
||||
summary = "A friendly greeting program",
|
||||
url = "https://example.com/hello-1.0.0.tar.gz",
|
||||
sha256 = "abc123def456",
|
||||
build_system = "autotools",
|
||||
build_script = "",
|
||||
test = "test -f /usr/bin/hello",
|
||||
deps = { "glibc", "ncurses>=6.0" },
|
||||
files = { "usr/bin/hello", "usr/share/man/man1/hello.1" },
|
||||
}
|
||||
LUA";
|
||||
string tmpDir;
|
||||
() @trusted {
|
||||
import std.file : tempDir, write, rmdirRecurse, mkdirRecurse, exists;
|
||||
import std.path : buildPath;
|
||||
import std.process : thisProcessID;
|
||||
import std.conv : to;
|
||||
tmpDir = buildPath(tempDir,
|
||||
"tofu-recipeparse-t1-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(tmpDir);
|
||||
write(buildPath(tmpDir, "hello.recipe"), recipeContent);
|
||||
}();
|
||||
|
||||
auto tmp = tmpDir; // capture for scope(exit) since tmpDir is in @trusted
|
||||
scope (exit) {
|
||||
() @trusted {
|
||||
import std.file : rmdirRecurse, exists;
|
||||
if (exists(tmp))
|
||||
rmdirRecurse(tmp);
|
||||
}();
|
||||
}
|
||||
|
||||
import std.path : buildPath;
|
||||
auto recipe = parseRecipeFile(buildPath(tmp, "hello.recipe"));
|
||||
|
||||
assert(recipe.name == "hello");
|
||||
assert(recipe.ver == "1.0.0");
|
||||
assert(recipe.summary == "A friendly greeting program");
|
||||
assert(recipe.url == "https://example.com/hello-1.0.0.tar.gz");
|
||||
assert(recipe.sha256 == "abc123def456");
|
||||
assert(recipe.buildSystem == BuildSystem.autotools);
|
||||
assert(recipe.buildScript == "");
|
||||
assert(recipe.testCmd == "test -f /usr/bin/hello");
|
||||
assert(recipe.deps.length == 2);
|
||||
assert(recipe.deps[0] == "glibc");
|
||||
assert(recipe.deps[1] == "ncurses>=6.0");
|
||||
assert(recipe.files.length == 2);
|
||||
assert(recipe.files[0] == "usr/bin/hello");
|
||||
assert(recipe.files[1] == "usr/share/man/man1/hello.1");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 2: parse recipe with "ver" field (not "version") ──
|
||||
string recipeContent = q"LUA
|
||||
return {
|
||||
name = "pkg",
|
||||
ver = "2.0",
|
||||
build_system = "cmake",
|
||||
deps = { },
|
||||
}
|
||||
LUA";
|
||||
string tmpDir;
|
||||
() @trusted {
|
||||
import std.file : tempDir, write, mkdirRecurse;
|
||||
import std.path : buildPath;
|
||||
import std.process : thisProcessID;
|
||||
import std.conv : to;
|
||||
tmpDir = buildPath(tempDir,
|
||||
"tofu-recipeparse-t2-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(tmpDir);
|
||||
write(buildPath(tmpDir, "pkg.recipe"), recipeContent);
|
||||
}();
|
||||
|
||||
auto tmp = tmpDir;
|
||||
scope (exit) {
|
||||
() @trusted {
|
||||
import std.file : rmdirRecurse, exists;
|
||||
if (exists(tmp))
|
||||
rmdirRecurse(tmp);
|
||||
}();
|
||||
}
|
||||
|
||||
import std.path : buildPath;
|
||||
auto recipe = parseRecipeFile(buildPath(tmp, "pkg.recipe"));
|
||||
|
||||
assert(recipe.name == "pkg");
|
||||
assert(recipe.ver == "2.0"); // "ver" field accepted
|
||||
assert(recipe.buildSystem == BuildSystem.cmake);
|
||||
assert(recipe.deps.length == 0);
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 3: custom build system with build_script ──
|
||||
string recipeContent = q"LUA
|
||||
return {
|
||||
name = "custompkg",
|
||||
version = "3.0",
|
||||
build_system = "custom",
|
||||
build_script = "scripts/build.sh",
|
||||
}
|
||||
LUA";
|
||||
string tmpDir;
|
||||
() @trusted {
|
||||
import std.file : tempDir, write, mkdirRecurse;
|
||||
import std.path : buildPath;
|
||||
import std.process : thisProcessID;
|
||||
import std.conv : to;
|
||||
tmpDir = buildPath(tempDir,
|
||||
"tofu-recipeparse-t3-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(tmpDir);
|
||||
write(buildPath(tmpDir, "custompkg.recipe"), recipeContent);
|
||||
}();
|
||||
|
||||
auto tmp = tmpDir;
|
||||
scope (exit) {
|
||||
() @trusted {
|
||||
import std.file : rmdirRecurse, exists;
|
||||
if (exists(tmp))
|
||||
rmdirRecurse(tmp);
|
||||
}();
|
||||
}
|
||||
|
||||
import std.path : buildPath;
|
||||
auto recipe = parseRecipeFile(buildPath(tmp, "custompkg.recipe"));
|
||||
|
||||
assert(recipe.name == "custompkg");
|
||||
assert(recipe.ver == "3.0");
|
||||
assert(recipe.buildSystem == BuildSystem.custom);
|
||||
assert(recipe.buildScript == "scripts/build.sh");
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 4: unknown build_system → BuildSystem.unknown ──
|
||||
string recipeContent = q"LUA
|
||||
return {
|
||||
name = "weirdpkg",
|
||||
version = "1.0",
|
||||
build_system = "bazel",
|
||||
}
|
||||
LUA";
|
||||
string tmpDir;
|
||||
() @trusted {
|
||||
import std.file : tempDir, write, mkdirRecurse;
|
||||
import std.path : buildPath;
|
||||
import std.process : thisProcessID;
|
||||
import std.conv : to;
|
||||
tmpDir = buildPath(tempDir,
|
||||
"tofu-recipeparse-t4-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(tmpDir);
|
||||
write(buildPath(tmpDir, "weirdpkg.recipe"), recipeContent);
|
||||
}();
|
||||
|
||||
auto tmp = tmpDir;
|
||||
scope (exit) {
|
||||
() @trusted {
|
||||
import std.file : rmdirRecurse, exists;
|
||||
if (exists(tmp))
|
||||
rmdirRecurse(tmp);
|
||||
}();
|
||||
}
|
||||
|
||||
import std.path : buildPath;
|
||||
auto recipe = parseRecipeFile(buildPath(tmp, "weirdpkg.recipe"));
|
||||
|
||||
assert(recipe.name == "weirdpkg");
|
||||
assert(recipe.buildSystem == BuildSystem.unknown);
|
||||
}
|
||||
|
||||
@safe unittest
|
||||
{
|
||||
// ── Test 5: minimal recipe (name only) ──
|
||||
string recipeContent = q"LUA
|
||||
return {
|
||||
name = "minimal",
|
||||
}
|
||||
LUA";
|
||||
string tmpDir;
|
||||
() @trusted {
|
||||
import std.file : tempDir, write, mkdirRecurse;
|
||||
import std.path : buildPath;
|
||||
import std.process : thisProcessID;
|
||||
import std.conv : to;
|
||||
tmpDir = buildPath(tempDir,
|
||||
"tofu-recipeparse-t5-" ~ thisProcessID.to!string);
|
||||
mkdirRecurse(tmpDir);
|
||||
write(buildPath(tmpDir, "minimal.recipe"), recipeContent);
|
||||
}();
|
||||
|
||||
auto tmp = tmpDir;
|
||||
scope (exit) {
|
||||
() @trusted {
|
||||
import std.file : rmdirRecurse, exists;
|
||||
if (exists(tmp))
|
||||
rmdirRecurse(tmp);
|
||||
}();
|
||||
}
|
||||
|
||||
import std.path : buildPath;
|
||||
auto recipe = parseRecipeFile(buildPath(tmp, "minimal.recipe"));
|
||||
|
||||
assert(recipe.name == "minimal");
|
||||
assert(recipe.ver == "");
|
||||
assert(recipe.summary == "");
|
||||
assert(recipe.url == "");
|
||||
assert(recipe.sha256 == "");
|
||||
assert(recipe.buildSystem == BuildSystem.unknown);
|
||||
assert(recipe.deps.length == 0);
|
||||
}
|
||||
Reference in New Issue
Block a user