feat(cache): add recipe cache with index-version-based staleness check

This commit is contained in:
2026-08-08 17:45:14 -04:00
parent d7d0d2ec6d
commit b3cdde4d7c
3 changed files with 804 additions and 0 deletions
+435
View File
@@ -0,0 +1,435 @@
/// tofu.cache — Recipe cache with index-version-based invalidation.
///
/// Cached recipes get a small JSON manifest (`.tofu-cache.json`) recording
/// the index version that was current at fetch time. Callers compare the
/// stored version against the current index version to decide whether the
/// recipe needs re-fetching (stale).
///
/// All public API is `@safe`. Filesystem and JSON parsing operations are
/// isolated in small `@trusted` helpers following the project convention
/// established in `tofu.config`, `tofu.http`, and `tofu.fetch`.
///
/// Atomic writes: the manifest is written to a `.tmp` file then renamed,
/// preventing interrupted writes from producing a corrupted manifest.
///
/// Directory removal: `rmdirRecurse` (std.file, available since D 2.104)
/// is used for recursive directory cleanup. On DMD 2.112 this is present.
module tofu.cache;
import tofu.types : CacheManifest;
import tofu.config : Config, load;
import tofu.vercmp : compare;
import tofu.log : logWarn;
import std.file;
import std.path : buildPath;
import std.json : JSONValue, JSONType, parseJSON;
import std.datetime : Clock;
import std.conv : to;
import std.format : format;
// ────────────────────────────────────────────────────────────
// Exception
// ────────────────────────────────────────────────────────────
/// Thrown on fatal cache errors. Corrupted caches are handled
/// gracefully by returning stale=true — this exception is reserved
/// for truly unrecoverable cases.
class CacheException : Exception
{
@safe this(string msg)
{
super(msg);
}
}
// ────────────────────────────────────────────────────────────
// Trusted filesystem / JSON helpers
// ────────────────────────────────────────────────────────────
@trusted
bool fExists(string p) { return exists(p); }
@trusted
void fMkdirRecurse(string p) { mkdirRecurse(p); }
@trusted
void fRemove(string p) { remove(p); }
@trusted
void fRmdirRecurse(string p) { rmdirRecurse(p); }
@trusted
void fRename(string from, string to) { rename(from, to); }
@trusted
void fWrite(string p, string c) { write(p, c); }
@trusted
string fReadText(string p) { return readText(p); }
@trusted
JSONValue fParseJSON(string c) { return parseJSON(c); }
// ────────────────────────────────────────────────────────────
// cacheRecipe — write (or update) the recipe cache manifest
// ────────────────────────────────────────────────────────────
/// Store a cache manifest for `name` recording the current `indexVersion`.
///
/// The manifest is written atomically: first to a `.tmp` file, then
/// renamed to `.tofu-cache.json`. This prevents corrupted manifests
/// from interrupted writes.
///
/// Params:
/// name = package name
/// indexVersion = version string from the current index (e.g. "2.1.0")
/// cfg = resolved configuration providing cache-directory paths
void cacheRecipe(string name, string indexVersion, Config cfg) @safe
{
auto dir = cfg.recipesCacheDir(name);
auto manifestFile = buildPath(dir, ".tofu-cache.json");
auto tmpFile = manifestFile ~ ".tmp";
if (!fExists(dir))
fMkdirRecurse(dir);
long fetchedAt = Clock.currTime().toUnixTime();
// Build a JSON object manually via formatted string rather than
// relying on std.json serializers (which have API variations across
// Phobos versions). Three-field object: name, ver, fetchedAt.
// Package names and version strings are simple identifiers that
// do not require JSON escaping.
auto jsonStr = format(
q"EOS
{"name":"%s","ver":"%s","fetchedAt":%d}
EOS", name, indexVersion, fetchedAt);
fWrite(tmpFile, jsonStr);
if (fExists(manifestFile))
fRemove(manifestFile);
fRename(tmpFile, manifestFile);
}
// ────────────────────────────────────────────────────────────
// isRecipeStale — check whether the recipe needs re-fetching
// ────────────────────────────────────────────────────────────
/// Returns `true` when the recipe cache for `name` is missing,
/// corrupted, or its stored index version differs from `indexVersion`.
///
/// Version comparison uses `tofu.vercmp.compare` (RPM-style semantic
/// comparison) rather than exact string matching. Never throws —
/// read failures and parse errors are logged via `logWarn` and
/// treated as stale.
///
/// Params:
/// name = package name
/// indexVersion = current index version to compare against
/// cfg = resolved configuration
///
/// Returns:
/// `true` if the recipe should be re-fetched, `false` if the cache
/// is current.
bool isRecipeStale(string name, string indexVersion, Config cfg) @safe
{
auto manifestFile = buildPath(
cfg.recipesCacheDir(name), ".tofu-cache.json");
if (!fExists(manifestFile))
return true;
string content;
try
{
content = fReadText(manifestFile);
}
catch (Exception e)
{
logWarn("cannot read cache for %s: %s", name, e.msg);
return true;
}
string cachedVer;
try
{
auto jv = fParseJSON(content);
cachedVer = jv["ver"].str;
}
catch (Exception e)
{
logWarn("corrupted cache for %s: %s", name, e.msg);
return true;
}
return compare(cachedVer, indexVersion) != 0;
}
// ────────────────────────────────────────────────────────────
// Cleanup helpers
// ────────────────────────────────────────────────────────────
/// Remove the recipe cache directory for `name` (and all cached files).
///
/// Safe to call when the directory does not exist — silently returns.
void cleanRecipeCache(string name, Config cfg) @safe
{
auto dir = cfg.recipesCacheDir(name);
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clean recipe cache for %s: %s",
name, e.msg);
}
}
}
/// Remove the entire built-packages directory tree.
///
/// Safe to call when the directory does not exist — silently returns.
void clearBuildCache(Config cfg) @safe
{
auto dir = cfg.builtDir();
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clear build cache: %s", e.msg);
}
}
}
/// Remove ALL recipe caches (`<cacheDir>/recipes/`).
///
/// Useful for `-Scc`-style full clean operations.
void clearRecipeCacheAll(Config cfg) @safe
{
auto dir = buildPath(cfg.cacheDir, "recipes");
if (fExists(dir))
{
try
{
fRmdirRecurse(dir);
}
catch (Exception e)
{
logWarn("failed to clear all recipe caches: %s", e.msg);
}
}
}
// ════════════════════════════════════════════════════════════
// Unittests — pure file ops, no network required
// ════════════════════════════════════════════════════════════
version (unittest)
{
import std.process : thisProcessID;
/// Build a Config pointing at a unique temp directory for test isolation.
/// Each call returns a Config with a distinct cache dir, preventing
/// tests from stepping on each other.
@safe Config makeTestConfig(string suffix)
{
auto dir = buildPath(
tempDir, "tofu-test-cache-" ~ thisProcessID.to!string ~ "-" ~ suffix);
const string[string] env = ["TOFU_CACHE_DIR": dir];
return load(null, env);
}
/// Recursively remove a test directory, ignoring errors.
@trusted void cleanupTestDir(string dir)
{
try
{
if (exists(dir))
rmdirRecurse(dir);
}
catch (Exception) {}
}
}
// ── Test (1): cacheRecipe then isRecipeStale(same ver) → false ─────
@safe unittest
{
auto cfg = makeTestConfig("t1");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "2.1.0", cfg);
assert(!isRecipeStale("mypkg", "2.1.0", cfg),
"same version should not be stale");
}
// ── Test (2): isRecipeStale(different ver) → true ──────────────────
@safe unittest
{
auto cfg = makeTestConfig("t2");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "1.0", cfg);
assert(isRecipeStale("mypkg", "2.0", cfg),
"different version should be stale");
}
// ── Test (3): missing cache → isRecipeStale → true ─────────────────
@safe unittest
{
auto cfg = makeTestConfig("t3");
scope (exit) cleanupTestDir(cfg.cacheDir);
assert(isRecipeStale("nopkg", "1.0", cfg),
"missing cache should be stale");
}
// ── Test (4): cleanRecipeCache removes dir ─────────────────────────
@safe unittest
{
auto cfg = makeTestConfig("t4");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("mypkg", "1.0", cfg);
auto dir = cfg.recipesCacheDir("mypkg");
// Verify the directory exists after caching
assert(fExists(dir), "cache dir should exist after cacheRecipe");
cleanRecipeCache("mypkg", cfg);
// After cleanRecipeCache, the dir should be gone
assert(!fExists(dir), "cache dir should be removed after cleanRecipeCache");
}
// ── Test (5): clearBuildCache removes built tree ───────────────────
@safe unittest
{
auto cfg = makeTestConfig("t5");
scope (exit) cleanupTestDir(cfg.cacheDir);
auto builtDir = cfg.builtDir();
fMkdirRecurse(builtDir);
// Touch a file so the dir is non-empty
fWrite(buildPath(builtDir, "sentinel"), "x");
assert(fExists(builtDir), "built dir should exist before clear");
clearBuildCache(cfg);
assert(!fExists(builtDir),
"built dir should be removed after clearBuildCache");
}
// ── Test (6): cache file is valid JSON (read back + parse) ─────────
@safe unittest
{
auto cfg = makeTestConfig("t6");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("firefox", "1.0", cfg);
auto manifestFile = buildPath(
cfg.recipesCacheDir("firefox"), ".tofu-cache.json");
assert(fExists(manifestFile), "manifest file should exist");
string content = fReadText(manifestFile);
JSONValue jv = fParseJSON(content);
assert(jv["name"].str == "firefox", "name field mismatch");
assert(jv["ver"].str == "1.0", "ver field mismatch");
assert(jv["fetchedAt"].integer > 0, "fetchedAt should be positive timestamp");
}
// ── Test (7): vercmp-based — semantic equality means NOT stale ─────
@safe unittest
{
auto cfg = makeTestConfig("t7");
scope (exit) cleanupTestDir(cfg.cacheDir);
// Same version string → not stale
cacheRecipe("pkg", "1.0.0", cfg);
assert(!isRecipeStale("pkg", "1.0.0", cfg),
"identical version strings should not be stale");
// Leading zeros are ignored per vercmp: "001" == "1"
cacheRecipe("pkg2", "01.05", cfg);
assert(!isRecipeStale("pkg2", "1.5", cfg),
"leading-zero variants should not be stale per vercmp");
// Different versions ARE stale
cacheRecipe("pkg3", "1.0", cfg);
assert(isRecipeStale("pkg3", "2.0", cfg),
"actually different versions should be stale");
}
// ── Test (8): corrupted cache JSON → isRecipeStale returns true ────
// (never throws, logs a warning)
@safe unittest
{
auto cfg = makeTestConfig("t8");
scope (exit) cleanupTestDir(cfg.cacheDir);
auto dir = cfg.recipesCacheDir("pkg");
if (!fExists(dir))
fMkdirRecurse(dir);
// Write deliberately broken JSON
auto manifestFile = buildPath(dir, ".tofu-cache.json");
fWrite(manifestFile, "this is not json {{{");
// Should not throw — returns true (stale) and logs a warning
bool stale;
try
{
stale = isRecipeStale("pkg", "1.0", cfg);
}
catch (Exception e)
{
assert(false, "isRecipeStale must never throw: " ~ e.msg);
}
assert(stale, "corrupted cache should be considered stale");
}
// ── Extra: clearRecipeCacheAll removes the entire recipes/ tree ────
@safe unittest
{
auto cfg = makeTestConfig("t9");
scope (exit) cleanupTestDir(cfg.cacheDir);
// Cache two different packages
cacheRecipe("pkg-a", "1.0", cfg);
cacheRecipe("pkg-b", "2.0", cfg);
auto recipesDir = buildPath(cfg.cacheDir, "recipes");
assert(fExists(recipesDir), "recipes dir should exist");
clearRecipeCacheAll(cfg);
assert(!fExists(recipesDir),
"recipes dir should be removed after clearRecipeCacheAll");
}
// ── Extra: isRecipeStale with vercmp numeric ordering ──────────────
@safe unittest
{
auto cfg = makeTestConfig("t10");
scope (exit) cleanupTestDir(cfg.cacheDir);
cacheRecipe("pkg", "1.9", cfg);
// vercmp: 1.10 > 1.9, so cached 1.9 != index 1.10 → stale
assert(isRecipeStale("pkg", "1.10", cfg),
"1.9 cached vs 1.10 index should be stale per vercmp");
}