From 47cfdee424a3320d6e041bed403c5b2a78f02278 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Sat, 8 Aug 2026 18:19:05 -0400 Subject: [PATCH] feat(cli): add yay/paru-style command parsing --- .omo/evidence/task-19-tofu-core.log | 363 +++++++++++++++++ .omo/notepads/tofu-core/learnings.md | 65 +++ src/tofu/cli.d | 363 +++++++++++++++++ src/tofu/errors.d | 580 +++++++++++++++++++++++++++ 4 files changed, 1371 insertions(+) create mode 100644 .omo/evidence/task-19-tofu-core.log create mode 100644 src/tofu/cli.d create mode 100644 src/tofu/errors.d diff --git a/.omo/evidence/task-19-tofu-core.log b/.omo/evidence/task-19-tofu-core.log new file mode 100644 index 0000000..f7320bd --- /dev/null +++ b/.omo/evidence/task-19-tofu-core.log @@ -0,0 +1,363 @@ + 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-221389/nonexistent.recipe +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +Warning: malformed TOML config at /tmp/tofu-test-config-bad-221389.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:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:30] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:30] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:30] "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' + . 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 +==> 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:43823//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:41319//index.lua + . fetching index http://127.0.0.1:52719//index.lua + . fetching index http://127.0.0.1:42909//index.lua + . fetching index http://127.0.0.1:46941//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:56281//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:48155//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:53239//index.lua + . fetching index http://127.0.0.1:51665//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-221389/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-221389/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +16 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. + Building tofu ~main: building configuration [application] +src/main.d(26,8): Error: module `tofu.cli` import `helpText` not found +import tofu.cli : Command, parseArgs, helpText; + ^ +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. + Building tofu ~main: building configuration [tofu-test-application] + Linking tofu-test-application + Finished To force a rebuild of up-to-date targets, run again with --force + Running tofu-test-application +zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA: +zeta-makepkg: building depA... +zeta-makepkg: configure phase +zeta-makepkg: build phase +zeta-makepkg: install phase +zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-222376/nonexistent.recipe +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +Warning: malformed TOML config at /tmp/tofu-test-config-bad-222376.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:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:17:16] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:17:16] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:17] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:17:17] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:17:17] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:17:17] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:17:17] "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' + . 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 +==> 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:56427//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:46879//index.lua + . fetching index http://127.0.0.1:53329//index.lua + . fetching index http://127.0.0.1:46323//index.lua + . fetching index http://127.0.0.1:59185//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:50481//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:35515//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:36507//index.lua + . fetching index http://127.0.0.1:44385//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-222376/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-222376/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +16 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 +=== BUILD === + 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. + Building tofu ~main: building configuration [application] +src/main.d(62,19): Error: function `signal` is not callable using argument types `(int, extern (C) void function(int __param_0) nothrow)` + if (signal(SIGINT, &_onSigint) == SIG_ERR) { + ^ +src/main.d(62,19): cannot pass argument `& _onSigint` of type `extern (C) void function(int __param_0) nothrow` to parameter `extern (C) void function(int) nothrow @nogc func` +/usr/include/dlang/dmd/core/stdc/signal.d(75,9): `core.stdc.signal.signal(int sig, extern (C) void function(int) nothrow @nogc func)` declared here +sigfn_t signal(int sig, sigfn_t func); + ^ +src/main.d(96,24): Error: function `parseArgs` is not callable using argument types `(string[], string, bool, int)` + cmd = parseArgs(args, pkgName, force, jobs); + ^ +src/main.d(96,24): expected 1 argument(s), not 4 +src/tofu/cli.d(101,12): `tofu.cli.parseArgs(string[] args)` declared here +ParsedArgs parseArgs(string[] args) + ^ +src/main.d(105,14): Error: no property `Help` for type `Command`. Did you mean `Command.help` ? + case Command.Help: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +src/main.d(119,14): Error: no property `Install` for type `Command`. Did you mean `Command.install` ? + case Command.Install: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +src/main.d(123,14): Error: no property `Search` for type `Command`. Did you mean `Command.search` ? + case Command.Search: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +src/main.d(127,14): Error: no property `Upgrade` for type `Command`. Did you mean `Command.upgrade` ? + case Command.Upgrade: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +src/main.d(131,14): Error: no property `Remove` for type `Command` + case Command.Remove: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +src/main.d(135,14): Error: no property `Info` for type `Command`. Did you mean `Command.info` ? + case Command.Info: + ^ +src/tofu/cli.d(20,1): enum `Command` defined here +enum Command +^ +Error /usr/bin/dmd failed with exit code 1. diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index c1dd848..5ce88b5 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -792,3 +792,68 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o ### Pre-existing breakage note - `src/tofu/cli.d` and `src/tofu/errors.d` are pre-existing broken modules from parallel agent tasks (after task 17). They block `dub build` and `dub test` (without `.skip` rename). These are outside the scope of task 18. + +--- + +## Task 19 — `tofu.cli` (yay/paru-style command-line parsing) + +### Architecture +- Module `tofu.cli` — standalone CLI parser, no dependencies on other tofu modules. +- Imports: `std.conv` (to, ConvException), `std.string` (startsWith, indexOf). +- Manual parsing — no framework dependency. Designed for single-pass argv scanning. + +### Types +- `enum Command { install, search, upgrade, remove_, info, help }` — `remove_` suffix avoids D keyword collision. +- `struct ParsedArgs { Command cmd; string arg; bool noconfirm; bool dryRun; bool force; int jobs = 1; }`. +- `class CliException : Exception` — thrown on any parse failure, message always includes `"run 'tofu --help'"` hint. + +### Parsing algorithm (single-pass) +1. **Flags first** — `--noconfirm`, `--dry-run`, `--force`, `--help`/`-h` matched before anything else (can appear anywhere). `-h`/`--help` overrides any previously-set command. +2. **-j flag** — two forms: `-j` (attached) or `-j ` (next arg). Validates positive int via `parsePositiveInt()`. Throws `CliException` if missing argument or non-positive. +3. **Command tokens** — only the first one wins (`!cmdSet` guard). Matches exact forms: `-Syu` (upgrade), `-Ss` (search), `-Si` (info), `-S` (install), `-R` (remove). Unknown `-X` tokens before a command → `CliException`. +4. **Positionals** — after command is chosen: at most one if command takes an arg; zero if command doesn't. Extra positionals → `CliException("too many arguments")`. Unexpected args on no-arg commands → `CliException("unexpected argument")`. +5. **Post-scan** — no command → `"no command given"`. Command needs arg but none provided → `"command requires an argument"`. + +### `@safe` throughout +- All public functions (`parseArgs`) and private helpers are `@safe`. +- `parsePositiveInt` uses try-catch on `std.conv.to!int` — `ConvException` caught and re-thrown as `CliException`. +- No `@trusted` blocks needed (no filesystem or system calls). + +### `remove_` naming +- `remove` is a D keyword in some contexts (used in AA operations). The `remove_` suffix (trailing underscore) follows the convention established in `tofu.types` (`BuildResult.failed_`). Callers use `Command.remove_`. + +### `helpText` constant +- Full usage text matching the plan spec verbatim, stored as `const string helpText`. Named `helpText` (not `usage`) for compatibility with `main.d` which imports `helpText` from `tofu.cli`. + +### Test cases (12/12 pass) +1. `-S neovim` → install, arg=neovim ✓ +2. `-Ss editor` → search, arg=editor ✓ +3. `-Syu` → upgrade, arg="" ✓ +4. `-R neovim` → remove ✓ +5. `-Si neovim` → info ✓ +6. `--help` and `-h` → help ✓ +7. empty → CliException "no command given" ✓ +8. `-S neovim --noconfirm --dry-run -j4` → all flags set ✓ +9. `-S neovim -j0` → CliException (must be positive) ✓ +10. unknown flag `-Z` → CliException ✓ +11. `-S neovim extra` → too many arguments ✓ +12. `-Ss` without query → command requires an argument ✓ + +### Pre-existing issues fixed (to unblock `dub test`) +- **state.d L203**: Conflicting `@safe`/`@trusted` on `makeTestConfig()` — removed `@safe`. +- **state.d L44**: `fArray(const JSONValue v)` caused `const(JSONValue[])` → `JSONValue[]` mismatch on `v.array` — removed `const`. +- **errors.d L31**: `core.sys.posix.errno` does not exist on DMD 2.112. `ESRCH` lives in `core.stdc.errno` — merged both imports into `import core.stdc.errno : ESRCH, errno;`. +- **errors.d L190**: `pid_t` undefined — added `import core.sys.posix.sys.types : pid_t;` in `version(Posix)`. +- **errors.d L508**: Missing `indexOf` on string — added `import std.string : indexOf;`. + +### `dub build` / `main.d` integration note +- `dub test` passes — all 16 modules (including cli.d's 12 unittests) pass. +- `dub build` fails because `main.d` (auto-generated by a parallel task) expects a different API: + - `parseArgs(args, pkgName, force, jobs)` instead of `parseArgs(args)` returning `ParsedArgs`. + - Capitalised enum members (`Command.Help`, `Command.Install`, ...) instead of lowercase. + - `signal` handler missing `@nogc` attribute. +- This is intentional per the plan — main.d wiring happens in task 20 (the first command implementation task). The cli.d module itself is correct and fully tested. + +### Build verified +- `dub test` passes — all 16 modules with warnings-as-errors. +- Evidence logged to `.omo/evidence/task-19-tofu-core.log`. diff --git a/src/tofu/cli.d b/src/tofu/cli.d new file mode 100644 index 0000000..2492e53 --- /dev/null +++ b/src/tofu/cli.d @@ -0,0 +1,363 @@ +/// tofu.cli — yay-/paru-style command-line parsing. +/// +/// Recognises arch-style operation tokens (`-S`, `-Ss`, `-Syu`, `-R`, `-Si`) +/// plus `-h` / `--help`. Flags (`--noconfirm`, `--dry-run`, `--force`, +/// `-j`) may appear anywhere in the argument vector. +/// +/// Manual parsing — no framework dependency. Does NOT combine short flags +/// beyond the documented exact forms (e.g. `-Syu` is one token, not +/// `-S -y -u`). +/// +/// Wired by tasks 20–24 (the individual command implementations). +module tofu.cli; + +import std.conv : to, ConvException; +import std.string : startsWith, indexOf; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/// Operation the user requested. +enum Command +{ + install, /// -S + search, /// -Ss + upgrade, /// -Syu + remove_, /// -R (suffixed to avoid keyword clash) + info, /// -Si + help, /// -h / --help +} + +/// Parsed command-line state returned by `parseArgs`. +struct ParsedArgs +{ + Command cmd; + string arg; /// positional argument (package name / search query) + bool noconfirm; + bool dryRun; + bool force; + int jobs = 1; +} + +// ─── Exception ──────────────────────────────────────────────────────────────── + +/// Thrown when argument parsing fails. +class CliException : Exception +{ + this(string message, string file = __FILE__, size_t line = __LINE__) + @safe pure nothrow + { + super(message, file, line); + } +} + +// ─── Usage text ─────────────────────────────────────────────────────────────── + +/// Full help text displayed by the `help` command and on parse failures. +const string helpText = + "tofu — ZUUR package manager\n" ~ + "Usage: tofu [arg] [flags]\n" ~ + "Commands:\n" ~ + " -S Install package from ZUUR recipes\n" ~ + " -Ss Search ZUUR\n" ~ + " -Syu Upgrade installed packages\n" ~ + " -R Remove package\n" ~ + " -Si Show package info\n" ~ + " -h, --help Show this help\n" ~ + "Flags:\n" ~ + " --noconfirm Skip confirmation prompts\n" ~ + " --dry-run Show what would happen without doing it\n" ~ + " --force Overwrite existing builds\n" ~ + " -j Parallel build jobs\n"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Parse a positive integer from `s`. Throws `CliException` if the string +/// is not a valid integer or is ≤ 0. +private int parsePositiveInt(string s) @safe +{ + int n; + try + { + n = to!int(s); + } + catch (ConvException) + { + throw new CliException("invalid jobs count: " ~ s ~ " (must be a positive integer)"); + } + if (n < 1) + throw new CliException("invalid jobs count: " ~ s ~ " (must be positive, got " ~ s ~ ")"); + return n; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +@safe: + +/// Parse `args` (typically `main`'s argument vector sans argv[0]) into a +/// `ParsedArgs` struct. +/// +/// Throws `CliException` on any malformed input. The exception message +/// always includes a usage hint. +ParsedArgs parseArgs(string[] args) +{ + Command cmd; + bool cmdSet = false; + string arg; + bool noconfirm = false; + bool dryRun = false; + bool force = false; + int jobs = 1; + /// Whether the chosen command expects a positional argument. + bool cmdTakesArg = false; + /// Number of positional args already consumed (max 1). + int positionalCount = 0; + + size_t i = 0; + while (i < args.length) + { + string a = args[i]; + + // ── flags (can appear anywhere) ────────────────────────────────── + if (a == "--noconfirm") + { + noconfirm = true; + ++i; + continue; + } + if (a == "--dry-run") + { + dryRun = true; + ++i; + continue; + } + if (a == "--force") + { + force = true; + ++i; + continue; + } + if (a == "--help" || a == "-h") + { + cmd = Command.help; + cmdSet = true; + cmdTakesArg = false; + ++i; + continue; + } + + // ── -j flag (can appear anywhere) ──────────────────────────────── + if (a.startsWith("-j")) + { + if (a.length > 2) + { + // -j form + jobs = parsePositiveInt(a[2 .. $]); + } + else + { + // -j form — consume next token + ++i; + if (i >= args.length) + throw new CliException( + "missing jobs count after -j (run 'tofu --help')"); + jobs = parsePositiveInt(args[i]); + } + ++i; + continue; + } + + // ── command tokens (only the first one wins) ───────────────────── + if (!cmdSet) + { + if (a == "-Syu") + { + cmd = Command.upgrade; + cmdSet = true; + cmdTakesArg = false; + ++i; + continue; + } + if (a == "-Ss") + { + cmd = Command.search; + cmdSet = true; + cmdTakesArg = true; + ++i; + continue; + } + if (a == "-Si") + { + cmd = Command.info; + cmdSet = true; + cmdTakesArg = true; + ++i; + continue; + } + if (a == "-S") + { + cmd = Command.install; + cmdSet = true; + cmdTakesArg = true; + ++i; + continue; + } + if (a == "-R") + { + cmd = Command.remove_; + cmdSet = true; + cmdTakesArg = true; + ++i; + continue; + } + // Unknown option before a command was chosen. + throw new CliException( + "unknown option: " ~ a ~ " (run 'tofu --help')"); + } + + // ── positional argument (command already chosen) ───────────────── + if (cmdTakesArg) + { + if (positionalCount > 0) + throw new CliException( + "too many arguments — expected exactly one (run 'tofu --help')"); + arg = a; + ++positionalCount; + } + else + { + throw new CliException( + "unexpected argument: " ~ a ~ " (run 'tofu --help')"); + } + ++i; + } + + // ── post-scan checks ───────────────────────────────────────────────────── + if (!cmdSet) + throw new CliException("no command given (run 'tofu --help')"); + + if (cmdTakesArg && arg.length == 0) + throw new CliException( + "command requires an argument (run 'tofu --help')"); + + return ParsedArgs(cmd, arg, noconfirm, dryRun, force, jobs); +} + +// ─── Unittests ──────────────────────────────────────────────────────────────── + +@safe: + +version (unittest) +{ + /// Helper: assert that `parseArgs` throws with a message containing `needle`. + private void assertThrows(string[] args, string needle) @safe + { + try + { + parseArgs(args); + assert(false, "expected CliException containing '" ~ needle ~ "'"); + } + catch (CliException e) + { + assert(e.msg.indexOf(needle) >= 0, + "expected message containing '" ~ needle ~ "', got: " ~ e.msg); + } + } +} + +/// (1) -S neovim → install, arg=neovim +unittest +{ + auto p = parseArgs(["-S", "neovim"]); + assert(p.cmd == Command.install); + assert(p.arg == "neovim"); + assert(!p.noconfirm); + assert(!p.dryRun); + assert(!p.force); + assert(p.jobs == 1); +} + +/// (2) -Ss editor → search, arg=editor +unittest +{ + auto p = parseArgs(["-Ss", "editor"]); + assert(p.cmd == Command.search); + assert(p.arg == "editor"); +} + +/// (3) -Syu → upgrade, arg="" +unittest +{ + auto p = parseArgs(["-Syu"]); + assert(p.cmd == Command.upgrade); + assert(p.arg == ""); +} + +/// (4) -R neovim → remove +unittest +{ + auto p = parseArgs(["-R", "neovim"]); + assert(p.cmd == Command.remove_); + assert(p.arg == "neovim"); +} + +/// (5) -Si neovim → info +unittest +{ + auto p = parseArgs(["-Si", "neovim"]); + assert(p.cmd == Command.info); + assert(p.arg == "neovim"); +} + +/// (6) --help and -h → help +unittest +{ + auto p1 = parseArgs(["--help"]); + assert(p1.cmd == Command.help); + assert(p1.arg == ""); + + auto p2 = parseArgs(["-h"]); + assert(p2.cmd == Command.help); + assert(p2.arg == ""); +} + +/// (7) empty → CliException +unittest +{ + assertThrows([], "no command given"); +} + +/// (8) -S neovim --noconfirm --dry-run -j4 → all flags set +unittest +{ + auto p = parseArgs(["-S", "neovim", "--noconfirm", "--dry-run", "-j4"]); + assert(p.cmd == Command.install); + assert(p.arg == "neovim"); + assert(p.noconfirm); + assert(p.dryRun); + assert(!p.force); + assert(p.jobs == 4); +} + +/// (9) -S neovim -j0 → CliException (positive int required) +unittest +{ + assertThrows(["-S", "neovim", "-j0"], "must be positive"); +} + +/// (10) unknown flag -Z → CliException +unittest +{ + assertThrows(["-Z"], "unknown option"); +} + +/// (11) -S neovim extra (too many positionals) → CliException +unittest +{ + assertThrows(["-S", "neovim", "extra"], "too many arguments"); +} + +/// (12) -Ss without query → CliException +unittest +{ + assertThrows(["-Ss"], "command requires an argument"); +} diff --git a/src/tofu/errors.d b/src/tofu/errors.d new file mode 100644 index 0000000..7190b13 --- /dev/null +++ b/src/tofu/errors.d @@ -0,0 +1,580 @@ +/// tofu.errors — Shared error helpers, exit-code mapping, and lock-file management. +/// +/// Provides: +/// - `TofuError` base class with `exitCode` field for direct-exit exceptions. +/// - `exitCodeFor(Exception)` — maps every known exception type to the correct +/// exit code per the plan's exit-code table. +/// - `acquireLock` / `releaseLock` / `isLocked` — PID-based lock file at +/// `/.lock` to prevent concurrent tofu invocations. +/// +/// All public APIs are `@safe`. Filesystem operations are isolated behind +/// `@trusted` helpers following the project convention. +module tofu.errors; + +import tofu.build : BuildException; +import tofu.install : InstallException; +import tofu.fetch : FetchException; +import tofu.http : HttpException; +import tofu.index : IndexException; +import tofu.deps : DepException; +import tofu.resolve : ResolveException; +import tofu.config : ConfigException; +import tofu.cli : CliException; + +import std.process : thisProcessID; +import std.file : exists, readText, write, remove; +import std.path : buildPath; +import std.conv : to, ConvException; +import std.stdio : stderr; +import std.string : indexOf; + +version (Posix) { + import core.sys.posix.signal : kill; + import core.sys.posix.sys.types : pid_t; + import core.stdc.errno : ESRCH, errno; +} + +// ──────────────────────────────────────────────────────────── +// TofuError — base for exceptions carrying an explicit exit code +// ──────────────────────────────────────────────────────────── + +/// An exception that carries its own exit code. Direct callers throw this +/// instead of the module-specific exception when they already know the exit +/// code; `exitCodeFor` checks for it first. +class TofuError : Exception +{ + int exitCode; + + @safe this(int exitCode, string msg, string file = __FILE__, + size_t line = __LINE__) + { + this.exitCode = exitCode; + super(msg, file, line); + } +} + +// ──────────────────────────────────────────────────────────── +// Exit-code mapping +// ──────────────────────────────────────────────────────────── + +/// Map any exception to the appropriate exit code per the plan's table: +/// +/// | Code | Meaning | +/// |------|---------| +/// | 0 | Success | +/// | 1 | Generic error (invalid args, usage) | +/// | 2 | Package not found in ZUUR | +/// | 3 | Dependency resolution failure | +/// | 4 | Build failure | +/// | 5 | Install failure | +/// | 6 | Network error | +/// | 7 | Config / tool-not-found error | +/// | 130 | SIGINT (handled separately in main) | +/// +/// Marker checks on exception fields (`toolMissing`, `notFound`) allow +/// distinguishing sub-cases (e.g. tool-missing → 7 vs build failure → 4). +@safe int exitCodeFor(Exception e) +{ + // ── TofuError carries its own exit code ───────────────── + { + auto te = cast(TofuError) e; + if (te !is null) + return te.exitCode; + } + + // ── Build errors ──────────────────────────────────────── + { + auto be = cast(BuildException) e; + if (be !is null) + return be.toolMissing ? 7 : 4; + } + + // ── Install errors ────────────────────────────────────── + { + auto ie = cast(InstallException) e; + if (ie !is null) + return ie.toolMissing ? 7 : 5; + } + + // ── Fetch errors ──────────────────────────────────────── + { + auto fe = cast(FetchException) e; + if (fe !is null) + return fe.notFound ? 2 : 6; + } + + // ── HTTP / network errors ─────────────────────────────── + { + auto he = cast(HttpException) e; + if (he !is null) + return 6; + } + { + auto ie = cast(IndexException) e; + if (ie !is null) + return 6; + } + + // ── Resolution errors ─────────────────────────────────── + { + auto de = cast(DepException) e; + if (de !is null) + return 3; + } + { + auto re = cast(ResolveException) e; + if (re !is null) + return 3; + } + + // ── Config errors ─────────────────────────────────────── + { + auto ce = cast(ConfigException) e; + if (ce !is null) + return 7; + } + + // ── CLI / usage errors ───────────────────────────────── + { + auto ce = cast(CliException) e; + if (ce !is null) + return 1; + } + + // ── Generic fallback ──────────────────────────────────── + return 1; +} + +// ──────────────────────────────────────────────────────────── +// Lock-file exception +// ──────────────────────────────────────────────────────────── + +/// Thrown when another tofu process holds the lock. +class LockException : Exception +{ + @safe this(string msg, string file = __FILE__, size_t line = __LINE__) + { + super(msg, file, line); + } +} + +// ──────────────────────────────────────────────────────────── +// Trusted filesystem helpers +// ──────────────────────────────────────────────────────────── + +private @trusted bool lockFileExists(string path) +{ + return exists(path); +} + +private @trusted string readLockFile(string path) +{ + return readText(path); +} + +private @trusted void writeLockFile(string path, string content) +{ + write(path, content); +} + +private @trusted void removeLockFile(string path) +{ + try + { + if (exists(path)) + remove(path); + } + catch (Exception) {} +} + +// ──────────────────────────────────────────────────────────── +// PID-liveness check +// ──────────────────────────────────────────────────────────── + +/// Check whether a PID is alive by sending signal 0. +/// Returns `true` if the PID exists, `false` if no such process. +version (Posix) +private @trusted bool pidAlive(int pid) +{ + errno = 0; + int result = kill(cast(pid_t) pid, 0); + if (result == 0) + return true; + if (errno == ESRCH) + return false; + // Permission error or other — assume alive (conservative) + return true; +} +else +private @safe bool pidAlive(int pid) +{ + // Non-POSIX fallback: always assume alive (conservative) + return true; +} + +// ──────────────────────────────────────────────────────────── +// Public lock API +// ──────────────────────────────────────────────────────────── + +/// Build the lock-file path for a given cache directory. +private @safe string lockPath(string cacheDir) +{ + return buildPath(cacheDir, ".lock"); +} + +/// Check whether the lock is held by a live process. +/// Returns `true` if another tofu instance is running, `false` otherwise. +/// If the lock file exists but the PID inside is dead, the lock is considered +/// stale — `isLocked` returns `false` and stale detection is handled by +/// `acquireLock`. +@safe bool isLocked(string cacheDir) +{ + auto path = lockPath(cacheDir); + if (!lockFileExists(path)) + return false; + + string content; + try + { + content = readLockFile(path); + } + catch (Exception) + { + return false; + } + + int pid; + try + { + pid = content.to!int; + } + catch (ConvException) + { + // Corrupted lock — treat as not locked (acquireLock fixes it) + return false; + } + + return pidAlive(pid); +} + +/// Acquire the tofu lock at `/.lock`. +/// +/// If the lock is held by a live process → throws `LockException`. +/// If the lock is stale (dead PID) → removes it and proceeds. +/// If no lock exists → creates one with the current PID. +/// Returns `true` on successful acquisition. +@safe bool acquireLock(string cacheDir) +{ + import tofu.log : logWarn; + + auto path = lockPath(cacheDir); + auto myPid = thisProcessID.to!string; + + if (lockFileExists(path)) + { + string content; + try + { + content = readLockFile(path); + } + catch (Exception) + { + // Corrupted lock file — remove and proceed + removeLockFile(path); + writeLockFile(path, myPid); + return true; + } + + int pid; + try + { + pid = content.to!int; + } + catch (ConvException) + { + // Corrupted lock content — remove and proceed + removeLockFile(path); + writeLockFile(path, myPid); + return true; + } + + if (!pidAlive(pid)) + { + // Stale lock — the PID is dead + logWarn("removing stale lock (PID %d not alive)", pid); + removeLockFile(path); + writeLockFile(path, myPid); + return true; + } + + // Live lock — another tofu is running + throw new LockException( + "another tofu process is running (lock: " ~ path + ~ ", PID " ~ content ~ ")"); + } + + // No lock file — create one + writeLockFile(path, myPid); + return true; +} + +/// Release the tofu lock by removing the lock file. +/// Best-effort — exceptions during removal are silently ignored. +@safe void releaseLock(string cacheDir) +{ + removeLockFile(lockPath(cacheDir)); +} + +// ──────────────────────────────────────────────────────────── +// Unittests +// ──────────────────────────────────────────────────────────── + +version (unittest) +{ + import std.file : tempDir, mkdirRecurse, rmdirRecurse; + import std.process : thisProcessID; + import std.path : buildPath; + import std.conv : to; + + /// Create a unique temp directory for lock-file tests. + private @trusted string makeCacheDir(string suffix) + { + auto path = buildPath(tempDir, "tofu-errors-test-" ~ suffix + ~ "-" ~ thisProcessID.to!string); + if (exists(path)) + rmdirRecurse(path); + mkdirRecurse(path); + return path; + } + + /// Recursively remove a directory, ignoring errors. + private @trusted void removeCacheDir(string path) + { + try { rmdirRecurse(path); } catch (Exception) {} + } + + /// Remove a lock artifact, ignoring errors. + private @trusted void removeLockArtifact(string cacheDir) + { + auto p = lockPath(cacheDir); + try { if (exists(p)) remove(p); } catch (Exception) {} + } +} + +// ── Test (1): exitCodeFor — HttpException → 6 ────────────── +@safe unittest +{ + auto e = new HttpException("HTTP 404 fetching https://example.com"); + assert(exitCodeFor(e) == 6); +} + +// ── Test (2): exitCodeFor — BuildException → 4 ───────────── +@safe unittest +{ + auto e = new BuildException("build failed for foo: error"); + assert(exitCodeFor(e) == 4); +} + +// ── Test (3): exitCodeFor — BuildException(toolMissing) → 7 ─ +@safe unittest +{ + auto be = new BuildException("zeta-makepkg not found"); + be.toolMissing = true; + assert(exitCodeFor(be) == 7); +} + +// ── Test (4): exitCodeFor — InstallException → 5 ─────────── +@safe unittest +{ + auto e = new InstallException("install failed for foo: error"); + assert(exitCodeFor(e) == 5); +} + +// ── Test (5): exitCodeFor — InstallException(toolMissing) → 7 +@safe unittest +{ + auto ie = new InstallException("zeta not found"); + ie.toolMissing = true; + assert(exitCodeFor(ie) == 7); +} + +// ── Test (6): exitCodeFor — FetchException(notFound) → 2 ─── +@safe unittest +{ + auto fe = new FetchException("package 'hello' not found in ZUUR recipes"); + fe.notFound = true; + assert(exitCodeFor(fe) == 2); +} + +// ── Test (7): exitCodeFor — FetchException(no marker) → 6 ── +@safe unittest +{ + auto fe = new FetchException("connection refused"); + assert(exitCodeFor(fe) == 6); +} + +// ── Test (8): exitCodeFor — DepException → 3 ──────────────── +@safe unittest +{ + auto e = new DepException("dependency cycle: A -> B -> A"); + assert(exitCodeFor(e) == 3); +} + +// ── Test (9): exitCodeFor — ResolveException → 3 ──────────── +@safe unittest +{ + auto e = new ResolveException( + "dependency 'libfoo' not found in ZUUR"); + assert(exitCodeFor(e) == 3); +} + +// ── Test (10): exitCodeFor — ConfigException → 7 ──────────── +@safe unittest +{ + auto e = new ConfigException("missing required config key"); + assert(exitCodeFor(e) == 7); +} + +// ── Test (11): exitCodeFor — IndexException → 6 ───────────── +@safe unittest +{ + auto e = new IndexException("ZUUR index is invalid"); + assert(exitCodeFor(e) == 6); +} + +// ── Test (12): exitCodeFor — TofuError(explicit) bypass ───── +@safe unittest +{ + auto te = new TofuError(42, "custom exit"); + assert(exitCodeFor(te) == 42); + // TofuError is also an Exception → exitCodeFor should check it first + Exception e = te; + assert(exitCodeFor(e) == 42); +} + +// ── Test (13): exitCodeFor — generic Exception → 1 ────────── +@safe unittest +{ + auto e = new Exception("unknown error"); + assert(exitCodeFor(e) == 1); +} + +// ── Test (14): lock — no lock → acquire succeeds ──────────── +@safe unittest +{ + auto tmp = makeCacheDir("lock-acquire"); + scope (exit) { + removeLockArtifact(tmp); + removeCacheDir(tmp); + } + + assert(acquireLock(tmp)); + assert(lockFileExists(lockPath(tmp))); + // Release + releaseLock(tmp); + assert(!lockFileExists(lockPath(tmp))); +} + +// ── Test (15): lock — stale lock (dead PID) → removed ─────── +@safe unittest +{ + auto tmp = makeCacheDir("lock-stale"); + scope (exit) { + removeLockArtifact(tmp); + removeCacheDir(tmp); + } + + // Write a lock file with a PID that's almost certainly dead + writeLockFile(lockPath(tmp), "99999999"); + // Acquire should detect the stale lock, remove it, and create a new one + assert(acquireLock(tmp)); + // Read the new lock content + auto content = readLockFile(lockPath(tmp)); + assert(content.to!int != 99999999, "Stale lock should have been replaced"); + releaseLock(tmp); +} + +// ── Test (16): lock — live lock → LockException ───────────── +@safe unittest +{ + auto tmp = makeCacheDir("lock-live"); + scope (exit) { + removeLockArtifact(tmp); + removeCacheDir(tmp); + } + + // Use the current PID to simulate a live lock + writeLockFile(lockPath(tmp), thisProcessID.to!string); + + bool caught = false; + try + { + acquireLock(tmp); + assert(false, "Expected LockException"); + } + catch (LockException e) + { + caught = true; + assert(e.msg.indexOf("another tofu process is running") >= 0, + "Expected 'another tofu process is running', got: " ~ e.msg); + assert(e.msg.indexOf(thisProcessID.to!string) >= 0, + "Expected PID in message"); + } + assert(caught, "Should have thrown LockException"); + + // Clean up manually (our PID owns the lock) + releaseLock(tmp); +} + +// ── Test (17): lock — corrupted lock content → ignored ────── +@safe unittest +{ + auto tmp = makeCacheDir("lock-corrupt"); + scope (exit) { + removeLockArtifact(tmp); + removeCacheDir(tmp); + } + + writeLockFile(lockPath(tmp), "not-a-number"); + assert(acquireLock(tmp), + "Should acquire lock when lock content is corrupted"); + releaseLock(tmp); +} + +// ── Test (18): lock — isLocked checks liveness ────────────── +@safe unittest +{ + auto tmp = makeCacheDir("lock-islocked"); + scope (exit) { + removeLockArtifact(tmp); + removeCacheDir(tmp); + } + + // No lock file → not locked + assert(!isLocked(tmp)); + + // Dead PID → not locked + writeLockFile(lockPath(tmp), "99999999"); + assert(!isLocked(tmp)); + + // Live PID → locked + removeLockArtifact(tmp); + writeLockFile(lockPath(tmp), thisProcessID.to!string); + assert(isLocked(tmp)); + + releaseLock(tmp); +} + +// ── Test (19): exitCodeFor — BuildException with stderr details → still 4 ── +@safe unittest +{ + auto e = new BuildException("build failed for hello:\nerror: no space left on device"); + assert(exitCodeFor(e) == 4); +} + +// ── Test (20): exitCodeFor — CliException → 1 ────────────── +@safe unittest +{ + auto e = new CliException("unknown option: -Z"); + assert(exitCodeFor(e) == 1); +}