diff --git a/.omo/evidence/task-21-tofu-core.log b/.omo/evidence/task-21-tofu-core.log new file mode 100644 index 0000000..75e2d81 --- /dev/null +++ b/.omo/evidence/task-21-tofu-core.log @@ -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 phaseerror build failed for depA: build failed for depA: +zeta-makepkg: building depA... +zeta-makepkg: configure phase +zeta-makepkg: build phase +zeta-makepkg: install phase +zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-245807/nonexistent.recipe +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +error package 'nonexistent' not found in ZUUR +error package 'ghost' not found in ZUUR +error dependency cycle: A -> B -> A +error build failed for bfp: build failed for bfp: +(no stderr output) +error build failed: bfp — build failed for bfp: +(no stderr output) +error install phase error: install failed for ifp: + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> ──── building depA (1/3) ──── + ok depA +==> ──── building depB (2/3) ──── + ok depB +==> ──── building target (3/3) ──── + ok target +==> ──── building depA (1/3) ──── + - nothing to build +==> ──── building pkg (1/1) ──── + - pkg already built, skipping +==> ──── building pkg (1/1) ──── + ok pkg +neovim + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch +neovim + version: 0.9.5 + summary: Text editor + pool: both + build system: cmake + deps: libluv>=1.30, msgpack-c + url: https://github.com/neovim/neovim +neovim + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch + installed: 0.9.5 + status: up to date +ripgrep + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 13.0.0 + status: outdated (zuur has 14.1.0) +firefox + version: 120.0 + summary: Web browser + pool: binary +firefox + version: 120.0 + summary: Web browser + pool: binary +ripgrep + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 15.0.0 + status: newer than zuur +==> generating build plan + - + happypkg (recipe) + ok build plan: 1 packages + - will build 1 package(s): happypkg +==> ──── building happypkg (1/1) ──── + ok happypkg + ok installed happypkg with 0 dependencies + ok installed 1 package(s) +==> generating build plan + - + bfp (recipe) + ok build plan: 1 packages + - will build 1 package(s): bfp +==> ──── building bfp (1/1) ──── +==> generating build plan + - + ifp (recipe) + ok build plan: 1 packages + - will build 1 package(s): ifp +==> ──── building ifp (1/1) ──── + ok ifp +==> generating build plan + - + drp (recipe) + ok build plan: 1 packages + - will build 1 package(s): drp + - dry run — nothing built +==> generating build plan + - + ncp (recipe) + ok build plan: 1 packages + - will build 1 package(s): ncp +==> ──── building ncp (1/1) ──── + ok ncp + ok installed ncp with 0 dependencies + ok installed 1 package(s) +==> generating build plan + - + abp (recipe) + ok build plan: 1 packages + - will build 1 package(s): abp +Proceed? [y/N] 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 +warn removing stale lock (PID 99999999 not alive) +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 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' + - aborted by user +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:49713//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:46903//index.lua + . fetching index http://127.0.0.1:55641//index.lua + . fetching index http://127.0.0.1:35097//index.lua + . fetching index http://127.0.0.1:60737//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:36947//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:42949//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:35765//index.lua + . fetching index http://127.0.0.1:35763//index.lua + ok index loaded: 1 packages +installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping + ok installed A with 2 dependencies + - nothing to install +install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-245807/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +21 modules passed unittests + - already installed — skipping + ok installed A with 0 dependencies + - binary libfoo-2.1 satisfies libfoo>=2.0 + - libfoo: binary 1.9 too old, building from recipe + - libfoo: binary 1.9 too old, building from recipe + - binary libbar-3.0 satisfies libbar (unconstrained) + - libbaz: no binary available, building from recipe + - binary libfoo-1.5 satisfies libfoo>=1.0 + - libfoo: binary 1.5 too old, building from recipe + - binary parentA-1.5 satisfies parentA (unconstrained) + - binary parentB-1.5 satisfies parentB (unconstrained) +==> generating build plan + - + C (recipe) + - + A (recipe) + ok build plan: 2 packages +==> generating build plan + - recipe libfoo not in cache, re-fetching + - + libfoo (recipe) + ok build plan: 1 packages +==> generating build plan +==> generating build plan + - nothing to build (all binary) +==> generating build plan + - + mypkg (recipe) + ok build plan: 1 packages +==> generating build plan + - + libfoo (recipe) + - + mypkg (recipe) + ok build plan: 2 packages + Warning + Warning ## Warning for package tofu ## + Warning + Warning The following compiler flags have been specified in the package description + Warning file. They are handled by DUB and direct use in packages is discouraged. + Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags + Warning to the compiler, or use one of the suggestions below: + Warning + Warning warningsAsErrors: Use "buildRequirements" to control the warning level + Warning + Starting Performing "debug" build using /usr/bin/dmd for x86_64. + Up-to-date toml 1.0.0: target for configuration [library] is up to date. + Up-to-date tofu ~main: target for configuration [application] is up to date. + Finished To force a rebuild of up-to-date targets, run again with --force diff --git a/.omo/notepads/tofu-core/decisions.md b/.omo/notepads/tofu-core/decisions.md index 06b4141..cb9028c 100644 --- a/.omo/notepads/tofu-core/decisions.md +++ b/.omo/notepads/tofu-core/decisions.md @@ -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. + +--- diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index be4ce06..3fa7277 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -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//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`. diff --git a/src/tofu/commands/install.d b/src/tofu/commands/install.d new file mode 100644 index 0000000..3e5f4ef --- /dev/null +++ b/src/tofu/commands/install.d @@ -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; +} diff --git a/src/tofu/recipeparse.d b/src/tofu/recipeparse.d new file mode 100644 index 0000000..c5e516b --- /dev/null +++ b/src/tofu/recipeparse.d @@ -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); +}