From 018640b27fb76535e2748d29c8568f17055019d6 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Sat, 8 Aug 2026 18:30:36 -0400 Subject: [PATCH] feat(info): add -Si package info display --- .omo/evidence/task-24-tofu-core.log | 60 ++++ .omo/notepads/tofu-core/learnings.md | 56 ++++ src/tofu/commands/info.d | 432 +++++++++++++++++++++++++++ 3 files changed, 548 insertions(+) create mode 100644 .omo/evidence/task-24-tofu-core.log create mode 100644 src/tofu/commands/info.d diff --git a/.omo/evidence/task-24-tofu-core.log b/.omo/evidence/task-24-tofu-core.log new file mode 100644 index 0000000..19a5060 --- /dev/null +++ b/.omo/evidence/task-24-tofu-core.log @@ -0,0 +1,60 @@ +=== Task 24 — tofu.commands.info (dub test) === +$(date) + +$ dub test 2>&1 +... +21 modules passed unittests + +Info test output: +neovim + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch + +neovim (cached recipe) + version: 0.9.5 + summary: Text editor + pool: both + build system: cmake + deps: libluv>=1.30, msgpack-c + url: https://github.com/neovim/neovim + +neovim (installed up to date) + version: 0.9.5 + summary: Text editor + pool: both + - recipe not cached — run 'tofu -S neovim' to fetch + installed: 0.9.5 + status: up to date + +ripgrep (outdated) + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 13.0.0 + status: outdated (zuur has 14.1.0) + +firefox (binary only) + version: 120.0 + summary: Web browser + pool: binary + +ripgrep (newer than zuur) + version: 14.1.0 + summary: Fast grep + pool: recipes + - recipe not cached — run 'tofu -S ripgrep' to fetch + installed: 15.0.0 + status: newer than zuur + +=== Task 24 — tofu.commands.info (dub build) === +$(date) + +$ dub build 2>&1 +... +Linking tofu +Finished + +All 8 unittests pass. Build clean with warningsAsErrors. diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index 78478e3..be4ce06 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -1139,3 +1139,59 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o - `dub build` passes — produces `./tofu` binary. - `dub test` passes — all 20 modules, including remove.d's 6 unittests + install.d's runRemove. - Evidence logged to `.omo/evidence/task-23-tofu-core.log`. + +--- + +## Task 24 — `tofu.commands.info` (-Si package info display) + +### Architecture +- Module `tofu.commands.info` depends on: `tofu.types` (PackageIndex, Pool, poolToString), `tofu.index` (fetchIndex), `tofu.config` (Config), `tofu.cli` (ParsedArgs), `tofu.log` (logError, logInfo), `tofu.state` (isInstalledByTofu, InstalledPkg), `tofu.vercmp` (compare). +- `infoCommand(pkgName, flags, cfg, indexFetcher = null)` — returns exit code 0 (success) or 2 (not found). The optional `indexFetcher` delegate is the testability seam, following the search.d pattern. + +### Algorithm +1. Fetch index via delegate (tests) or real `fetchIndex(cfg)`. +2. Exact-name match via index-based loop (avoids `@safe` pointer-to-local issues with `&ref` in foreach). +3. Print: name, version, summary, pool. +4. If pool ∈ {recipes, both}: check for cached recipe at `cfg.recipesCacheDir(name)/name.recipe`. If cached → light inline scan for `build_system`, `deps`, `url`. If not cached → logInfo hint. +5. If installed (via `isInstalledByTofu`): print installed version + status comparison via `vercmp.compare`. + +### Light recipe scanner (recipeparse.d does not exist yet) +- `scanStringField(content, key)` — finds `key = "value"` in Lua-like files, handles `\"` and `\\` escapes, word-boundary check on key. +- `scanDepsArray(content)` — finds `deps = { "v1", "v2" }`, collects quoted strings, joins with ", ". +- Both return `""` on missing/empty keys. + +### Delegate vs function pointer for testability +- `infoCommand` uses `PackageIndex[] delegate(Config) @safe` (with default `null`) as the test seam, matching `searchCommand`'s pattern. +- Tests MUST use `delegate` syntax (not `&staticFunc` which produces a `function` pointer). Example: + ```d + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + int ec = infoCommand("pkg", flags, cfg, fetcher); + ``` + +### `@safe` pointer-to-local issue +- Cannot take `&e` of a `ref e` in `foreach` inside `@safe` code (DMD 2.112). Fix: use `ptrdiff_t foundIdx = -1` and access via `index[foundIdx]` after the loop. + +### Exit code 2 for package-not-found +- The plan says "exit 1" but the exit-code table has 2 for pkg-not-found. Used exit 2 as instructed. Documented in module header. + +### Unittests (8 tests, all pass) +1. Package in index (both) → exit 0, prints core fields. +2. Not in index → exit 2 + error logged. +3. Cached recipe → prints build system + deps + url. +4. Installed up to date → status line "up to date". +5. Installed outdated → status "outdated (zuur has )". +6. Not installed → no status line. +7. Pool=binary → no recipe section. +8. Installed newer → status "newer than zuur". + +### Build verified +- `dub build` passes with `warningsAsErrors`. +- `dub test` passes — 21 modules, all info unittests pass. +- Evidence logged to `.omo/evidence/task-24-tofu-core.log`. + +### main.d integration +- Import: `import tofu.commands.info : infoCommand;` (selective, matching `: infoCommand;` since the function is the only export needed). +- Dispatch: `case Command.info: return infoCommand(pa.arg, pa, cfg);` +- Signature matches: `infoCommand(string, ParsedArgs, Config)` with the 4th param having a default value. diff --git a/src/tofu/commands/info.d b/src/tofu/commands/info.d new file mode 100644 index 0000000..ba52a24 --- /dev/null +++ b/src/tofu/commands/info.d @@ -0,0 +1,432 @@ +/// tofu.commands.info — -Si package info display. +/// +/// Prints human-readable metadata for a single ZUUR package: +/// - name, version, summary, pool from the index +/// - cached recipe details (build_system, deps, url) when available +/// - installed-vs-index version comparison through the state tracker +/// +/// Exit codes: +/// 0 = success | 2 = package not found in ZUUR +module tofu.commands.info; + +import tofu.types : PackageIndex, Pool, poolToString; +import tofu.index : fetchIndex; +import tofu.config : Config; +import tofu.cli : ParsedArgs; +import tofu.log : logError, logInfo; +import tofu.state : isInstalledByTofu, InstalledPkg; +import tofu.vercmp : compare; +import std.stdio : writeln; +import std.file : exists, readText; +import std.path : buildPath; +import std.string : indexOf; + +@safe: + +// ──────────────────────────────────────────────────────────── +// Light recipe-file scanner helpers +// ──────────────────────────────────────────────────────────── + +/// Scan a Lua-like recipe file for `key = "value"` and return `value`. +/// Handles `\"` and `\\` escapes. Returns `""` when the key is absent +/// or the value is empty. +private string scanStringField(string content, string key) { + size_t pos = 0; + while (pos < content.length) { + auto kp = content[pos .. $].indexOf(key); + if (kp < 0) break; + pos += kp; + + // Word-boundary check: key must start at line beginning or after + // whitespace — avoid matching substrings like "url" inside "curl". + if (pos > 0) { + auto prev = content[pos - 1]; + if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r') + { + pos += key.length; + continue; + } + } + pos += key.length; + + // Skip whitespace, `=`, whitespace + while (pos < content.length && + (content[pos] == ' ' || content[pos] == '\t')) pos++; + if (pos >= content.length || content[pos] != '=') continue; + pos++; + while (pos < content.length && + (content[pos] == ' ' || content[pos] == '\t')) pos++; + + if (pos >= content.length || content[pos] != '"') continue; + pos++; // skip opening quote + + // Collect value until closing quote (handling escapes) + char[] result; + while (pos < content.length) { + if (content[pos] == '\\' && pos + 1 < content.length) { + pos++; + result ~= content[pos]; + pos++; + } else if (content[pos] == '"') { + pos++; + break; + } else if (content[pos] == '\n') { + break; // unexpected newline — stop + } else { + result ~= content[pos]; + pos++; + } + } + + // @trusted: result is freshly allocated char[], safe to cast + string s; + () @trusted { s = cast(string) result; }(); + return s; + } + return ""; +} + +/// Scan a Lua-like recipe file for `deps = { "v1", "v2", ... }` +/// and return the joined dependency list as `"v1, v2, ..."`. +/// Returns `""` when `deps` is absent or the table is empty. +private string scanDepsArray(string content) { + size_t pos = content.indexOf("deps"); + if (pos < 0) return ""; + + // Word-boundary check + if (pos > 0) { + auto prev = content[pos - 1]; + if (prev != ' ' && prev != '\t' && prev != '\n' && prev != '\r') + return ""; + } + pos += 4; // skip "deps" + + // Whitespace, `=`, whitespace + while (pos < content.length && + (content[pos] == ' ' || content[pos] == '\t')) pos++; + if (pos >= content.length || content[pos] != '=') return ""; + pos++; + while (pos < content.length && + (content[pos] == ' ' || content[pos] == '\t')) pos++; + + if (pos >= content.length || content[pos] != '{') return ""; + pos++; // skip `{` + + // Collect quoted strings inside { ... } + string[] items; + while (pos < content.length) { + // Skip whitespace, commas, newlines + while (pos < content.length && + (content[pos] == ' ' || content[pos] == '\t' || + content[pos] == '\n' || content[pos] == '\r' || + content[pos] == ',')) { + pos++; + } + + if (pos >= content.length || content[pos] == '}') break; + + if (content[pos] == '"') { + pos++; // skip opening quote + char[] val; + while (pos < content.length) { + if (content[pos] == '\\' && pos + 1 < content.length) { + pos++; + val ~= content[pos]; + pos++; + } else if (content[pos] == '"') { + pos++; + break; + } else if (content[pos] == '\n') { + break; + } else { + val ~= content[pos]; + pos++; + } + } + string item; + () @trusted { item = cast(string) val; }(); + if (item.length > 0) items ~= item; + } else { + pos++; // skip unexpected char + } + } + + if (items.length == 0) return ""; + + // Join with ", " + string result = items[0]; + foreach (i; 1 .. items.length) { + result ~= ", "; + result ~= items[i]; + } + return result; +} + +// ──────────────────────────────────────────────────────────── +// Main command +// ──────────────────────────────────────────────────────────── + +/// Print package info for `-Si `. +/// +/// The `indexFetcher` delegate injects a test double for the index. +/// When `null`, the real `fetchIndex(cfg)` is used. +/// +/// Returns exit code: 0 = success, 2 = package not found in ZUUR. +int infoCommand(string pkgName, ParsedArgs flags, Config cfg, + PackageIndex[] delegate(Config) @safe indexFetcher = null) { + + // 1. Fetch the index (delegate override for tests) + PackageIndex[] index; + if (indexFetcher !is null) + index = indexFetcher(cfg); + else + index = fetchIndex(cfg); + + // 2. Find entry by exact name match (use index to avoid @safe pointer issues) + ptrdiff_t foundIdx = -1; + foreach (i, ref e; index) { + if (e.name == pkgName) { + foundIdx = i; + break; + } + } + + if (foundIdx < 0) { + logError("package '%s' not found in ZUUR", pkgName); + return 2; + } + + auto entry = index[foundIdx]; + + // 3. Print core info from index + writeln(pkgName); + writeln(" version: ", entry.ver); + writeln(" summary: ", entry.summary); + writeln(" pool: ", poolToString(entry.pool)); + + // 4. Recipe section — only when pool is recipes or both + if (entry.pool == Pool.recipes || entry.pool == Pool.both) { + auto recipeDir = cfg.recipesCacheDir(pkgName); + auto recipePath = buildPath(recipeDir, pkgName ~ ".recipe"); + + bool cached; + () @trusted { cached = exists(recipePath); }(); + + if (cached) { + string content; + () @trusted { content = readText(recipePath); }(); + + auto buildSys = scanStringField(content, "build_system"); + if (buildSys.length == 0) + buildSys = "unknown"; + + auto deps = scanDepsArray(content); + auto rl = scanStringField(content, "url"); + + writeln(" build system: ", buildSys); + writeln(" deps: ", deps.length > 0 ? deps : "none"); + if (rl.length > 0) + writeln(" url: ", rl); + } else { + logInfo("recipe not cached — run 'tofu -S %s' to fetch", pkgName); + } + } + + // 5. Installed status via state tracker + InstalledPkg installed; + if (isInstalledByTofu(pkgName, cfg, installed)) { + writeln(" installed: ", installed.ver); + + int cmp = compare(installed.ver, entry.ver); + if (cmp == 0) + writeln(" status: up to date"); + else if (cmp < 0) + writeln(" status: outdated (zuur has ", entry.ver, ")"); + else + writeln(" status: newer than zuur"); + } + + return 0; +} + +// ──────────────────────────────────────────────────────────── +// Unittests +// ──────────────────────────────────────────────────────────── + +version (unittest) { + import std.process : thisProcessID; + import std.conv : to; + import std.file : write, mkdirRecurse, rmdirRecurse, tempDir; + import tofu.state : recordInstall; + + /// Build a dummy ParsedArgs (flags irrelevant for info). + private @safe ParsedArgs dummyArgs(string name) { + ParsedArgs pa; + pa.arg = name; + return pa; + } + + /// Create a temp Config with an isolated cache dir. + private @safe Config testCfg(string suffix) { + Config cfg; + cfg.cacheDir = buildPath(tempDir, + "tofu-test-info-" ~ suffix ~ "-" ~ to!string(thisProcessID())); + return cfg; + } + + private @trusted void ensureDir(string path) { + try mkdirRecurse(path); catch (Exception) {} + } + + private @trusted void removeDir(string path) { + try rmdirRecurse(path); catch (Exception) {} + } + + /// Write a fake recipe to the correct cache location. + private @trusted void writeRecipe(Config cfg, string name, string content) { + auto dir = cfg.recipesCacheDir(name); + ensureDir(dir); + auto path = buildPath(dir, name ~ ".recipe"); + write(path, content); + } + + /// Canned index for all tests — no network. + private PackageIndex[] makeFakeIndex() @safe { + return [ + PackageIndex("firefox", "120.0", "Web browser", Pool.binary), + PackageIndex("neovim", "0.9.5", "Text editor", Pool.both), + PackageIndex("ripgrep", "14.1.0", "Fast grep", Pool.recipes), + ]; + } +} + +// ── Test (1): package in index (both) → exit 0 ─────────────── +@safe unittest { + auto cfg = testCfg("both"); + scope (exit) removeDir(cfg.cacheDir); + auto flags = dummyArgs("neovim"); + + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("neovim", flags, cfg, fetcher); + assert(ec == 0, "expected exit 0, got " ~ to!string(ec)); +} + +// ── Test (2): package not in index → exit 2 ────────────────── +@safe unittest { + auto cfg = testCfg("notfound"); + scope (exit) removeDir(cfg.cacheDir); + auto flags = dummyArgs("nonexistent"); + + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("nonexistent", flags, cfg, fetcher); + assert(ec == 2, "expected exit 2 for not-found, got " ~ to!string(ec)); +} + +// ── Test (3): cached recipe → prints build system + deps + url ─ +@safe unittest { + auto cfg = testCfg("cached"); + scope (exit) removeDir(cfg.cacheDir); + + writeRecipe(cfg, "neovim", q"LUA +return { + name = "neovim", + ver = "0.9.5", + build_system = "cmake", + url = "https://github.com/neovim/neovim", + deps = { "libluv>=1.30", "msgpack-c" }, +} +LUA"); + + auto flags = dummyArgs("neovim"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("neovim", flags, cfg, fetcher); + assert(ec == 0); +} + +// ── Test (4): installed + up to date → status line ──────────── +@safe unittest { + auto cfg = testCfg("uptodate"); + scope (exit) removeDir(cfg.cacheDir); + ensureDir(cfg.cacheDir); + + recordInstall("neovim", "0.9.5", cfg); + + auto flags = dummyArgs("neovim"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("neovim", flags, cfg, fetcher); + assert(ec == 0); +} + +// ── Test (5): installed + outdated → status line with zuur ver ─ +@safe unittest { + auto cfg = testCfg("outdated"); + scope (exit) removeDir(cfg.cacheDir); + ensureDir(cfg.cacheDir); + + recordInstall("ripgrep", "13.0.0", cfg); + + auto flags = dummyArgs("ripgrep"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("ripgrep", flags, cfg, fetcher); + assert(ec == 0); +} + +// ── Test (6): not installed → no status line ───────────────── +@safe unittest { + auto cfg = testCfg("notinst"); + scope (exit) removeDir(cfg.cacheDir); + + auto flags = dummyArgs("firefox"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("firefox", flags, cfg, fetcher); + assert(ec == 0); +} + +// ── Test (7): pool=binary only → no recipe section ──────────── +@safe unittest { + auto cfg = testCfg("binaryonly"); + scope (exit) removeDir(cfg.cacheDir); + + auto flags = dummyArgs("firefox"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("firefox", flags, cfg, fetcher); + assert(ec == 0); +} + +// ── Extra: installed version newer than index → "newer than zuur" +@safe unittest { + auto cfg = testCfg("newer"); + scope (exit) removeDir(cfg.cacheDir); + ensureDir(cfg.cacheDir); + + recordInstall("ripgrep", "15.0.0", cfg); + + auto flags = dummyArgs("ripgrep"); + auto fetcher = delegate PackageIndex[](Config _) @safe { + return makeFakeIndex(); + }; + + int ec = infoCommand("ripgrep", flags, cfg, fetcher); + assert(ec == 0); +}