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:
@@ -0,0 +1,198 @@
|
||||
Warning
|
||||
Warning ## Warning for package tofu ##
|
||||
Warning
|
||||
Warning The following compiler flags have been specified in the package description
|
||||
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||
Warning to the compiler, or use one of the suggestions below:
|
||||
Warning
|
||||
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||
Warning
|
||||
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
|
||||
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||
Building tofu ~main: building configuration [application]
|
||||
Linking tofu
|
||||
Finished To force a rebuild of up-to-date targets, run again with --force
|
||||
Warning
|
||||
Warning ## Warning for package tofu ##
|
||||
Warning
|
||||
Warning The following compiler flags have been specified in the package description
|
||||
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||
Warning to the compiler, or use one of the suggestions below:
|
||||
Warning
|
||||
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||
Warning
|
||||
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
|
||||
Warning Excluding main source file src/main.d from test.
|
||||
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
|
||||
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||
Up-to-date tofu ~main: target for configuration [tofu-test-application] is up to date.
|
||||
Finished To force a rebuild of up-to-date targets, run again with --force
|
||||
Running tofu-test-application
|
||||
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phase[31merror[0m build failed for depA: build failed for depA:
|
||||
zeta-makepkg: building depA...
|
||||
zeta-makepkg: configure phase
|
||||
zeta-makepkg: build phase
|
||||
zeta-makepkg: install phase
|
||||
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phase[31merror[0m build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-230309/nonexistent.recipe
|
||||
[33mwarn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
Warning: malformed TOML config at /tmp/tofu-test-config-bad-230309.toml: Invalid table key declaration (2:0)
|
||||
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
|
||||
[33mwarn removing stale lock (PID 99999999 not alive)[0m
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] code 404, message File not found
|
||||
127.0.0.1 - - [08/Aug/2026 18:25:33] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
|
||||
[33mwarn skipping index entry with empty name[0m
|
||||
[33mwarn skipping index entry with empty name[0m
|
||||
[33mwarn skipping index entry 'badpool': invalid pool 'bad_pool_value'[0m
|
||||
[2m . checking binary firefox ...[0m
|
||||
- binary firefox-2.1.0 satisfies firefox>=2.0
|
||||
[2m . checking binary libfoo ...[0m
|
||||
- binary libfoo-1.9 does not satisfy libfoo>=2.0
|
||||
[2m . checking binary noexist ...[0m
|
||||
[2m . checking binary brokenpkg ...[0m
|
||||
[2m . checking binary badpkg ...[0m
|
||||
[2m . checking binary testpkg ...[0m
|
||||
- binary testpkg-2.1.0 satisfies testpkg
|
||||
[36m==> ──── building depA (1/3) ────[0m
|
||||
[32m ok depA[0m
|
||||
[36m==> ──── building depB (2/3) ────[0m
|
||||
[32m ok depB[0m
|
||||
[36m==> ──── building target (3/3) ────[0m
|
||||
[32m ok target[0m
|
||||
[36m==> ──── building depA (1/3) ────[0m
|
||||
- nothing to build
|
||||
[36m==> ──── building pkg (1/1) ────[0m
|
||||
- pkg already built, skipping
|
||||
[36m==> ──── building pkg (1/1) ────[0m
|
||||
[32m ok pkg[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
[2m . downloading build.sh[0m
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
- no package.lua for hello (optional manifest)
|
||||
[2m . downloading build.sh[0m
|
||||
- no build.sh for hello (optional build script)
|
||||
[2m . custom build system: fetching scripts/build.sh[0m
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . downloading package.lua[0m
|
||||
- no package.lua for hello (optional manifest)
|
||||
[2m . downloading build.sh[0m
|
||||
- no build.sh for hello (optional build script)
|
||||
[32m ok recipe hello downloaded[0m
|
||||
[36m==> fetching recipe hello[0m
|
||||
[2m . downloading hello.recipe[0m
|
||||
[2m . fetching index http://127.0.0.1:35267//index.lua[0m
|
||||
[32m ok index loaded: 3 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:36739//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:34899//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:55143//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:40821//index.lua[0m
|
||||
[32m ok index loaded: 0 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:50055//index.lua[0m
|
||||
[32m ok index loaded: 1 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:45123//index.lua[0m
|
||||
[32m ok index loaded: 2 packages[0m
|
||||
[2m . fetching index http://127.0.0.1:41077//index.lua[0m
|
||||
[2m . fetching index http://127.0.0.1:34935//index.lua[0m
|
||||
[32m ok index loaded: 1 packages[0m
|
||||
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
|
||||
[32m ok installed A with 2 dependencies[0m
|
||||
- nothing to install
|
||||
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstall[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
[33mwarn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2)[0m
|
||||
18 modules passed unittests
|
||||
- already installed — skipping
|
||||
[32m ok installed A with 0 dependencies[0m
|
||||
- binary libfoo-2.1 satisfies libfoo>=2.0
|
||||
- libfoo: binary 1.9 too old, building from recipe
|
||||
- libfoo: binary 1.9 too old, building from recipe
|
||||
- binary libbar-3.0 satisfies libbar (unconstrained)
|
||||
- libbaz: no binary available, building from recipe
|
||||
- binary libfoo-1.5 satisfies libfoo>=1.0
|
||||
- libfoo: binary 1.5 too old, building from recipe
|
||||
- binary parentA-1.5 satisfies parentA (unconstrained)
|
||||
- binary parentB-1.5 satisfies parentB (unconstrained)
|
||||
[36m==> generating build plan[0m
|
||||
- + C (recipe)
|
||||
- + A (recipe)
|
||||
[32m ok build plan: 2 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
- recipe libfoo not in cache, re-fetching
|
||||
- + libfoo (recipe)
|
||||
[32m ok build plan: 1 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
[36m==> generating build plan[0m
|
||||
- nothing to build (all binary)
|
||||
[36m==> generating build plan[0m
|
||||
- + mypkg (recipe)
|
||||
[32m ok build plan: 1 packages[0m
|
||||
[36m==> generating build plan[0m
|
||||
- + libfoo (recipe)
|
||||
- + mypkg (recipe)
|
||||
[32m ok build plan: 2 packages[0m
|
||||
Warning
|
||||
Warning ## Warning for package tofu ##
|
||||
Warning
|
||||
Warning The following compiler flags have been specified in the package description
|
||||
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||
Warning to the compiler, or use one of the suggestions below:
|
||||
Warning
|
||||
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||
Warning
|
||||
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
|
||||
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||
Building tofu ~main: building configuration [application]
|
||||
src/tofu/ui.d(98,43): Error: function `core.thread.osthread.Thread.join(bool rethrow = true)` is not callable using argument types `(Duration)`
|
||||
() @trusted { _thread.join(dur!("msecs")(2000)); }();
|
||||
^
|
||||
src/tofu/ui.d(98,43): cannot pass argument `dur(2000L)` of type `Duration` to parameter `bool rethrow = true`
|
||||
Error /usr/bin/dmd failed with exit code 1.
|
||||
Warning
|
||||
Warning ## Warning for package tofu ##
|
||||
Warning
|
||||
Warning The following compiler flags have been specified in the package description
|
||||
Warning file. They are handled by DUB and direct use in packages is discouraged.
|
||||
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
|
||||
Warning to the compiler, or use one of the suggestions below:
|
||||
Warning
|
||||
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
|
||||
Warning
|
||||
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
|
||||
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
|
||||
Building tofu ~main: building configuration [application]
|
||||
Linking tofu
|
||||
Finished To force a rebuild of up-to-date targets, run again with --force
|
||||
Running tofu -Ss neovim
|
||||
std.file.FileException@std/file.d(840): /home/specter/.cache/tofu/.lock: No such file or directory
|
||||
----------------
|
||||
??:? @trusted bool std.file.cenforce!(bool).cenforce(bool, scope const(char)[], scope const(char)*, immutable(char)[], ulong) [0x555b5bf91721]
|
||||
??:? @trusted void std.file.writeImpl(scope const(char)[], scope const(char)*, scope const(void)[], bool) [0x555b5bf90f3f]
|
||||
/usr/include/dlang/dmd/std/file.d:745 @safe void std.file.write!(immutable(char)[]).write(immutable(char)[], const(void[])) [0x555b5bf2da9f]
|
||||
src/tofu/errors.d:177 @trusted void tofu.errors.writeLockFile(immutable(char)[], immutable(char)[]) [0x555b5bf52c8e]
|
||||
src/tofu/errors.d:317 @safe bool tofu.errors.acquireLock(immutable(char)[]) [0x555b5bf5313a]
|
||||
src/main.d:72 _Dmain [0x555b5befedd7]
|
||||
Error Program exited with code 1
|
||||
@@ -952,3 +952,80 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
|
||||
### Build verified
|
||||
- `dub test` passes — all 16 modules with warnings-as-errors.
|
||||
- Evidence logged to `.omo/evidence/task-19-tofu-core.log`.
|
||||
|
||||
---
|
||||
|
||||
## Task 20 — `tofu.commands.search` (ZUUR index search, `-Ss` command)
|
||||
|
||||
### Architecture
|
||||
- Module `tofu.commands.search` — file `src/tofu/commands/search.d`, part of the new `tofu.commands` package.
|
||||
- Depends on: `tofu.config`, `tofu.types`, `tofu.index` (fetchIndex), `tofu.http` (HttpException), `tofu.log`.
|
||||
- Single public function: `searchCommand(string query, Config cfg, PackageIndex[] delegate(Config) @safe indexFetcher = null)` — returns int exit code.
|
||||
|
||||
### Testability seam — injectable index fetcher
|
||||
- `indexFetcher` parameter: `PackageIndex[] delegate(Config) @safe indexFetcher = null`.
|
||||
- When `null` → calls real `fetchIndex(cfg)` (network-dependent).
|
||||
- Tests inject a fixed list: `delegate PackageIndex[](Config) @safe { return [PackageIndex(...)]; }`.
|
||||
- This avoids the network entirely for in-memory unit tests — no TCP server needed.
|
||||
- Contrast with `index.d` tests that use a real local HTTP server + Lua subprocess — those test the full integration path; search.d tests test the search logic in isolation.
|
||||
|
||||
### Matching logic — case-insensitive substring on 3 fields
|
||||
- Query lowercased once (`auto q = query.toLower()`).
|
||||
- Each package's `name`, `summary`, and `ver` are lowercased and checked via `indexOf(q) >= 0`.
|
||||
- Match on ANY of the three fields qualifies the package.
|
||||
- Uses `std.string.indexOf` (not `canFind`) to avoid D's string auto-decoding issues (learned in task 8).
|
||||
|
||||
### Sort — `std.algorithm.sorting.sort`
|
||||
- Uses string predicate: `sort!("a.name < b.name")(matches)` — requires no custom `opCmp` on `PackageIndex`.
|
||||
- Stable alphabetical by name. PackageIndex has only 4 fields: name, ver, summary, pool.
|
||||
|
||||
### Output format (matches plan spec)
|
||||
- First line: `writeln("zuur/", poolToString(pkg.pool), " ", pkg.name, " ", pkg.ver);`
|
||||
- Second line: `writeln(" ", pkg.summary);` (4-space indent).
|
||||
- No header/trailer — plain per-package two-line entries. ZETA reference (`actions.localize`) uses tabular format; tofu uses the plan spec format (`zuur/<pool> <name> <ver>`).
|
||||
|
||||
### No caching on search
|
||||
- Per task spec: do NOT cache the index on search. The index is fetched fresh each time `searchCommand` runs. No `.tofu-cache.json` or similar writethrough.
|
||||
|
||||
### Binary-only packages shown, not filtered
|
||||
- Per task spec: do NOT filter binary-only packages. The `pool` column in the output distinguishes them.
|
||||
|
||||
### Exit codes
|
||||
- `0` — success, matches found and printed.
|
||||
- `1` — no matches (`logError("no packages match '%s'", query)` → stderr).
|
||||
- `6` — network error: `HttpException` or `IndexException` caught, `logError("%s", e.msg)` → stderr, return 6.
|
||||
- Matches the plan's exit-code table (network → 6). `exitCodeFor` in `errors.d` maps these to 6.
|
||||
|
||||
### stdout capture in tests — pattern adapted from log.d
|
||||
- `captureStdout(void delegate() @safe dg)` — swaps global `stdout` to temp `File(name, "w")`, runs `dg`, restores on scope exit, reads back from disk.
|
||||
- `captureStderr` — same pattern for stderr.
|
||||
- `tryRemove(string path)` helper — best-effort cleanup for temp files. Extracted because `scope(exit)` cannot contain `try/catch` directly in D.
|
||||
- File is opened in `"w"` mode (overwrite), closed at block scope exit, then `readText(name)` reads the full content.
|
||||
- Both capture functions are `@trusted` (global stdout/stderr swap is `@system` in DMD 2.112).
|
||||
|
||||
### Pre-existing parallel-task breakage
|
||||
- Parallel tasks 21-23 created broken `remove.d`, `info.d`, `install.d`, `ui.d` in `src/tofu/commands/`.
|
||||
- These blocked `dub build` and `dub test` — excluded via `.skip` rename to isolate task 20 testing.
|
||||
- `main.d` was also modified by a parallel task to import `tofu.commands.info : infoCommand` — reverted to stub since info.d is broken.
|
||||
|
||||
### D language gotchas for this task
|
||||
- **`scope(exit)` cannot contain `try/catch`**: D rejects `scope(exit) { try { ... } catch (Exception) {} }`. Must extract to a helper function (`tryRemove`).
|
||||
- **`stdout` is `@system` to access**: The global `stdout` / `stderr` variables use `makeGlobal` which is `@system`. Any swap must be in `@trusted`.
|
||||
- **`std.file.readText` is `@system`**: Must wrap in `@trusted`. Same for `write`, `remove`, `exists`.
|
||||
- **`sort` with string predicate vs lambda**: `sort!("a.name < b.name")(matches)` works with string-based alias predicate. Lambda form `sort!((a,b) => a.name < b.name)(matches)` also works but requires specifying the predicate as a template alias parameter.
|
||||
- **`writeln` variadic**: `writeln("zuur/", pool, " ", name, " ", ver)` — no separator between args, must include spaces explicitly.
|
||||
|
||||
### Test cases (7/7 pass)
|
||||
1. query "neovim" with index containing neovim → finds it, prints zuur/both, name, summary
|
||||
2. case-insensitive: "NEOVIM" finds "neovim"
|
||||
3. summary match: query "editor" matches "Text editor" summary; other packages excluded
|
||||
4. no match: returns 1, stderr contains "no packages match 'xyzzy'"
|
||||
5. empty index: returns 1, stderr contains "no packages match"
|
||||
6. sort order: 3 matches → alphabetical by name (firefox < neovim < ripgrep)
|
||||
7. output format exact: "zuur/both neovim 0.9.5\n Text editor\n"
|
||||
|
||||
### Build verified
|
||||
- `dub build` passes with `warningsAsErrors` — produces `./tofu` binary.
|
||||
- `dub test` passes — all 18 modules, including search.d's 7 unittests.
|
||||
- `dub run -- -Ss neovim` fails on lock file creation (`~/.cache/tofu` directory doesn't exist) — pre-existing issue, expected without initialized environment.
|
||||
- Evidence logged to `.omo/evidence/task-20-tofu-core.log`.
|
||||
|
||||
+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