diff --git a/.omo/evidence/task-18-tofu-core.log b/.omo/evidence/task-18-tofu-core.log new file mode 100644 index 0000000..456932a --- /dev/null +++ b/.omo/evidence/task-18-tofu-core.log @@ -0,0 +1,260 @@ + 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/state.d(203,53): Error: conflicting attribute `@trusted` +private @safe Config makeTestConfig(string suffix) @trusted { + ^ +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] +src/tofu/errors.d(31,12): Error: unable to read module `errno` + import core.sys.posix.errno : ESRCH; + ^ +src/tofu/errors.d(31,12): Expected 'core/sys/posix/errno.d' or 'core/sys/posix/errno/package.d' in one of the following import paths: +import path[0] = src/ +import path[1] = ../../.dub/cache/tofu/~main/code/tofu-test-application-unittest-qC6iqORPwrCDXediNbesdA/ +import path[2] = ../../.dub/packages/toml/1.0.0/toml/src/ +import path[3] = /usr/include/dlang/dmd +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] +src/tofu/cli.d(261,25): Error: no property `indexOf` for `e.msg` of type `string` + assert(e.msg.indexOf(needle) >= 0, + ^ +src/tofu/state.d(85,21): Error: `@safe` function `tofu.state.loadState` cannot call `@system` function `std.json.JSONValue.array` + foreach (entry; root.array) { + ^ +/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here + @property ref inout(JSONValue[]) array() scope return inout pure @system + ^ +src/tofu/state.d(344,12): Error: `@safe` function `tofu.state.__unittest_L327_C7` cannot call `@system` function `std.json.JSONValue.array` + assert(parsed.array.length == 2); + ^ +/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here + @property ref inout(JSONValue[]) array() scope return inout pure @system + ^ +src/tofu/state.d(347,21): Error: `@safe` function `tofu.state.__unittest_L327_C7` cannot call `@system` function `std.json.JSONValue.array` + foreach (entry; parsed.array) { + ^ +/usr/include/dlang/dmd/std/json.d(417,38): `std.json.JSONValue.array` is declared here + @property ref inout(JSONValue[]) array() scope return inout pure @system + ^ +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] +src/tofu/state.d(44,68): Error: return value `v.array()` of type `const(JSONValue[])` does not match return type `JSONValue[]`, and cannot be implicitly converted +private @trusted JSONValue[] fArray(const JSONValue v) { return v.array; } + ^ +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-221718/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-221718.toml: Invalid table key declaration (2:0) +Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1 +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/build.sh HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 - +127.0.0.1 - - [08/Aug/2026 18:16:38] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:38] "GET /recipes/hello/package.lua HTTP/1.1" 404 - +127.0.0.1 - - [08/Aug/2026 18:16:39] code 404, message File not found +127.0.0.1 - - [08/Aug/2026 18:16:39] "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:59437//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:56161//index.lua + . fetching index http://127.0.0.1:52523//index.lua + . fetching index http://127.0.0.1:50341//index.lua + . fetching index http://127.0.0.1:32959//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:58617//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:51607//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:42319//index.lua + . fetching index http://127.0.0.1:38171//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-221718/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-221718/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +14 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: unable to read module `cli` +import tofu.cli : Command, parseArgs, helpText; + ^ +src/main.d(26,8): Expected 'tofu/cli.d' or 'tofu/cli/package.d' in one of the following import paths: +import path[0] = src/ +import path[1] = ../../.dub/packages/toml/1.0.0/toml/src/ +import path[2] = /usr/include/dlang/dmd +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 52c7696..c1dd848 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -735,3 +735,60 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o - `dub build` passes with `warningsAsErrors`. - No D LSP server configured for `.d` — diagnostics verified via compiler. - Evidence logged to `.omo/evidence/task-17-tofu-core.log`. + +--- + +## Task 18 — `tofu.state` (post-install state tracking for -Syu upgrades) + +### Architecture +- Module `tofu.state` depends on: `tofu.config` (Config, cacheDir), `tofu.log` (logWarn), `std.json` (parseJSON, JSONValue, JSONType), `std.file` (readText, write, rename, exists, remove), `std.datetime` (Clock). +- State file: `cfg.cacheDir ~ "/installed.json"` — JSON array of `{"name":"...","ver":"...","installedAt":,"source":"recipe"}` objects. +- Five public APIs: `recordInstall`, `listInstalled`, `isInstalledByTofu`, `removeInstallRecord`, `installedVersion`. +- Atomic writes: write to `.tmp` file then `rename()` — ensures the state file is never half-written. + +### `@safe` / `@trusted` architecture +- All public functions are `@safe`. +- JSON operations (`parseJSON`, `JSONValue.array` property) are `@system` in Phobos — isolated behind `@trusted` wrappers (`fParseJSON`, `fArray`). +- `JSONValue.array` returns `ref inout(JSONValue[])` — must use `ref JSONValue` parameter in the wrapper (not by-value copy) to avoid dangling reference. +- Filesystem operations (`readText`, `write`, `rename`, `exists`, `remove`) isolated in `@trusted` wrappers — same pattern as all other modules. + +### State file structure +- Single JSON array at `~/.cache/tofu/installed.json` — SEPARATE from ZETA's per-package `var/db/zeta//` database. +- `recordInstall` handles reinstall: if an entry for `name` already exists, it's replaced (new ver + timestamp); otherwise appended. +- `removeInstallRecord` for missing entries is a no-op. +- `installedVersion` returns `""` for unknown packages — convenience for upgrade command comparison. +- Source is always `"recipe"` — this module only tracks recipe-built packages. + +### Corrupted/missing state handling +- Missing file → empty list (normal for fresh install). +- Corrupted JSON → `logWarn` + return empty list (never throws). +- Individual corrupted entries within a valid JSON array → skipped silently. +- After corruption, `recordInstall` writes fresh state successfully (recovery). + +### Test strategy (7 unittests) +- Used temp directory pattern from `config.d`: `tempDir ~ "/tofu-test-state-" ~ suffix ~ "-" ~ thisProcessID.to!string`. +- `scope(exit)` with `rmdirRecurse` for cleanup — same as `cache.d`/`build.d`/`install.d`. +- `makeTestConfig(suffix)` creates isolated `Config` with unique cache dir per test. +- Test (1): recordInstall → listInstalled contains it with correct ver ✓ +- Test (2): recordInstall twice same name → single entry, latest ver ✓ +- Test (3): isInstalledByTofu → true with filled pkg; unknown → false ✓ +- Test (4): removeInstallRecord → gone; removing missing → no-op ✓ +- Test (5): corrupted JSON file → empty list + no throw ✓ +- Test (6): missing file → empty list ✓ +- Test (7): file is valid JSON after writes (parse back externally) ✓ +- Extra: installedVersion convenience ✓ + +### `@safe` + `@trusted` attribute conflict on test helpers +- `makeTestConfig` calls `tempDir` and `thisProcessID` which are `@safe` in DMD 2.112 (not `@system`). Marking it both `@safe` and `@trusted` causes "conflicting attribute" error. Fix: `@safe` only (no `@trusted` suffix needed). + +### `JSONType` enum in Phobos +- Members: `JSONType.array`, `JSONType.object`, `JSONType.string_` (underscore because `string` is a D keyword), `JSONType.integer`, `JSONType.float_`, etc. +- `JSONValue.str` returns `string`, `.integer` returns `long`. + +### Build verification +- `dub test` (excluding pre-existing broken `cli.d` and `errors.d`) — 14 modules pass unittests including state.d's 7 test blocks. +- `dub build` fails due to pre-existing `cli.d` import error (`indexOf` on string) — NOT caused by state.d. state.d compiles clean standalone (`dmd -c -o-`). +- Evidence logged to `.omo/evidence/task-18-tofu-core.log`. + +### 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. diff --git a/src/tofu/state.d b/src/tofu/state.d new file mode 100644 index 0000000..d870109 --- /dev/null +++ b/src/tofu/state.d @@ -0,0 +1,366 @@ +/// tofu state — post-install package tracking for -Syu upgrade checks. +/// +/// Tofu maintains its own lightweight JSON state file separate from ZETA's +/// per-package database. This tracks what tofu installed (by recipe) so the +/// upgrade command can compare installed versions against the ZUUR index. +/// +/// State file: `cfg.cacheDir ~ "/installed.json"` — a JSON array of objects: +/// `[{"name":"hello","ver":"1.0","installedAt":,"source":"recipe"}]` +/// +/// Atomic writes: tmp file + rename ensures the state file is never +/// half-written. +/// +/// Corrupted or missing state file → empty list + logWarn (never throws). +module tofu.state; + +import std.file : readText, write, rename, exists, remove, tempDir; +import std.process : thisProcessID; +import std.json : parseJSON, JSONValue, JSONType; +import std.datetime : Clock; +import std.conv : to; +import std.format : format; +import std.path : buildPath; +import tofu.config : Config; +import tofu.log : logWarn; + +// ─── Data types ────────────────────────────────────────────────────────────── + +/// A single entry in tofu's installed-package state. +struct InstalledPkg { + string name = ""; + string ver = ""; + long installedAt; + string source = ""; +} + +// ─── @trusted wrappers (fs + json — @system in Phobos) ────────────────────── + +private @trusted string fReadText(string path) { return readText(path); } +private @trusted void fWrite(string path, string c) { write(path, c); } +private @trusted void fRename(string from, string to) { rename(from, to); } +private @trusted void fRemove(string path) { try remove(path); catch (Exception) {} } +private @trusted bool fExists(string path) { return exists(path); } +private @trusted JSONValue fParseJSON(string content) { return parseJSON(content); } +private @trusted JSONValue[] fArray(ref JSONValue v) { return v.array; } + +// ─── Path helper ───────────────────────────────────────────────────────────── + +/// Absolute path to the installed-state JSON file. +private pure @safe nothrow +string statePath(Config cfg) { + return cfg.cacheDir ~ "/installed.json"; +} + +// ─── Internal: load / save ─────────────────────────────────────────────────── + +/// Read the state file and parse it into an `InstalledPkg[]`. +/// Missing file → empty array. Corrupted JSON → logWarn + empty array. +private @safe +InstalledPkg[] loadState(Config cfg) { + auto path = statePath(cfg); + if (!fExists(path)) + return []; + + string content; + try { + content = fReadText(path); + } catch (Exception) { + logWarn("could not read installed state at %s", path); + return []; + } + + JSONValue root; + try { + root = fParseJSON(content); + } catch (Exception e) { + logWarn("corrupted installed state at %s: %s", path, e.msg); + return []; + } + + if (root.type != JSONType.array) { + logWarn("corrupted installed state at %s: expected JSON array", path); + return []; + } + + InstalledPkg[] result; + foreach (entry; fArray(root)) { + if (entry.type != JSONType.object) + continue; + try { + InstalledPkg pkg; + pkg.name = entry["name"].str; + pkg.ver = entry["ver"].str; + pkg.installedAt = entry["installedAt"].integer; + pkg.source = entry["source"].str; + if (pkg.name.length > 0) + result ~= pkg; + } catch (Exception) { + // Skip individual corrupted entries silently. + } + } + return result; +} + +/// Serialize entries to a JSON array string. +private @safe +string serialize(const InstalledPkg[] pkgs) { + import std.array : appender; + auto w = appender!string(); + w.put("["); + foreach (i, pkg; pkgs) { + if (i > 0) w.put(","); + w.put(format( + `{"name":"%s","ver":"%s","installedAt":%d,"source":"%s"}`, + pkg.name, pkg.ver, pkg.installedAt, pkg.source)); + } + w.put("]"); + return w.data; +} + +/// Write entries to the state file atomically: tmp file → rename. +private @safe +void saveState(const InstalledPkg[] pkgs, Config cfg) { + auto path = statePath(cfg); + auto tmp = path ~ ".tmp"; + fWrite(tmp, serialize(pkgs)); + fRename(tmp, path); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/// Record an install (or reinstall). If an entry for `name` already exists +/// it is replaced with the new version and timestamp; otherwise a new entry +/// is appended. Written atomically to disk. +void recordInstall(string name, string ver, Config cfg) @safe { + auto pkgs = loadState(cfg); + auto now = Clock.currTime().toUnixTime(); + + bool found = false; + foreach (ref pkg; pkgs) { + if (pkg.name == name) { + pkg.ver = ver; + pkg.installedAt = now; + pkg.source = "recipe"; + found = true; + break; + } + } + + if (!found) + pkgs ~= InstalledPkg(name, ver, now, "recipe"); + + saveState(pkgs, cfg); +} + +/// Return every package tofu has recorded as installed. +/// Missing or corrupted state file → empty array. +InstalledPkg[] listInstalled(Config cfg) @safe { + return loadState(cfg); +} + +/// Check whether `name` was installed by tofu. On success fills `pkg` and +/// returns `true`. On failure returns `false` (pkg unchanged). +bool isInstalledByTofu(string name, Config cfg, out InstalledPkg pkg) @safe { + auto pkgs = loadState(cfg); + foreach (entry; pkgs) { + if (entry.name == name) { + pkg = entry; + return true; + } + } + return false; +} + +/// Remove the install record for `name`. Missing entry → no-op. +/// Written atomically to disk. +void removeInstallRecord(string name, Config cfg) @safe { + auto pkgs = loadState(cfg); + bool found = false; + InstalledPkg[] filtered; + foreach (pkg; pkgs) { + if (pkg.name == name) + found = true; + else + filtered ~= pkg; + } + if (found) + saveState(filtered, cfg); +} + +/// Convenience: return the installed version string for `name`, or `""` if +/// not installed by tofu. +string installedVersion(string name, Config cfg) @safe { + auto pkgs = loadState(cfg); + foreach (pkg; pkgs) { + if (pkg.name == name) + return pkg.ver; + } + return ""; +} + +// ─── Unittests ─────────────────────────────────────────────────────────────── + +// Test helpers +private @safe Config makeTestConfig(string suffix) { + Config cfg; + cfg.cacheDir = buildPath(tempDir, + "tofu-test-state-" ~ suffix ~ "-" ~ thisProcessID.to!string); + return cfg; +} + +private @trusted void ensureDir(string path) { + import std.file : mkdirRecurse; + try mkdirRecurse(path); catch (Exception) {} +} + +private @trusted void removeDir(string path) { + import std.file : rmdirRecurse; + try rmdirRecurse(path); catch (Exception) {} +} + +private @trusted void ensureStateDir(Config cfg) { + ensureDir(cfg.cacheDir); +} + +// ── Test (1): recordInstall → listInstalled contains it with correct ver. +@safe unittest { + auto cfg = makeTestConfig("basic"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("hello", "1.0", cfg); + auto pkgs = listInstalled(cfg); + assert(pkgs.length == 1); + assert(pkgs[0].name == "hello"); + assert(pkgs[0].ver == "1.0"); + assert(pkgs[0].source == "recipe"); + assert(pkgs[0].installedAt > 0); +} + +// ── Test (2): recordInstall twice same name → single entry, latest ver. +@safe unittest { + auto cfg = makeTestConfig("replace"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("hello", "1.0", cfg); + recordInstall("hello", "2.0", cfg); + auto pkgs = listInstalled(cfg); + assert(pkgs.length == 1); + assert(pkgs[0].name == "hello"); + assert(pkgs[0].ver == "2.0"); +} + +// ── Test (3): isInstalledByTofu → true with filled pkg; unknown → false. +@safe unittest { + auto cfg = makeTestConfig("check"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("firefox", "120.0", cfg); + + InstalledPkg pkg; + assert(isInstalledByTofu("firefox", cfg, pkg)); + assert(pkg.name == "firefox"); + assert(pkg.ver == "120.0"); + assert(pkg.source == "recipe"); + + InstalledPkg notFound; + assert(!isInstalledByTofu("nonexistent", cfg, notFound)); +} + +// ── Test (4): removeInstallRecord → gone; removing missing → no-op. +@safe unittest { + auto cfg = makeTestConfig("remove"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("a", "1.0", cfg); + recordInstall("b", "2.0", cfg); + assert(listInstalled(cfg).length == 2); + + removeInstallRecord("a", cfg); + auto pkgs = listInstalled(cfg); + assert(pkgs.length == 1); + assert(pkgs[0].name == "b"); + + // Removing missing → no-op + removeInstallRecord("nonexistent", cfg); + pkgs = listInstalled(cfg); + assert(pkgs.length == 1); +} + +// ── Test (5): corrupted JSON file → empty list + no throw. +@safe unittest { + auto cfg = makeTestConfig("corrupt"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + // Write garbage to the state file + auto path = statePath(cfg); + () @trusted { write(path, "this is not valid {{{ JSON"); }(); + + auto pkgs = listInstalled(cfg); + assert(pkgs.length == 0); + + // Verify we can still write to it after corruption + recordInstall("recovery", "1.0", cfg); + pkgs = listInstalled(cfg); + assert(pkgs.length == 1); + assert(pkgs[0].name == "recovery"); +} + +// ── Test (6): missing file → empty list. +@safe unittest { + auto cfg = makeTestConfig("missing"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + // Don't write anything — cache dir exists but no installed.json + auto pkgs = listInstalled(cfg); + assert(pkgs.length == 0); + + // installedVersion returns "" for missing + assert(installedVersion("anything", cfg) == ""); +} + +// ── Test (7): file is valid JSON after writes (parse back). +@safe unittest { + auto cfg = makeTestConfig("valid"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("x", "1.0", cfg); + recordInstall("y", "2.1", cfg); + + // Read raw file and parse back externally + auto path = statePath(cfg); + string raw; + () @trusted { raw = readText(path); }(); + + JSONValue parsed; + () @trusted { parsed = parseJSON(raw); }(); + + assert(parsed.type == JSONType.array); + assert(fArray(parsed).length == 2); + + bool foundX, foundY; + foreach (entry; fArray(parsed)) { + auto name = entry["name"].str; + auto ver = entry["ver"].str; + if (name == "x") { assert(ver == "1.0"); foundX = true; } + if (name == "y") { assert(ver == "2.1"); foundY = true; } + } + assert(foundX && foundY); +} + +// ── Extra: installedVersion convenience. +@safe unittest { + auto cfg = makeTestConfig("instver"); + scope (exit) removeDir(cfg.cacheDir); + ensureStateDir(cfg); + + recordInstall("zed", "3.3.1", cfg); + assert(installedVersion("zed", cfg) == "3.3.1"); + assert(installedVersion("nope", cfg) == ""); +}