feat(search): add -Ss ZUUR index search

Implement tofu.commands.search module with searchCommand():
- Case-insensitive substring match on name, summary, version
- Sort results alphabetically by name
- Output format: zuur/<pool> <name> <ver>\n    <summary>
- Exit codes: 0 (success), 1 (no match), 6 (network error)
- Testable via injectable indexFetcher delegate (7 unit tests)
- Wire search dispatch in main.d (replaces stub)
This commit is contained in:
2026-08-08 18:26:41 -04:00
parent 854e7155f9
commit de77fb1733
4 changed files with 656 additions and 4 deletions
+5 -4
View File
@@ -20,6 +20,9 @@ import tofu.errors; // exitCodeFor, acquireLock, releaseLock, LockExceptio
import tofu.config; // Config, load
import tofu.cli; // Command, ParsedArgs, parseArgs, helpText, CliException
import tofu.log; // logError, logInfo
import tofu.commands.install; // installCommand
import tofu.commands.search; // searchCommand
import tofu.commands.remove; // removeCommand
import std.stdio; // writeln, stderr
@@ -100,12 +103,10 @@ int main(string[] args)
return 0;
case Command.install:
logError("command 'install' not implemented yet");
return 1;
return installCommand(pa.arg, pa, cfg);
case Command.search:
logError("command 'search' not implemented yet");
return 1;
return searchCommand(pa.arg, cfg);
case Command.upgrade:
logError("command 'upgrade' not implemented yet");
+376
View File
@@ -0,0 +1,376 @@
/// tofu.commands.search — -Ss ZUUR index search command.
///
/// Searches the ZUUR package index by case-insensitive substring match on
/// name, summary, and version. Results are sorted alphabetically by name
/// and printed in `zuur/<pool> <name> <ver>\n <summary>` format.
///
/// Exit codes:
/// 0 — matches found and printed
/// 1 — no packages match the query
/// 6 — network error (HttpException / IndexException)
module tofu.commands.search;
import tofu.config;
import tofu.types;
import tofu.index;
import tofu.http;
import tofu.log;
import std.string : toLower, indexOf;
import std.algorithm.sorting : sort;
import std.stdio : writeln;
// ────────────────────────────────────────────────────────────
// Public API
// ────────────────────────────────────────────────────────────
/// Search the ZUUR index for packages matching `query`.
///
/// Matching is case-insensitive substring: a package matches if `query`
/// (lowercased) is a substring of the lowercased name, summary, or version.
///
/// The `indexFetcher` delegate injects a test double for the index.
/// When `null`, the real `fetchIndex(cfg)` is used.
///
/// Returns the exit code:
/// 0 — success (results printed to stdout)
/// 1 — no matches (error message printed to stderr)
/// 6 — network / index error (error message printed to stderr)
@safe int searchCommand(string query, Config cfg,
PackageIndex[] delegate(Config) @safe indexFetcher = null)
{
PackageIndex[] index;
try
{
if (indexFetcher !is null)
index = indexFetcher(cfg);
else
index = fetchIndex(cfg);
}
catch (HttpException e)
{
logError("%s", e.msg);
return 6;
}
catch (IndexException e)
{
logError("%s", e.msg);
return 6;
}
// ── Filter: case-insensitive substring match on name + summary + ver ─
auto q = query.toLower();
PackageIndex[] matches;
foreach (ref pkg; index)
{
if (pkg.name.toLower().indexOf(q) >= 0 ||
pkg.summary.toLower().indexOf(q) >= 0 ||
pkg.ver.toLower().indexOf(q) >= 0)
{
matches ~= pkg;
}
}
if (matches.length == 0)
{
logError("no packages match '%s'", query);
return 1;
}
// ── Sort alphabetically by name ──────────────────────────────────────
sort!("a.name < b.name")(matches);
// ── Print results ────────────────────────────────────────────────────
foreach (ref pkg; matches)
{
writeln("zuur/", poolToString(pkg.pool), " ", pkg.name, " ", pkg.ver);
writeln(" ", pkg.summary);
}
return 0;
}
// ────────────────────────────────────────────────────────────
// Unittests
// ────────────────────────────────────────────────────────────
version (unittest)
{
import std.stdio : File, stdout, stderr;
import std.file : exists, remove, readText, tempDir;
/// Remove a temp file, silently ignoring errors (best-effort cleanup).
private void tryRemove(string path) @trusted
{
try
{
if (exists(path))
remove(path);
}
catch (Exception) {}
}
/// Capture stdout produced by `dg` into a string.
/// Swaps the global `stdout` File to a temp file, runs `dg`, then
/// reads back the full content. Restores stdout on scope exit.
/// Pattern adapted from `tofu.log` test harness.
private string captureStdout(void delegate() @safe dg) @trusted
{
auto name = tempDir() ~ "/tofu-search-stdout.tmp";
scope (exit) tryRemove(name);
{
auto file = File(name, "w");
auto saved = stdout;
stdout = file;
scope (exit) stdout = saved;
scope (failure) stdout = saved;
dg();
stdout.flush();
}
// File closed here — read from disk
string result;
() @trusted {
if (exists(name))
result = readText(name);
}();
return result;
}
/// Capture stderr produced by `dg` into a string.
private string captureStderr(void delegate() @safe dg) @trusted
{
auto name = tempDir() ~ "/tofu-search-stderr.tmp";
scope (exit) tryRemove(name);
{
auto file = File(name, "w");
auto saved = stderr;
stderr = file;
scope (exit) stderr = saved;
scope (failure) stderr = saved;
dg();
stderr.flush();
}
string result;
() @trusted {
if (exists(name))
result = readText(name);
}();
return result;
}
}
/// (1) query "neovim" with index containing neovim → finds it
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("neovim", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"should contain neovim in output: " ~ stdoutContent);
assert(stdoutContent.indexOf("zuur/both") >= 0,
"should contain zuur/both: " ~ stdoutContent);
assert(stdoutContent.indexOf("Text editor") >= 0,
"should contain summary: " ~ stdoutContent);
}
/// (2) case-insensitive: query "NEOVIM" finds "neovim"
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("NEOVIM", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"case-insensitive search should find neovim: " ~ stdoutContent);
}
/// (3) matches summary text (query "editor" matches summary "Text editor")
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("zsh", "5.9", "Z shell", Pool.both),
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
PackageIndex("ripgrep", "14.1", "Fast grep", Pool.recipes),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("editor", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
assert(stdoutContent.indexOf("neovim") >= 0,
"query 'editor' should match 'Text editor' summary: " ~ stdoutContent);
// only neovim should match (summary "Text editor")
assert(stdoutContent.indexOf("zsh") == -1,
"'zsh' should not match 'editor': " ~ stdoutContent);
assert(stdoutContent.indexOf("ripgrep") == -1,
"'ripgrep' should not match 'editor': " ~ stdoutContent);
}
/// (4) no match → returns 1, prints "no packages match" to stderr
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stderrContent;
int rc;
stderrContent = captureStderr({
rc = searchCommand("xyzzy", cfg, fetcher);
});
assert(rc == 1, "expected exit 1, got " ~ to!string(rc));
assert(stderrContent.indexOf("no packages match") >= 0,
"should print 'no packages match': " ~ stderrContent);
assert(stderrContent.indexOf("xyzzy") >= 0,
"should include query in error message: " ~ stderrContent);
}
/// (5) empty index → no match path
@safe unittest
{
import std.conv : to;
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return [];
};
auto cfg = Config();
string stderrContent;
int rc;
stderrContent = captureStderr({
rc = searchCommand("anything", cfg, fetcher);
});
assert(rc == 1, "expected exit 1 for empty index, got " ~ to!string(rc));
assert(stderrContent.indexOf("no packages match") >= 0,
"should print 'no packages match' for empty index: " ~ stderrContent);
}
/// (6) sort order: two matches → alphabetical
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("ripgrep", "14.1", "Fast grep", Pool.recipes),
PackageIndex("firefox", "120.0", "Web browser", Pool.binary),
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
// query "r" matches all three: ripgrep in name, firefox ("Web browser" has 'r'),
// neovim ("Text editor" has 'r')
stdoutContent = captureStdout({
rc = searchCommand("r", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
// Find positions of package names in output
auto firefoxPos = stdoutContent.indexOf("firefox");
auto neovimPos = stdoutContent.indexOf("neovim");
auto ripgrepPos = stdoutContent.indexOf("ripgrep");
assert(firefoxPos >= 0 && neovimPos >= 0 && ripgrepPos >= 0,
"all three packages should appear: " ~ stdoutContent);
assert(firefoxPos < neovimPos,
"firefox should appear before neovim (alphabetical): " ~ stdoutContent);
assert(neovimPos < ripgrepPos,
"neovim should appear before ripgrep (alphabetical): " ~ stdoutContent);
}
/// (7) output format exact: "zuur/both neovim 0.9.5\\n Text editor"
@safe unittest
{
import std.conv : to;
auto index = [
PackageIndex("neovim", "0.9.5", "Text editor", Pool.both),
];
auto fetcher = delegate PackageIndex[](Config cfg) @safe {
return index;
};
auto cfg = Config();
string stdoutContent;
int rc;
stdoutContent = captureStdout({
rc = searchCommand("neovim", cfg, fetcher);
});
assert(rc == 0, "expected exit 0, got " ~ to!string(rc));
// Build the exact expected line (writeln adds \n to each call)
auto expected = "zuur/both neovim 0.9.5\n Text editor\n";
assert(stdoutContent.indexOf(expected) >= 0 || stdoutContent == expected,
"exact output format mismatch.\nExpected to contain:\n" ~ expected
~ "\nActual:\n" ~ stdoutContent);
}