feat(state): track tofu-installed packages for upgrade checks
This commit is contained in:
@@ -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) == "");
|
||||
}
|
||||
Reference in New Issue
Block a user