From bff54d577dc6aab9aa0d3b2919ff4f2ed4c5dfd2 Mon Sep 17 00:00:00 2001 From: Joel Baranick Date: Mon, 31 Mar 2025 18:16:04 -0700 Subject: [PATCH] feat(search): show search results as a table of versions and platforms Search against the v2 search API, asking for each result's newest releases and their systems, so `devbox search` lists versions alongside the platforms they are available on, grouping consecutive versions that share the same platforms. It requests 11 releases of the top 11 packages by default, and every release with --show-all. Search services that don't return releases fall back to one /v2/pkg call per displayed package. A package whose lookup fails is left out, and the search fails only when every lookup does. Search services set with DEVBOX_SEARCH_HOST now need to implement /v2/search, and /v2/pkg unless they return releases, instead of /v1/search. --- SEARCH_API.md | 110 ++++++++++++++----- go.mod | 1 + go.sum | 2 + internal/boxcli/search.go | 99 +++++++++++++---- internal/boxcli/search_test.go | 116 ++++++++++++++++++++ internal/searcher/client.go | 131 +++++++++++++++++++++- internal/searcher/client_test.go | 179 +++++++++++++++++++++++++++++++ internal/searcher/model.go | 56 +++++++++- vendor-hash | 2 +- 9 files changed, 641 insertions(+), 55 deletions(-) create mode 100644 internal/boxcli/search_test.go diff --git a/SEARCH_API.md b/SEARCH_API.md index 6074696af07..c53dd0722c6 100644 --- a/SEARCH_API.md +++ b/SEARCH_API.md @@ -22,7 +22,8 @@ truth for the response schemas. | ----------------- | ----------------------------------------------------------------------------------------------------- | | `GET /v2/resolve` | Writing `devbox.lock` (`devbox add`, `devbox update`, `devbox install`), `devbox init --auto` | | `GET /v1/resolve` | `devbox search @`, `devbox info`, and `devbox.lock` when `DEVBOX_FEATURE_RESOLVE_V2=0` | -| `GET /v1/search` | `devbox search ` | +| `GET /v2/search` | `devbox search ` | +| `GET /v2/pkg` | `devbox search `, once for each search result without `releases` | ## Conventions @@ -175,47 +176,106 @@ When `/v1/resolve` is used to write a lock file, `resolved` is `github:NixOS/nixpkgs/#` from the same system selection described for `/v2/resolve`. -## `GET /v1/search` +## `GET /v2/search` -Free-text package search, used by `devbox search `. +Free-text package search, used by `devbox search `. Each result +carries its newest releases and the systems each runs on, so one request is +enough. ### Parameters -| Name | Required | Description | -| ---- | -------- | -------------------------------------------------------------- | -| `q` | yes | Search query. Devbox never sends an empty query. | +| Name | Required | Description | +| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `q` | yes | Search query. Devbox never sends an empty query. | +| `releases` | yes | How many of each result's newest releases to return, as a positive integer, or `all`. Devbox sends `11` to show 10 and know there are more, and `all` with `--show-all`. | ### Response ```json { - "num_results": 14, - "packages": [ + "query": "hello", + "total_results": 15, + "results": [ { "name": "hello", - "num_versions": 5, - "versions": [ - { "name": "hello", "version": "2.12.3", "summary": "Program that produces a familiar, friendly greeting" }, - { "name": "hello", "version": "2.12.2" } + "summary": "Program that produces a familiar, friendly greeting", + "last_updated": "2026-10-17T03:39:59Z", + "releases": [ + { + "version": "2.12.3", + "systems": ["aarch64-darwin", "aarch64-linux", "x86_64-darwin", "x86_64-linux"] + } ] } ] } ``` -| Field | Required | Description | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------ | -| `num_results` | yes | Result count, displayed as `Found + results`. | -| `packages` | yes | Matching packages, most relevant first. An empty list prints `No results found`. | -| `packages[].name` | yes | Package name, as a user would pass it to `devbox add`. | -| `packages[].num_versions` | yes | Total versions available. Used to decide whether to show a `...` after truncated version lists. | -| `packages[].versions` | yes | Available versions, newest first. Entries use the same shape as the `/v1/resolve` response. | -| `packages[].versions[].version` | yes | Version string to display. Empty versions are skipped. | +| Field | Required | Description | +| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- | +| `total_results` | yes | Total number of matches, displayed as `Found + results`. | +| `results` | yes | Matching packages, most relevant first. An empty list prints `No results found`. | +| `results[].name` | yes | Package name, as a user would pass it to `devbox add`. Passed to `/v2/pkg` as `name`. | +| `results[].releases` | no | The package's newest releases, newest first, at most as many as `releases` asked for. Without it, Devbox calls `/v2/pkg` for the package. | +| `results[].releases[].version` | yes | Version string to display. Releases with an empty version are skipped. | +| `results[].releases[].systems` | yes | Nix systems the release is available on, such as `aarch64-darwin`. May be empty. | +| `query`, `results[].summary`, `results[].last_updated`, and other result fields | no | Informational. Not read by Devbox. | + +## `GET /v2/pkg` + +Returns every release of a single package, with the platforms each release is +available on. Devbox calls it, several at a time, only for `/v2/search` results +without `releases`, so a service that returns `releases` doesn't need it. + +### Parameters + +| Name | Required | Description | +| ------ | -------- | ------------------------------------------------ | +| `name` | yes | Exact package name, from `/v2/search` `results[].name`. | + +### Response + +```json +{ + "name": "hello", + "summary": "Program that produces a familiar, friendly greeting", + "homepage_url": "https://www.gnu.org/software/hello/manual/", + "license": "GPL-3.0-or-later", + "releases": [ + { + "version": "2.12.3", + "last_updated": "2026-10-17T03:39:59Z", + "platforms": [ + { "arch": "arm64", "os": "macOS" }, + { "arch": "arm64", "os": "Linux" }, + { "arch": "x86-64", "os": "macOS" }, + { "arch": "x86-64", "os": "Linux" } + ] + } + ] +} +``` -Devbox displays packages and versions in the order returned and doesn't -re-sort them, so ranking is entirely up to the server. By default it shows the -first 10 packages and the first 10 versions of each. `--show-all` shows -everything returned. +| Field | Required | Description | +| ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- | +| `name` | yes | Package name to display. | +| `releases` | yes | Available versions, newest first. | +| `releases[].version` | yes | Version string to display. Releases with an empty version are skipped. | +| `releases[].platforms` | yes | Platforms the release is available on. May be empty. | +| `releases[].platforms[].arch` | yes | CPU architecture, such as `arm64` or `x86-64`. Devbox displays `arm64` as `aarch64` and `-` as `_`. | +| `releases[].platforms[].os` | yes | Operating system, such as `Linux` or `macOS`. Devbox lowercases it and displays `macOS` as `darwin`. | +| `summary`, `homepage_url`, `license`, `releases[].last_updated`, and other platform fields | no | Informational. Not read by Devbox. | + +A package whose lookup fails, including with `404 Not Found`, is dropped from +the search results. The search fails only when every lookup fails with +something other than a `404`. + +Devbox shows each package's versions next to the Nix systems they are available +on (for example `aarch64-darwin`), grouping consecutive releases that have the +same platforms. It displays packages and releases in the order returned and +doesn't re-sort them, so ranking is entirely up to the server. By default it +shows the first 10 packages and the first 10 versions of each. `--show-all` +shows everything returned. ## Testing a search service @@ -224,7 +284,7 @@ Point Devbox at the server and exercise each endpoint: ```sh export DEVBOX_SEARCH_HOST=http://localhost:8080 -devbox search ripgrep # /v1/search +devbox search ripgrep # /v2/search, and /v2/pkg without releases devbox search go@1.21 # /v1/resolve devbox info hello # /v1/resolve devbox init && devbox add hello # /v2/resolve, check devbox.lock diff --git a/go.mod b/go.mod index 035214dd0bf..af656942ae2 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-envparse v0.1.0 + github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/joho/godotenv v1.5.1 github.com/mattn/go-isatty v0.0.24 github.com/mholt/archives v0.1.5 diff --git a/go.sum b/go.sum index 95247dbafa2..c99068064c6 100644 --- a/go.sum +++ b/go.sum @@ -455,6 +455,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFlmffSQ= +github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jgautheron/goconst v1.11.0 h1:KgN90z5qXt5f0Uzf3cWXev3hfMMFUyNeKdpkSBRvLDk= github.com/jgautheron/goconst v1.11.0/go.mod h1:0p+wv1lFOiUr0IlNNT1nrm6+8DB8u2sU6KHGzFRXHDc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= diff --git a/internal/boxcli/search.go b/internal/boxcli/search.go index 0a5b481314a..0fc759cd63d 100644 --- a/internal/boxcli/search.go +++ b/internal/boxcli/search.go @@ -4,12 +4,15 @@ package boxcli import ( + "bytes" "fmt" "io" - "math" + "slices" "strings" + "text/tabwriter" - "github.com/samber/lo" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/spf13/cobra" "go.jetify.com/devbox/internal/boxcli/usererr" @@ -33,12 +36,17 @@ func searchCmd() *cobra.Command { query := args[0] name, version, isVersioned := searcher.ParseVersionedPackage(query) if !isVersioned { - results, err := searcher.Client().Search(cmd.Context(), query) + client := searcher.Client() + limit := trimmedVersionsLength + 1 + if flags.showAll { + limit = 0 + } + results, err := client.Search(cmd.Context(), query, limit) if err != nil { return err } return printSearchResults( - cmd.OutOrStdout(), query, results, flags.showAll, + cmd.OutOrStdout(), query, results, flags.showAll, client.Host(), ) } packageVersion, err := searcher.Client().Resolve(name, version) @@ -71,6 +79,7 @@ func printSearchResults( query string, results *searcher.SearchResults, showAll bool, + host string, ) error { if len(results.Packages) == 0 { fmt.Fprintf(w, "No results found for %q\n", query) @@ -87,41 +96,87 @@ func printSearchResults( pkgs := results.Packages if !showAll && len(pkgs) > trimmedVersionsLength { resultsAreTrimmed = true - pkgs = results.Packages[:int(math.Min(10, float64(len(results.Packages))))] + pkgs = pkgs[:trimmedVersionsLength] + } + + tableWriter := table.NewWriter() + tableWriter.AppendHeader(table.Row{"Package", "Versions", "Platforms"}) + appendRow := func(name string, versions, systems []string) { + versionStr := columnize(versions, 2) + systemColumns := 1 + if strings.Count(versionStr, "\n")+1 < len(systems) { + systemColumns = 2 + } + tableWriter.AppendRow(table.Row{name, versionStr, columnize(systems, systemColumns)}) } for _, pkg := range pkgs { - nonEmptyVersions := []string{} - for i, v := range pkg.Versions { - if !showAll && i >= trimmedVersionsLength { + var versions, systems []string + shown := 0 + for _, pkgVersion := range pkg.Versions { + if pkgVersion.Version == "" { + continue + } + if !showAll && shown >= trimmedVersionsLength { resultsAreTrimmed = true break } - if v.Version != "" { - nonEmptyVersions = append(nonEmptyVersions, v.Version) - } - } + shown++ - versionString := "" - if len(nonEmptyVersions) > 0 { - ellipses := lo.Ternary(resultsAreTrimmed && pkg.NumVersions > trimmedVersionsLength, " ...", "") - if showAll { - versionString = fmt.Sprintf("\n > %s \n", strings.Join(nonEmptyVersions, "\n > ")) - } else { - versionString = fmt.Sprintf(" (%s%s)", strings.Join(nonEmptyVersions, ", "), ellipses) + if len(versions) > 0 && !slices.Equal(systems, pkgVersion.Systems) { + appendRow(pkg.Name, versions, systems) + versions = nil } + versions = append(versions, pkgVersion.Version) + systems = pkgVersion.Systems + } + if len(versions) > 0 || shown == 0 { + appendRow(pkg.Name, versions, systems) } - fmt.Fprintf(w, "* %s %s\n", pkg.Name, versionString) } + tableWriter.SetColumnConfigs([]table.ColumnConfig{ + {Number: 1, AutoMerge: true, VAlign: text.VAlignMiddle}, + {Number: 2, AlignHeader: text.AlignCenter}, + {Number: 3, AlignHeader: text.AlignCenter}, + }) + tableWriter.SetStyle(table.StyleLight) + tableWriter.Style().Options.SeparateRows = true + fmt.Fprintln(w, tableWriter.Render()) + if resultsAreTrimmed { - fmt.Println() + fmt.Fprintln(w) ux.Fwarningf( w, - "Showing top 10 results and truncated versions. Use --show-all to "+ + "Showing top %d results and truncated versions. Use --show-all to "+ "show all.\n\n", + trimmedVersionsLength, ) } + fmt.Fprintf(w, "For more information go to: %s/\n", strings.TrimSuffix(host, "/")) return nil } + +func columnize(data []string, maxColumns int) string { + columns := maxColumns + if len(data) <= columns { + columns = 1 + } + + buf := bytes.NewBufferString("") + var versionsGroup []string + writer := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0) + for _, version := range data { + if len(versionsGroup) == columns { + _, _ = fmt.Fprintf(writer, "%s\n", strings.Join(versionsGroup, "\t")) + versionsGroup = nil + } + versionsGroup = append(versionsGroup, version) + } + if len(versionsGroup) > 0 { + _, _ = fmt.Fprintf(writer, "%s\n", strings.Join(versionsGroup, "\t")) + } + _ = writer.Flush() + return strings.TrimSuffix(buf.String(), "\n") +} diff --git a/internal/boxcli/search_test.go b/internal/boxcli/search_test.go new file mode 100644 index 00000000000..231f1c0ca69 --- /dev/null +++ b/internal/boxcli/search_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 Jetify Inc. and contributors. All rights reserved. +// Use of this source code is governed by the license in the LICENSE file. + +package boxcli + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "go.jetify.com/devbox/internal/searcher" +) + +const testHost = "https://www.nixsearch.com" + +func searchVersion(version string, systems ...string) searcher.SearchRelease { + return searcher.SearchRelease{Version: version, Systems: systems} +} + +func TestPrintSearchResultsGroupsVersionsBySystems(t *testing.T) { + results := &searcher.SearchResults{ + NumResults: 1, + Packages: []searcher.Package{{ + Name: "ripgrep", + Versions: []searcher.SearchRelease{ + searchVersion("15.0.0", "aarch64-darwin", "x86_64-linux"), + searchVersion("14.1.1", "aarch64-darwin", "x86_64-linux"), + searchVersion("12.1.1", "x86_64-linux"), + }, + }}, + } + + var buf bytes.Buffer + if err := printSearchResults(&buf, "ripgrep", results, false, testHost); err != nil { + t.Fatal(err) + } + want := `Found 1+ results for "ripgrep": + +┌─────────┬──────────┬────────────────┐ +│ PACKAGE │ VERSIONS │ PLATFORMS │ +├─────────┼──────────┼────────────────┤ +│ ripgrep │ 15.0.0 │ aarch64-darwin │ +│ │ 14.1.1 │ x86_64-linux │ +│ ├──────────┼────────────────┤ +│ │ 12.1.1 │ x86_64-linux │ +└─────────┴──────────┴────────────────┘ +For more information go to: https://www.nixsearch.com/ +` + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("printSearchResults() mismatch (-want +got):\n%s", diff) + } +} + +func TestPrintSearchResultsTrimsVersions(t *testing.T) { + pkg := searcher.Package{Name: "go"} + for i := range trimmedVersionsLength + 1 { + pkg.Versions = append(pkg.Versions, searchVersion(fmt.Sprintf("1.%d.0", i), "x86_64-linux")) + } + results := &searcher.SearchResults{NumResults: 1, Packages: []searcher.Package{pkg}} + + var buf bytes.Buffer + if err := printSearchResults(&buf, "go", results, false, testHost); err != nil { + t.Fatal(err) + } + if out := buf.String(); strings.Contains(out, "1.10.0") || !strings.Contains(out, "Use --show-all") { + t.Errorf("versions are not trimmed:\n%s", out) + } + + buf.Reset() + if err := printSearchResults(&buf, "go", results, true, testHost); err != nil { + t.Fatal(err) + } + if out := buf.String(); !strings.Contains(out, "1.10.0") || strings.Contains(out, "Use --show-all") { + t.Errorf("--show-all trimmed versions:\n%s", out) + } +} + +func TestPrintSearchResultsKeepsPackagesWithoutVersions(t *testing.T) { + results := &searcher.SearchResults{ + NumResults: 2, + Packages: []searcher.Package{ + {Name: "1.0", Versions: []searcher.SearchRelease{searchVersion("1.0", "x86_64-linux")}}, + {Name: "empty"}, + }, + } + + var buf bytes.Buffer + if err := printSearchResults(&buf, "q", results, false, "http://localhost:8080/"); err != nil { + t.Fatal(err) + } + want := `Found 2+ results for "q": + +┌─────────┬──────────┬──────────────┐ +│ PACKAGE │ VERSIONS │ PLATFORMS │ +├─────────┼──────────┼──────────────┤ +│ 1.0 │ 1.0 │ x86_64-linux │ +├─────────┼──────────┼──────────────┤ +│ empty │ │ │ +└─────────┴──────────┴──────────────┘ +For more information go to: http://localhost:8080/ +` + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("printSearchResults() mismatch (-want +got):\n%s", diff) + } +} + +func TestColumnizeAlignsColumns(t *testing.T) { + got := columnize([]string{"1.10.0", "1.9.0", "1.8.10", "1.7.0", "1.6.0"}, 2) + want := "1.10.0 1.9.0\n1.8.10 1.7.0\n1.6.0" + if got != want { + t.Errorf("columnize() = %q, want %q", got, want) + } +} diff --git a/internal/searcher/client.go b/internal/searcher/client.go index f2f5e34de07..ea8c958cae6 100644 --- a/internal/searcher/client.go +++ b/internal/searcher/client.go @@ -12,9 +12,14 @@ import ( "net/http" "net/url" "runtime" + "slices" + "strconv" + "strings" "time" "github.com/pkg/errors" + "golang.org/x/sync/errgroup" + "go.jetify.com/devbox/internal/build" "go.jetify.com/devbox/internal/envir" "go.jetify.com/devbox/internal/redact" @@ -34,18 +39,136 @@ func Client() *client { } } -func (c *client) Search(ctx context.Context, query string) (*SearchResults, error) { +func (c *client) Host() string { + return c.host +} + +// Search returns the packages that match query, most relevant first. A +// positive limit caps both the number of packages and each package's newest +// releases; otherwise every package and release is returned. +func (c *client) Search(ctx context.Context, query string, limit int) (*SearchResults, error) { if query == "" { return nil, fmt.Errorf("query should not be empty") } - endpoint, err := url.JoinPath(c.host, "v1/search") + endpoint, err := url.JoinPath(c.host, "v2/search") if err != nil { return nil, errors.WithStack(err) } - searchURL := endpoint + "?q=" + url.QueryEscape(query) + releases := "all" + if limit > 0 { + releases = strconv.Itoa(limit) + } + searchURL := endpoint + "?q=" + url.QueryEscape(query) + "&releases=" + releases + + searchResults, err := execGet[SearchResultsV2](ctx, searchURL) + if err != nil { + return nil, err + } + hits := searchResults.Results + if limit > 0 && len(hits) > limit { + hits = hits[:limit] + } + + found := make([]*Package, len(hits)) + var lookups []int + for i, hit := range hits { + if hit.Releases == nil { + lookups = append(lookups, i) + continue + } + versions := make([]SearchRelease, len(hit.Releases)) + for j, release := range hit.Releases { + versions[j] = SearchRelease{Version: release.Version, Systems: sortedSystems(release.Systems)} + } + found[i] = &Package{Name: hit.Name, Versions: versions} + } + if err := c.lookupPackages(ctx, hits, lookups, limit, found); err != nil { + return nil, err + } + + results := &SearchResults{NumResults: searchResults.TotalResults} + for _, pkg := range found { + if pkg != nil { + results.Packages = append(results.Packages, *pkg) + } + } + return results, nil +} + +// lookupPackages fills found[i] from /v2/pkg for each index in lookups, for +// search services that don't return releases inline. A package whose lookup +// fails is left out, unless every lookup fails. +func (c *client) lookupPackages(ctx context.Context, hits []SearchResultV2, lookups []int, limit int, found []*Package) error { + if len(lookups) == 0 { + return nil + } + pkgEndpoint, err := url.JoinPath(c.host, "v2/pkg") + if err != nil { + return errors.WithStack(err) + } + + errs := make([]error, len(lookups)) + var group errgroup.Group + group.SetLimit(8) + for n, i := range lookups { + group.Go(func() error { + pkgURL := pkgEndpoint + "?name=" + url.QueryEscape(hits[i].Name) + packageInfo, err := execGet[PackageV2](ctx, pkgURL) + errs[n] = err + if err == nil { + pkg := newPackage(packageInfo, limit) + found[i] = &pkg + } + return nil + }) + } + _ = group.Wait() + + var firstErr error + for _, err := range errs { + if err == nil { + return nil + } + if firstErr == nil && !errors.Is(err, ErrNotFound) { + firstErr = err + } + } + return firstErr +} - return execGet[SearchResults](ctx, searchURL) +func newPackage(info *PackageV2, limit int) Package { + releases := info.Releases + if limit > 0 && len(releases) > limit { + releases = releases[:limit] + } + versions := make([]SearchRelease, len(releases)) + for i, release := range releases { + systems := make([]string, len(release.Platforms)) + for j, platform := range release.Platforms { + systems[j] = platform.nixSystem() + } + versions[i] = SearchRelease{Version: release.Version, Systems: sortedSystems(systems)} + } + return Package{Name: info.Name, Versions: versions} +} + +func sortedSystems(systems []string) []string { + sorted := slices.Clone(systems) + slices.Sort(sorted) + return slices.Compact(sorted) +} + +func (p Platform) nixSystem() string { + arch := strings.ReplaceAll(p.Arch, "-", "_") + if arch == "arm64" { + arch = "aarch64" + } + goos := strings.ToLower(p.OS) + if goos == "macos" { + goos = "darwin" + } + return arch + "-" + goos } // Resolve calls the /resolve endpoint of the search service. This returns diff --git a/internal/searcher/client_test.go b/internal/searcher/client_test.go index 21b56791a66..dea8435503c 100644 --- a/internal/searcher/client_test.go +++ b/internal/searcher/client_test.go @@ -8,9 +8,13 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" + "sync" "sync/atomic" "testing" "time" + + "github.com/google/go-cmp/cmp" ) func TestExecGetRetries(t *testing.T) { @@ -104,3 +108,178 @@ func TestExecGetStopsRetryingWhenCanceled(t *testing.T) { t.Errorf("got %d requests, want 1", n) } } + +func TestSearchInlineReleases(t *testing.T) { + var gotReleases string + var pkgCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/v2/search", func(w http.ResponseWriter, r *http.Request) { + gotReleases = r.URL.Query().Get("releases") + _, _ = w.Write([]byte(`{"total_results": 3, "results": [ + {"name": "ripgrep", "releases": [ + {"version": "15.0.0", "systems": ["x86_64-linux", "aarch64-darwin"]}, + {"version": "14.1.1", "systems": []} + ]}, + {"name": "ripgrep-all", "releases": []}, + {"name": "past-limit", "releases": []} + ]}`)) + }) + mux.HandleFunc("/v2/pkg", func(w http.ResponseWriter, r *http.Request) { + pkgCalls.Add(1) + http.NotFound(w, r) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + got, err := (&client{host: server.URL}).Search(t.Context(), "ripgrep", 2) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if gotReleases != "2" { + t.Errorf("releases = %q, want %q", gotReleases, "2") + } + if n := pkgCalls.Load(); n != 0 { + t.Errorf("made %d /v2/pkg calls, want 0", n) + } + want := &SearchResults{ + NumResults: 3, + Packages: []Package{ + {Name: "ripgrep", Versions: []SearchRelease{ + {Version: "15.0.0", Systems: []string{"aarch64-darwin", "x86_64-linux"}}, + {Version: "14.1.1", Systems: []string{}}, + }}, + {Name: "ripgrep-all", Versions: []SearchRelease{}}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Search() mismatch (-want +got):\n%s", diff) + } +} + +func TestSearchAllReleases(t *testing.T) { + var gotReleases string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotReleases = r.URL.Query().Get("releases") + _, _ = w.Write([]byte(`{"total_results": 0, "results": []}`)) + })) + t.Cleanup(server.Close) + + if _, err := (&client{host: server.URL}).Search(t.Context(), "go", 0); err != nil { + t.Fatalf("Search() error = %v", err) + } + if gotReleases != "all" { + t.Errorf("releases = %q, want %q", gotReleases, "all") + } +} + +func TestSearchFallsBackToPkg(t *testing.T) { + origBackoff := retryBackoff + retryBackoff = func(int) time.Duration { return 0 } + t.Cleanup(func() { retryBackoff = origBackoff }) + + var lookedMu sync.Mutex + var looked []string + mux := http.NewServeMux() + mux.HandleFunc("/v2/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"total_results": 14, "results": [ + {"name": "ripgrep"}, {"name": "removed"}, {"name": "broken"}, + {"name": "ripgrep-all"}, {"name": "past-limit"} + ]}`)) + }) + mux.HandleFunc("/v2/pkg", func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + lookedMu.Lock() + looked = append(looked, name) + lookedMu.Unlock() + switch name { + case "ripgrep": + _, _ = w.Write([]byte(`{"name": "ripgrep", "releases": [ + {"version": "15.0.0", "platforms": [ + {"arch": "x86-64", "os": "Linux", "attribute_path": "ripgrep"}, + {"arch": "arm64", "os": "macOS", "attribute_path": "ripgrep"}, + {"arch": "x86-64", "os": "Linux", "attribute_path": "rg"} + ]}, + {"version": "14.1.1", "platforms": []}, + {"version": "13.0.0", "platforms": []}, + {"version": "12.1.1", "platforms": []}, + {"version": "11.0.2", "platforms": []} + ]}`)) + case "ripgrep-all": + _, _ = w.Write([]byte(`{"name": "ripgrep-all", "releases": [ + {"version": "0.10.10", "platforms": [{"arch": "x86-64", "os": "Linux"}]} + ]}`)) + case "broken": + http.Error(w, "boom", http.StatusInternalServerError) + default: + http.NotFound(w, r) + } + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + got, err := (&client{host: server.URL}).Search(t.Context(), "ripgrep", 4) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + slices.Sort(looked) + looked = slices.Compact(looked) + if diff := cmp.Diff([]string{"broken", "removed", "ripgrep", "ripgrep-all"}, looked); diff != "" { + t.Errorf("/v2/pkg lookups mismatch (-want +got):\n%s", diff) + } + want := &SearchResults{ + NumResults: 14, + Packages: []Package{ + {Name: "ripgrep", Versions: []SearchRelease{ + {Version: "15.0.0", Systems: []string{"aarch64-darwin", "x86_64-linux"}}, + {Version: "14.1.1", Systems: []string{}}, + {Version: "13.0.0", Systems: []string{}}, + {Version: "12.1.1", Systems: []string{}}, + }}, + {Name: "ripgrep-all", Versions: []SearchRelease{ + {Version: "0.10.10", Systems: []string{"x86_64-linux"}}, + }}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("Search() mismatch (-want +got):\n%s", diff) + } +} + +func TestSearchFailsWhenEveryLookupFails(t *testing.T) { + origBackoff := retryBackoff + retryBackoff = func(int) time.Duration { return 0 } + t.Cleanup(func() { retryBackoff = origBackoff }) + + mux := http.NewServeMux() + mux.HandleFunc("/v2/search", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"total_results": 2, "results": [{"name": "removed"}, {"name": "broken"}]}`)) + }) + mux.HandleFunc("/v2/pkg", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("name") == "broken" { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + http.NotFound(w, r) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + if _, err := (&client{host: server.URL}).Search(t.Context(), "q", 0); err == nil { + t.Error("Search() error = nil, want the failed lookup's error") + } +} + +func TestSearchNoResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"total_results": 0, "results": []}`)) + })) + t.Cleanup(server.Close) + + got, err := (&client{host: server.URL}).Search(t.Context(), "nothing", 0) + if err != nil { + t.Fatalf("Search() error = %v", err) + } + if len(got.Packages) != 0 { + t.Errorf("Search() packages = %v, want none", got.Packages) + } +} diff --git a/internal/searcher/model.go b/internal/searcher/model.go index a54e663e646..9960788582e 100644 --- a/internal/searcher/model.go +++ b/internal/searcher/model.go @@ -9,15 +9,65 @@ import ( "go.jetify.com/devbox/nix/flake" ) +type SearchResultsV2 struct { + Query string `json:"query"` + TotalResults int `json:"total_results"` + Results []SearchResultV2 `json:"results"` +} + +type SearchResultV2 struct { + Name string `json:"name"` + Summary string `json:"summary"` + LastUpdated time.Time `json:"last_updated"` + + // Releases is nil when the search service doesn't support the releases + // parameter, and the package's releases must come from /v2/pkg instead. + Releases []SearchRelease `json:"releases"` +} + +type SearchRelease struct { + Version string `json:"version"` + Systems []string `json:"systems"` +} + +type PackageV2 struct { + Name string `json:"name"` + Summary string `json:"summary"` + HomepageURL string `json:"homepage_url"` + License string `json:"license"` + Releases []Release `json:"releases"` +} + +type Release struct { + Version string `json:"version"` + LastUpdated time.Time `json:"last_updated"` + Platforms []Platform `json:"platforms"` +} + +type Platform struct { + Arch string `json:"arch"` + OS string `json:"os"` + AttributePath string `json:"attribute_path"` + CommitHash string `json:"commit_hash"` + Date time.Time `json:"date"` + Outputs []Output `json:"outputs"` +} + +type Output struct { + Name string `json:"name"` + Path string `json:"path"` + Default bool `json:"default"` + Nar string `json:"nar"` +} + type SearchResults struct { NumResults int `json:"num_results"` Packages []Package `json:"packages,omitempty"` } type Package struct { - Name string `json:"name"` - NumVersions int `json:"num_versions"` - Versions []PackageVersion `json:"versions,omitempty"` + Name string `json:"name"` + Versions []SearchRelease `json:"versions,omitempty"` } type PackageVersion struct { diff --git a/vendor-hash b/vendor-hash index 9a289217e05..aabc3eefe80 100644 --- a/vendor-hash +++ b/vendor-hash @@ -1 +1 @@ -sha256-y4tb40DDhwA+jPNBfsgeCF8NsSrxf7uRroKJCz8sskY= +sha256-HSWDqEGmqZtkQHw96lYewQFDBWt7zbV3xtFXyf07llM=