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:
+5
-4
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user