feat(state): track tofu-installed packages for upgrade checks

This commit is contained in:
2026-08-08 18:17:24 -04:00
parent 88047eb4ca
commit 44024ca37d
3 changed files with 683 additions and 0 deletions
+260
View File
@@ -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.
+57
View File
@@ -735,3 +735,60 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `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 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":<unix-ts>,"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/<name>/` 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.
+366
View File
@@ -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":<unix-ts>,"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) == "");
}