diff --git a/.omo/evidence/task-20-tofu-core.log b/.omo/evidence/task-20-tofu-core.log new file mode 100644 index 0000000..860d4b3 --- /dev/null +++ b/.omo/evidence/task-20-tofu-core.log @@ -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 phaseerror 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 phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-230309/nonexistent.recipe +warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2) +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 +warn removing stale lock (PID 99999999 not alive) +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 - +warn skipping index entry with empty name +warn skipping index entry with empty name +warn skipping index entry 'badpool': invalid pool 'bad_pool_value' + . checking binary firefox ... + - binary firefox-2.1.0 satisfies firefox>=2.0 + . checking binary libfoo ... + - binary libfoo-1.9 does not satisfy libfoo>=2.0 + . checking binary noexist ... + . checking binary brokenpkg ... + . checking binary badpkg ... + . checking binary testpkg ... + - binary testpkg-2.1.0 satisfies testpkg +==> ──── building depA (1/3) ──── + ok depA +==> ──── building depB (2/3) ──── + ok depB +==> ──── building target (3/3) ──── + ok target +==> ──── building depA (1/3) ──── + - nothing to build +==> ──── building pkg (1/1) ──── + - pkg already built, skipping +==> ──── building pkg (1/1) ──── + ok pkg +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + . downloading build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + . custom build system: fetching scripts/build.sh + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . downloading package.lua + - no package.lua for hello (optional manifest) + . downloading build.sh + - no build.sh for hello (optional build script) + ok recipe hello downloaded +==> fetching recipe hello + . downloading hello.recipe + . fetching index http://127.0.0.1:35267//index.lua + ok index loaded: 3 packages + . fetching index http://127.0.0.1:36739//index.lua + . fetching index http://127.0.0.1:34899//index.lua + . fetching index http://127.0.0.1:55143//index.lua + . fetching index http://127.0.0.1:40821//index.lua + ok index loaded: 0 packages + . fetching index http://127.0.0.1:50055//index.lua + ok index loaded: 1 packages + . fetching index http://127.0.0.1:45123//index.lua + ok index loaded: 2 packages + . fetching index http://127.0.0.1:41077//index.lua + . fetching index http://127.0.0.1:34935//index.lua + ok index loaded: 1 packages +installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping + ok installed A with 2 dependencies + - nothing to install +install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +warn corrupted installed state at /tmp/tofu-test-state-corrupt-230309/installed.json: Found 'h' when expecting 'r'. (Line 1:2) +18 modules passed unittests + - already installed — skipping + ok installed A with 0 dependencies + - 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) +==> generating build plan + - + C (recipe) + - + A (recipe) + ok build plan: 2 packages +==> generating build plan + - recipe libfoo not in cache, re-fetching + - + libfoo (recipe) + ok build plan: 1 packages +==> generating build plan +==> generating build plan + - nothing to build (all binary) +==> generating build plan + - + mypkg (recipe) + ok build plan: 1 packages +==> generating build plan + - + libfoo (recipe) + - + mypkg (recipe) + ok build plan: 2 packages + 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 diff --git a/.omo/notepads/tofu-core/learnings.md b/.omo/notepads/tofu-core/learnings.md index 5737f5e..acaceb9 100644 --- a/.omo/notepads/tofu-core/learnings.md +++ b/.omo/notepads/tofu-core/learnings.md @@ -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/ `). + +### 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`. diff --git a/src/main.d b/src/main.d index 62eb69f..b0a807c 100644 --- a/src/main.d +++ b/src/main.d @@ -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"); diff --git a/src/tofu/commands/search.d b/src/tofu/commands/search.d new file mode 100644 index 0000000..3bf5393 --- /dev/null +++ b/src/tofu/commands/search.d @@ -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/ \n ` 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); +}