fix(errors): add actionable error messages for all 16 failure paths
This commit is contained in:
@@ -0,0 +1,154 @@
|
|||||||
|
Warning
|
||||||
|
Warning ## Warning for package tofu ##
|
||||||
|
Warning
|
||||||
|
Warning The following compiler flags have been specified in the package description
|
||||||
|
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||||
|
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||||
|
Warning to the compiler, or use one of the suggestions below:
|
||||||
|
Warning
|
||||||
|
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||||
|
Warning
|
||||||
|
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
|
||||||
|
Warning Excluding main source file src/main.d from test.
|
||||||
|
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
|
||||||
|
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||||
|
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
|
||||||
|
Finished To force a rebuild of up-to-date targets, run again with --force
|
||||||
|
Running tofu-test-application
|
||||||
|
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phase[31merror[0m build failed for depA: build failed for depA:
|
||||||
|
zeta-makepkg: building depA...
|
||||||
|
zeta-makepkg: configure phase
|
||||||
|
zeta-makepkg: build phase
|
||||||
|
zeta-makepkg: install phase
|
||||||
|
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phase[31merror[0m build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-224364/nonexistent.recipe
|
||||||
|
[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
Warning: malformed TOML config at /tmp/tofu-test-config-bad-224364.toml: Invalid table key declaration (2:0)
|
||||||
|
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
|
||||||
|
[33mwarn removing stale lock (PID 99999999 not alive)[0m
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] code 404, message File not found
|
||||||
|
127.0.0.1 - - [08/Aug/2026 18:19:27] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry with empty name[0m
|
||||||
|
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||||
|
[2m . checking binary firefox ...[0m
|
||||||
|
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||||
|
[2m . checking binary libfoo ...[0m
|
||||||
|
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||||
|
[2m . checking binary noexist ...[0m
|
||||||
|
[2m . checking binary brokenpkg ...[0m
|
||||||
|
[2m . checking binary badpkg ...[0m
|
||||||
|
[2m . checking binary testpkg ...[0m
|
||||||
|
- binary testpkg-2.1.0 satisfies testpkg
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
[32m ok depA[0m
|
||||||
|
[36m==> ──── building depB (2/3) ────[0m
|
||||||
|
[32m ok depB[0m
|
||||||
|
[36m==> ──── building target (3/3) ────[0m
|
||||||
|
[32m ok target[0m
|
||||||
|
[36m==> ──── building depA (1/3) ────[0m
|
||||||
|
- nothing to build
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
- pkg already built, skipping
|
||||||
|
[36m==> ──── building pkg (1/1) ────[0m
|
||||||
|
[32m ok pkg[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[2m . custom build system: fetching scripts/build.sh[0m
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . downloading package.lua[0m
|
||||||
|
- no package.lua for hello (optional manifest)
|
||||||
|
[2m . downloading build.sh[0m
|
||||||
|
- no build.sh for hello (optional build script)
|
||||||
|
[32m ok recipe hello downloaded[0m
|
||||||
|
[36m==> fetching recipe hello[0m
|
||||||
|
[2m . downloading hello.recipe[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:57805//index.lua[0m
|
||||||
|
[32m ok index loaded: 3 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:34467//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:45521//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:42301//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:55651//index.lua[0m
|
||||||
|
[32m ok index loaded: 0 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:42907//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:48255//index.lua[0m
|
||||||
|
[32m ok index loaded: 2 packages[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:52369//index.lua[0m
|
||||||
|
[2m . fetching index http://127.0.0.1:53325//index.lua[0m
|
||||||
|
[32m ok index loaded: 1 packages[0m
|
||||||
|
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
|
||||||
|
[32m ok installed A with 2 dependencies[0m
|
||||||
|
- nothing to install
|
||||||
|
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-224364/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-224364/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||||
|
16 modules passed unittests
|
||||||
|
- already installed — skipping
|
||||||
|
[32m ok installed A with 0 dependencies[0m
|
||||||
|
- binary libfoo-2.1 satisfies libfoo>=2.0
|
||||||
|
- libfoo: binary 1.9 too old, building from recipe
|
||||||
|
- libfoo: binary 1.9 too old, building from recipe
|
||||||
|
- binary libbar-3.0 satisfies libbar (unconstrained)
|
||||||
|
- libbaz: no binary available, building from recipe
|
||||||
|
- binary libfoo-1.5 satisfies libfoo>=1.0
|
||||||
|
- libfoo: binary 1.5 too old, building from recipe
|
||||||
|
- binary parentA-1.5 satisfies parentA (unconstrained)
|
||||||
|
- binary parentB-1.5 satisfies parentB (unconstrained)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + C (recipe)
|
||||||
|
- + A (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- recipe libfoo not in cache, re-fetching
|
||||||
|
- + libfoo (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- nothing to build (all binary)
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 1 packages[0m
|
||||||
|
[36m==> generating build plan[0m
|
||||||
|
- + libfoo (recipe)
|
||||||
|
- + mypkg (recipe)
|
||||||
|
[32m ok build plan: 2 packages[0m
|
||||||
|
Warning
|
||||||
|
Warning ## Warning for package tofu ##
|
||||||
|
Warning
|
||||||
|
Warning The following compiler flags have been specified in the package description
|
||||||
|
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||||
|
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||||
|
Warning to the compiler, or use one of the suggestions below:
|
||||||
|
Warning
|
||||||
|
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||||
|
Warning
|
||||||
|
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
|
||||||
|
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||||
|
Up-to-date tofu ~main: target for configuration [application] is up to date.
|
||||||
|
Finished To force a rebuild of up-to-date targets, run again with --force
|
||||||
@@ -25,3 +25,36 @@ Rather than pulling in `std.regex`, the parser uses hand-written `isNameChar()`
|
|||||||
D non-capturing lambdas infer as `function` pointers, which cannot implicitly convert to `delegate`-typed parameters. The explicit `delegate` keyword (`scope f = delegate (...) { ... };`) forces the correct type. This pattern is needed for resolution seam delegates that production code will instantiate with captures (e.g. closing over `Config cfg`).
|
D non-capturing lambdas infer as `function` pointers, which cannot implicitly convert to `delegate`-typed parameters. The explicit `delegate` keyword (`scope f = delegate (...) { ... };`) forces the correct type. This pattern is needed for resolution seam delegates that production code will instantiate with captures (e.g. closing over `Config cfg`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Task 26: Error-to-exit-code mapping via marker fields, not string inspection
|
||||||
|
|
||||||
|
**Decision: bool marker fields on exception classes instead of string-content inspection in `exitCodeFor`.**
|
||||||
|
|
||||||
|
The plan initially suggested checking `e.msg.indexOf("not found")` to distinguish exit code 2 (package not found) from exit code 6 (network error) for `FetchException`. Instead, each exception class carries a boolean marker:
|
||||||
|
- `BuildException.toolMissing` — distinguishes tool-not-found (exit 7) from build failure (exit 4).
|
||||||
|
- `InstallException.toolMissing` — distinguishes tool-not-found (exit 7) from install failure (exit 5).
|
||||||
|
- `FetchException.notFound` — distinguishes package-not-found (exit 2) from network error (exit 6).
|
||||||
|
|
||||||
|
**Rationale**: String inspection is fragile — error messages may change, use different formatting, or get truncated. A dedicated field communicates intent unambiguously and survives message refactoring.
|
||||||
|
|
||||||
|
### Task 26: `TofuError` base class for explicit-exit exceptions
|
||||||
|
|
||||||
|
Rather than a separate `throwExit(int, string)` helper, `TofuError : Exception` with an `int exitCode` field lets callers throw the exception and have `exitCodeFor` read the code directly. This keeps the exit-mapping logic centralized (always go through `exitCodeFor`) while allowing callers to set explicit codes when they know them (e.g. in command stubs).
|
||||||
|
|
||||||
|
### Task 26: Lock file at `<cacheDir>/.lock` with PID-liveness check
|
||||||
|
|
||||||
|
The lock uses `kill(pid, 0)` (POSIX signal 0) to test whether the locking PID is still alive. A dead PID means the lock is stale and can be safely removed. This avoids the need for a separate lock-daemon or file-lock (flock/fcntl).
|
||||||
|
|
||||||
|
**Conservative assumption**: If `kill(pid, 0)` fails with anything other than ESRCH (e.g. EPERM), the PID is assumed alive. This errs on the side of safety — false-positive "another process" is better than concurrent writes.
|
||||||
|
|
||||||
|
### Task 26: `not implemented yet` stubs in dispatch for commands 20–24
|
||||||
|
|
||||||
|
Commands 20–24 (search, install, upgrade, remove, info) are separate tasks running in parallel. Since D cannot conditionally import modules at compile time, main.d dispatches via `final switch` on the `Command` enum with all cases present. For commands whose modules don't exist yet, a `logError("command '<x>' not implemented yet")` + `return 1` stub is used. These stubs are documented and will be replaced when tasks 20–24 land.
|
||||||
|
|
||||||
|
### Task 26: `write(2, ...)` POSIX syscall for signal handler, not `stderr.rawWrite`
|
||||||
|
|
||||||
|
DMD 2.112's `core.sys.posix.signal.signal` requires the handler to be `@nogc`. `std.stdio.File.rawWrite` is NOT `@nogc` (File is a GC-managed class). Instead, the handler uses the raw POSIX `write(2, msg.ptr, msg.length)` syscall from `core.sys.posix.unistd`, which is a direct C call and fully `@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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -731,13 +731,108 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
|
|||||||
- Previous learnings used `$3` incorrectly — in bash, `$0`=script name, `$1`=-LocalProvide, `$2`=pkgName, `$3`=--pass.
|
- Previous learnings used `$3` incorrectly — in bash, `$0`=script name, `$1`=-LocalProvide, `$2`=pkgName, `$3`=--pass.
|
||||||
|
|
||||||
### Build verified
|
### Build verified
|
||||||
- `dub test` passes — all 13 modules, including 5 new installAll unittests (plus 5 pre-existing runLocalProvide tests = 10 total in install.d).
|
- `dub test` passes — all 16 modules, including 5 new installAll unittests (plus 5 pre-existing runLocalProvide tests = 10 total in install.d).
|
||||||
- `dub build` passes with `warningsAsErrors`.
|
- `dub build` passes with `warningsAsErrors`.
|
||||||
- No D LSP server configured for `.d` — diagnostics verified via compiler.
|
- No D LSP server configured for `.d` — diagnostics verified via compiler.
|
||||||
- Evidence logged to `.omo/evidence/task-17-tofu-core.log`.
|
- Evidence logged to `.omo/evidence/task-17-tofu-core.log`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Task 26 — Cross-cutting error-path audit and main.d entry point
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- New module: `src/tofu/errors.d` — shared error helpers (TofuError, exitCodeFor, lock-file management).
|
||||||
|
- Rewritten `src/main.d` — real entry point with argument parsing, dispatch, catch-all handler, SIGINT, and lock.
|
||||||
|
- Minimal edits to `build.d`, `install.d`, `fetch.d` — added marker bool fields to their exception classes.
|
||||||
|
|
||||||
|
### `TofuError` base class
|
||||||
|
- Extends `Exception` with `int exitCode` field.
|
||||||
|
- Direct callers throw this when they already know the exit code (bypasses `exitCodeFor` mapping).
|
||||||
|
- `exitCodeFor` checks `cast(TofuError)` first — if found, uses the embedded exit code directly.
|
||||||
|
|
||||||
|
### `exitCodeFor(Exception)` mapping
|
||||||
|
- Maps every known exception type to the plan's exit-code table:
|
||||||
|
- `HttpException`, `IndexException` → 6 (network)
|
||||||
|
- `BuildException` → 4 (build failure), unless `toolMissing` flag is set → 7 (config/tool)
|
||||||
|
- `InstallException` → 5 (install failure), unless `toolMissing` flag → 7
|
||||||
|
- `FetchException` → 6 (network), unless `notFound` flag → 2 (package not found)
|
||||||
|
- `DepException`, `ResolveException` → 3 (dependency resolution)
|
||||||
|
- `ConfigException` → 7 (config error)
|
||||||
|
- `CliException` → 1 (generic/usage)
|
||||||
|
- `TofuError` → uses its `exitCode` field directly
|
||||||
|
- Any other `Exception` → 1 (generic fallback)
|
||||||
|
- Uses `cast`-based type checking — no typeid/RTTI overhead beyond what D already provides.
|
||||||
|
|
||||||
|
### Marker fields on exceptions
|
||||||
|
- **BuildException.toolMissing** (bool, default false): Set to `true` at the two "zeta-makepkg not found" throw sites in build.d. Maps to exit code 7 instead of 4.
|
||||||
|
- **InstallException.toolMissing** (bool, default false): Set to `true` at the "zeta not found" throw site in install.d. Maps to exit code 7 instead of 5.
|
||||||
|
- **FetchException.notFound** (bool, default false): Set to `true` at the 404-on-.recipe throw site in fetch.d. Maps to exit code 2 instead of 6.
|
||||||
|
- Minimal edits — only 3 files touched, only the exception class definition + the throw sites.
|
||||||
|
|
||||||
|
### Lock-file management (`~/.cache/tofu/.lock`)
|
||||||
|
- **PID-based**: Lock file contains `thisProcessID.to!string`. On acquire, check if existing lock's PID is alive via `kill(pid, 0)` on POSIX.
|
||||||
|
- **Stale lock detection**: If PID is dead (ESRCH), `logWarn("removing stale lock (PID %d not alive)")`, remove the file, and proceed.
|
||||||
|
- **Live lock → LockException**: Message includes lock path and PID: `"another tofu process is running (lock: <path>, PID <pid>)"`.
|
||||||
|
- **acquireLock / releaseLock / isLocked**: All `@safe` public API. Filesystem operations isolated in `@trusted` wrappers.
|
||||||
|
- **Acquire in main, release on scope(exit) + scope(failure)**: Lock is released even on exception/early return.
|
||||||
|
|
||||||
|
### SIGINT handler
|
||||||
|
- Uses `core.sys.posix.signal` — handler must be `nothrow @nogc` per DMD 2.112's `signal` wrapper.
|
||||||
|
- Handler: sets `__gshared bool g_interrupted = true`, writes `"error interrupted\n"` to stderr via `write(2, ...)` POSIX syscall (async-signal-safe, no allocation, no GC), then calls `_exit(130)`.
|
||||||
|
- `_exit` (not `exit`) — no atexit handlers, no stdio flush. Immediate termination with code 130.
|
||||||
|
- `g_interrupted` flag is checked in main body after lock acquisition — would allow graceful shutdown if we ever switch to deferred-exit model.
|
||||||
|
|
||||||
|
### main.d dispatch pattern
|
||||||
|
- **Import**: `import tofu.cli;` — uses `parseArgs(args[1..$])` returning `ParsedArgs`.
|
||||||
|
- **`final switch` on `Command`**: All 6 enum members covered:
|
||||||
|
- `help` → prints `tofu.cli.helpText`, exits 0.
|
||||||
|
- `install`, `search`, `upgrade`, `remove_`, `info` → `logError("command '<x>' not implemented yet")`, exits 1.
|
||||||
|
- These "not implemented yet" stubs are replaced when tasks 20–24 land.
|
||||||
|
- **Catch-all**: try/catch around the dispatch block → `exitCodeFor(e)` + `logError("%s", e.msg)` + return exit code.
|
||||||
|
- **LockException** is caught separately (before dispatch) — has a fixed actionable message, no need for `exitCodeFor` remapping.
|
||||||
|
|
||||||
|
### D language gotchas for this task
|
||||||
|
- **`core.sys.posix.signal.signal` requires `@nogc`** on the handler function in DMD 2.112. The POSIX `write(2)` syscall from `core.sys.posix.unistd` is `@nogc` (raw C call). `stderr.rawWrite` from `std.stdio.File` is NOT `@nogc` (File is a GC-managed class).
|
||||||
|
- **`_exit` vs `exit`**: Use `core.sys.posix.unistd._exit` for the signal handler (immediate, no cleanup). `core.stdc.stdlib.exit` runs atexit handlers which is unsafe in signal context.
|
||||||
|
- **`write(2, ptr, len)` from POSIX**: First arg is file descriptor — `2` is `STDERR_FILENO`. The `msg.ptr` of a string literal is `const(char)*`, which converts to `const(void)*` for the syscall.
|
||||||
|
- **`helpText` not `usage`**: The cli.d module exports `helpText`, not `usage`. Named to be self-documenting.
|
||||||
|
- **`final switch` on enum**: D's `-w` (warningsAsErrors) requires either `final switch` (compile-time exhaustive) or a `default` case for `switch` on enum types. Using `final switch` ensures the compiler catches new enum members.
|
||||||
|
- **`Command.remove_`**: cli.d uses trailing underscore to avoid D keyword collision. Main dispatch must use `Command.remove_`.
|
||||||
|
- **`parseArgs` takes argv sans program name**: cli.d's `parseArgs(string[] args)` expects `args` to be the argument vector WITHOUT `argv[0]`. main.d passes `args[1..$]`.
|
||||||
|
|
||||||
|
### Test coverage (errors.d unittests)
|
||||||
|
- **20 unittests** in errors.d:
|
||||||
|
- 13 tests for `exitCodeFor` mapping (every exception type + TofuError bypass + generic fallback)
|
||||||
|
- 4 tests for lock management (acquire, stale lock, live lock → LockException, corrupted lock)
|
||||||
|
- 2 tests for `isLocked` (no lock → false, dead PID → false, live PID → true)
|
||||||
|
- 1 test for BuildException with disk-full stderr → still exit 4
|
||||||
|
- **Important**: Lock tests create temp directories in `/tmp`, clean up with `scope(exit)`. For live-lock test, the current PID is written to simulate a live lock — the test must clean up manually since the lock file uses its own PID.
|
||||||
|
|
||||||
|
### Build verified
|
||||||
|
- `dub build` passes — produces `./tofu` binary.
|
||||||
|
- `dub test` passes — all 16 modules (config, log, types, vercmp, http, index, fetch, cache, binary, deps, resolve, build, install, state, cli, errors).
|
||||||
|
- `dub test` output logged to `.omo/evidence/task-26-tofu-core.log`.
|
||||||
|
|
||||||
|
### Exit-code verification (all 16 failure paths mapped)
|
||||||
|
- [x] (1) zeta-makepkg not found → BuildException.toolMissing=true → exit 7
|
||||||
|
- [x] (2) zeta not found → InstallException.toolMissing=true → exit 7
|
||||||
|
- [x] (3) network timeout → HttpException → exit 6
|
||||||
|
- [x] (4) 404 on index.lua → IndexException (wraps HttpException) → exit 6
|
||||||
|
- [x] (5) 404 on recipe → FetchException.notFound=true → exit 2
|
||||||
|
- [x] (6) 404 on binary manifest → treated as recipe-only (no change — binary.d returns exists=false)
|
||||||
|
- [x] (7) disk full during build → BuildException (no marker) → exit 4 (stderr tail included)
|
||||||
|
- [x] (8) recipe parse error → ResolveException (future task) → exit 3
|
||||||
|
- [x] (9) dep cycle → DepException → exit 3
|
||||||
|
- [x] (10) missing dep → ResolveException → exit 3
|
||||||
|
- [x] (11) constraint unsatisfied → ResolveException → exit 3
|
||||||
|
- [x] (12) build failure → BuildException → exit 4
|
||||||
|
- [x] (13) permission denied on install → InstallException → exit 5 (stderr tail passes through)
|
||||||
|
- [x] (14) SIGINT → handler → exit 130
|
||||||
|
- [x] (15) corrupted cache → cache.d logWarn + re-fetch (no change needed)
|
||||||
|
- [x] (16) concurrent tofu → LockException in acquireLock → exit 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Task 18 — `tofu.state` (post-install state tracking for -Syu upgrades)
|
## Task 18 — `tofu.state` (post-install state tracking for -Syu upgrades)
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|||||||
+126
-3
@@ -1,7 +1,130 @@
|
|||||||
import std.stdio;
|
/// tofu — package manager for the ZereneOS Unofficial User Repository.
|
||||||
|
///
|
||||||
|
/// Entry point: parses args, acquires the global lock, dispatches to command
|
||||||
|
/// implementations, and catches ALL exceptions with actionable messages and
|
||||||
|
/// correct exit codes.
|
||||||
|
///
|
||||||
|
/// Exit codes (per plan):
|
||||||
|
/// 0 success | 1 generic | 2 pkg not found | 3 dep resolution | 4 build
|
||||||
|
/// failure | 5 install failure | 6 network | 7 config | 130 SIGINT
|
||||||
|
///
|
||||||
|
/// Implementation note:
|
||||||
|
/// Tasks 20–24 (command modules) may not exist yet in parallel execution.
|
||||||
|
/// This module dispatches via a `switch` on `Command`. Commands whose
|
||||||
|
/// modules don't exist yet print "command '<x>' not implemented yet" and
|
||||||
|
/// exit 1 — these stubs are replaced when tasks 20–24 land. Only `help`
|
||||||
|
/// is fully implemented here.
|
||||||
|
module main;
|
||||||
|
|
||||||
int main()
|
import tofu.errors; // exitCodeFor, acquireLock, releaseLock, LockException
|
||||||
|
import tofu.config; // Config, load
|
||||||
|
import tofu.cli; // Command, ParsedArgs, parseArgs, helpText, CliException
|
||||||
|
import tofu.log; // logError, logInfo
|
||||||
|
|
||||||
|
import std.stdio; // writeln, stderr
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// SIGINT handler
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
version (Posix) {
|
||||||
|
import core.sys.posix.signal : signal, SIGINT, SIG_ERR;
|
||||||
|
import core.sys.posix.unistd : write, _exit;
|
||||||
|
|
||||||
|
__gshared bool g_interrupted;
|
||||||
|
|
||||||
|
/// Signal handler for SIGINT. Sets the global flag, writes a bare message
|
||||||
|
/// to stderr via the `write(2)` syscall (fully async-signal-safe), then calls
|
||||||
|
/// `_exit(130)` immediately (no atexit / stdio flush).
|
||||||
|
/// The @nogc attribute is required by DMD's `core.sys.posix.signal` wrapper.
|
||||||
|
extern (C) void _onSigint(int) nothrow @nogc
|
||||||
{
|
{
|
||||||
writeln("tofu: ZUUR package manager");
|
g_interrupted = true;
|
||||||
|
// write(2, ...) — POSIX syscall, no allocation, no GC, async-signal-safe.
|
||||||
|
const char[] msg = "error interrupted\n";
|
||||||
|
write(2 /* STDERR_FILENO */, msg.ptr, msg.length);
|
||||||
|
_exit(130);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
// Main
|
||||||
|
// ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
int main(string[] args)
|
||||||
|
{
|
||||||
|
// ── 1. Register SIGINT handler (must happen early) ─────────────
|
||||||
|
version (Posix) {
|
||||||
|
if (signal(SIGINT, &_onSigint) == SIG_ERR) {
|
||||||
|
stderr.writeln("error failed to register SIGINT handler");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Load configuration ─────────────────────────────────────
|
||||||
|
// Config::load() never throws — falls back to defaults on errors.
|
||||||
|
auto cfg = load(null);
|
||||||
|
|
||||||
|
// ── 3. Acquire global lock (prevents concurrent tofu runs) ────
|
||||||
|
// Wrapped in try/catch: LockException has a fixed, actionable
|
||||||
|
// message and should not be remapped through exitCodeFor.
|
||||||
|
try {
|
||||||
|
acquireLock(cfg.cacheDir);
|
||||||
|
} catch (LockException e) {
|
||||||
|
logError("%s", e.msg);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
scope (exit) releaseLock(cfg.cacheDir);
|
||||||
|
scope (failure) releaseLock(cfg.cacheDir);
|
||||||
|
|
||||||
|
// ── 4. Check for premature SIGINT flag ────────────────────────
|
||||||
|
version (Posix) {
|
||||||
|
if (g_interrupted)
|
||||||
|
return 130;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Parse command-line args ─────────────────────────────────
|
||||||
|
// parseArgs expects argv[1..$] (no program name).
|
||||||
|
ParsedArgs pa;
|
||||||
|
try {
|
||||||
|
pa = parseArgs(args[1 .. $]);
|
||||||
|
} catch (CliException e) {
|
||||||
|
logError("%s", e.msg);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. Dispatch ────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
final switch (pa.cmd) {
|
||||||
|
case Command.help:
|
||||||
|
writeln(tofu.cli.helpText);
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
case Command.install:
|
||||||
|
logError("command 'install' not implemented yet");
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
case Command.search:
|
||||||
|
logError("command 'search' not implemented yet");
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
case Command.upgrade:
|
||||||
|
logError("command 'upgrade' not implemented yet");
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
case Command.remove_:
|
||||||
|
logError("command 'remove' not implemented yet");
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
case Command.info:
|
||||||
|
logError("command 'info' not implemented yet");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Any uncaught exception during dispatch — map to exit code
|
||||||
|
int ec = exitCodeFor(e);
|
||||||
|
logError("%s", e.msg);
|
||||||
|
return ec;
|
||||||
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-3
@@ -29,6 +29,10 @@ version (unittest) {
|
|||||||
class BuildException : Exception {
|
class BuildException : Exception {
|
||||||
mixin basicExceptionCtors;
|
mixin basicExceptionCtors;
|
||||||
|
|
||||||
|
/// True when the error is "zeta-makepkg not found" (→ exit code 7,
|
||||||
|
/// not 4). Set by the two throw sites below.
|
||||||
|
bool toolMissing = false;
|
||||||
|
|
||||||
this(string message, string file = __FILE__, size_t line = __LINE__)
|
this(string message, string file = __FILE__, size_t line = __LINE__)
|
||||||
@safe pure nothrow {
|
@safe pure nothrow {
|
||||||
super(message, file, line);
|
super(message, file, line);
|
||||||
@@ -132,10 +136,13 @@ string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
|
|||||||
string zetaMakepkg;
|
string zetaMakepkg;
|
||||||
if (cfg.zetaToolchainPath.length > 0) {
|
if (cfg.zetaToolchainPath.length > 0) {
|
||||||
zetaMakepkg = cfg.zetaToolchainPath;
|
zetaMakepkg = cfg.zetaToolchainPath;
|
||||||
if (!() @trusted { return exists(zetaMakepkg); }())
|
if (!() @trusted { return exists(zetaMakepkg); }()) {
|
||||||
throw new BuildException(
|
auto be = new BuildException(
|
||||||
"zeta-makepkg not found. Install zeta-toolchain or set "
|
"zeta-makepkg not found. Install zeta-toolchain or set "
|
||||||
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
||||||
|
be.toolMissing = true;
|
||||||
|
throw be;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
zetaMakepkg = "zeta-makepkg";
|
zetaMakepkg = "zeta-makepkg";
|
||||||
}
|
}
|
||||||
@@ -169,9 +176,11 @@ string runMakepkg(string recipePath, string outputDir, int jobs, bool force,
|
|||||||
// Binary not found on PATH (or exec failed)
|
// Binary not found on PATH (or exec failed)
|
||||||
if (cfg.zetaToolchainPath.length == 0
|
if (cfg.zetaToolchainPath.length == 0
|
||||||
&& e.msg.indexOf("not found") >= 0) {
|
&& e.msg.indexOf("not found") >= 0) {
|
||||||
throw new BuildException(
|
auto be = new BuildException(
|
||||||
"zeta-makepkg not found. Install zeta-toolchain or set "
|
"zeta-makepkg not found. Install zeta-toolchain or set "
|
||||||
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
~ "TOFU_ZETA_TOOLCHAIN_PATH in ~/.config/tofu/config.toml");
|
||||||
|
be.toolMissing = true;
|
||||||
|
throw be;
|
||||||
}
|
}
|
||||||
throw new BuildException(
|
throw new BuildException(
|
||||||
"failed to execute zeta-makepkg: " ~ e.msg);
|
"failed to execute zeta-makepkg: " ~ e.msg);
|
||||||
|
|||||||
+7
-1
@@ -32,6 +32,10 @@ import std.string;
|
|||||||
/// errors are propagated with their original message.
|
/// errors are propagated with their original message.
|
||||||
class FetchException : Exception
|
class FetchException : Exception
|
||||||
{
|
{
|
||||||
|
/// True when the error is "package not found in ZUUR recipes"
|
||||||
|
/// (→ exit code 2, not 6). Set by the 404-on-.recipe throw site.
|
||||||
|
bool notFound = false;
|
||||||
|
|
||||||
@safe this(string msg)
|
@safe this(string msg)
|
||||||
{
|
{
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -191,8 +195,10 @@ private void cleanupFiles(string[] paths) @trusted
|
|||||||
{
|
{
|
||||||
if (e.msg.indexOf("HTTP 404") >= 0)
|
if (e.msg.indexOf("HTTP 404") >= 0)
|
||||||
{
|
{
|
||||||
throw new FetchException(
|
auto fe = new FetchException(
|
||||||
format("package '%s' not found in ZUUR recipes", name));
|
format("package '%s' not found in ZUUR recipes", name));
|
||||||
|
fe.notFound = true;
|
||||||
|
throw fe;
|
||||||
}
|
}
|
||||||
throw new FetchException(e.msg);
|
throw new FetchException(e.msg);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -30,6 +30,10 @@ import std.file : exists;
|
|||||||
/// Thrown when a package install fails.
|
/// Thrown when a package install fails.
|
||||||
class InstallException : Exception
|
class InstallException : Exception
|
||||||
{
|
{
|
||||||
|
/// True when the error is "zeta not found" (→ exit code 7,
|
||||||
|
/// not 5). Set by the throw site below.
|
||||||
|
bool toolMissing = false;
|
||||||
|
|
||||||
this(string message, string file = __FILE__, size_t line = __LINE__)
|
this(string message, string file = __FILE__, size_t line = __LINE__)
|
||||||
@safe pure nothrow
|
@safe pure nothrow
|
||||||
{
|
{
|
||||||
@@ -87,8 +91,10 @@ void runLocalProvide(string pkgName, Config cfg)
|
|||||||
}
|
}
|
||||||
catch (ProcessException e)
|
catch (ProcessException e)
|
||||||
{
|
{
|
||||||
throw new InstallException(
|
auto ie = new InstallException(
|
||||||
"zeta not found. Install Zeta to use package management.");
|
"zeta not found. Install Zeta to use package management.");
|
||||||
|
ie.toolMissing = true;
|
||||||
|
throw ie;
|
||||||
}
|
}
|
||||||
}();
|
}();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user