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
+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) == "");
}