From b3355419b4709b8e1bab0d834b620592b1d50a99 Mon Sep 17 00:00:00 2001 From: Luke Russell Date: Wed, 24 Jun 2026 11:33:19 -0700 Subject: [PATCH] feat: Add --category flag to docs search command Add a --category flag to `slack docs search` so results can be filtered by category, matching the filters offered by the docs site search modal: guides, reference, changelog, python, javascript, java, slack_cli, slack_github_action, deno_slack_sdk. - internal/api: thread category through DocsSearch, the DocsClient interface, the URL builder (appends &category= when set), and the mock. Add DocsSearchCategories as the shared source of valid values. - cmd/docs: add the --category flag, validate it against DocsSearchCategories (like --output), and pass it to the API for text and json output. For browser output, append &filter= to the search page URL for parity with the site. An empty category preserves the existing search-everything behavior. Docs reference regeneration (slack docgen) happens at release, not here. Co-Authored-By: Claude --- cmd/docs/search.go | 36 +++++++++++++++++----- cmd/docs/search_test.go | 65 +++++++++++++++++++++++++++++++++------ internal/api/api_mock.go | 4 +-- internal/api/docs.go | 26 +++++++++++++--- internal/api/docs_test.go | 37 ++++++++++++++++++++-- 5 files changed, 142 insertions(+), 26 deletions(-) diff --git a/cmd/docs/search.go b/cmd/docs/search.go index 52672d92..12fcbc66 100644 --- a/cmd/docs/search.go +++ b/cmd/docs/search.go @@ -18,8 +18,10 @@ import ( "encoding/json" "fmt" "net/url" + "slices" "strings" + "github.com/slackapi/slack-cli/internal/api" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" @@ -27,14 +29,19 @@ import ( "github.com/spf13/cobra" ) -func buildDocsSearchURL(query string) string { - encodedQuery := url.QueryEscape(query) - return fmt.Sprintf("%s/search/?q=%s", docsURL, encodedQuery) +func buildDocsSearchURL(query, category string) string { + params := url.Values{} + params.Set("q", query) + if category != "" { + params.Set("filter", category) + } + return fmt.Sprintf("%s/search/?%s", docsURL, params.Encode()) } type searchConfig struct { - output string - limit int + output string + limit int + category string } func makeAbsoluteURL(relativeURL string) string { @@ -67,6 +74,10 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { Meaning: "Search docs with limited JSON results", Command: "docs search \"api\" --output=json --limit=5", }, + { + Meaning: "Search only the API reference docs", + Command: "docs search \"chat.postMessage\" --category=reference", + }, }), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -76,6 +87,7 @@ func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser") cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of text or json search results to return") + cmd.Flags().StringVar(&cfg.category, "category", "", fmt.Sprintf("filter results by category: %s", strings.Join(api.DocsSearchCategories, ", "))) return cmd } @@ -85,9 +97,17 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg query := strings.Join(args, " ") + if cfg.category != "" && !slices.Contains(api.DocsSearchCategories, cfg.category) { + return slackerror.New(slackerror.ErrInvalidFlag).WithMessage( + "Invalid category: %s", cfg.category, + ).WithRemediation( + "Use one of: %s", strings.Join(api.DocsSearchCategories, ", "), + ) + } + switch cfg.output { case "json": - searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit) + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category) if err != nil { return err } @@ -106,7 +126,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg return nil case "text": - searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit) + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit, cfg.category) if err != nil { return err } @@ -144,7 +164,7 @@ func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, arg return nil case "browser": - docsSearchURL := buildDocsSearchURL(query) + docsSearchURL := buildDocsSearchURL(query, cfg.category) clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "books", diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go index 1586eab5..bfd0611a 100644 --- a/cmd/docs/search_test.go +++ b/cmd/docs/search_test.go @@ -32,7 +32,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns text results": { CmdArgs: []string{"search", "Block Kit"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ TotalResults: 2, Limit: 20, Results: []api.DocsSearchItem{ @@ -52,7 +52,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns JSON results": { CmdArgs: []string{"search", "Block Kit", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit", 20, "").Return(&api.DocsSearchResponse{ TotalResults: 2, Limit: 20, Results: []api.DocsSearchItem{ @@ -85,7 +85,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns JSON results with absolute URLs": { CmdArgs: []string{"search", "test", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(&api.DocsSearchResponse{ TotalResults: 1, Limit: 20, Results: []api.DocsSearchItem{ @@ -103,7 +103,7 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns empty results": { CmdArgs: []string{"search", "nonexistent"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "nonexistent", 20, "").Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, @@ -116,41 +116,86 @@ func Test_Docs_SearchCommand(t *testing.T) { "returns error on API failure": { CmdArgs: []string{"search", "test"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) }, ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed}, }, "returns error on API failure for JSON output": { CmdArgs: []string{"search", "test", "--output=json"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 20).Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) + cm.API.On("DocsSearch", mock.Anything, "test", 20, "").Return(nil, slackerror.New(slackerror.ErrHTTPRequestFailed)) }, ExpectedErrorStrings: []string{slackerror.ErrHTTPRequestFailed}, }, "passes custom limit": { CmdArgs: []string{"search", "test", "--limit=5"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "test", 5).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "test", 5, "").Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 5, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5) + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "test", 5, "") }, }, "joins multiple arguments into query": { CmdArgs: []string{"search", "Block", "Kit", "Element"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20).Return(&api.DocsSearchResponse{ + cm.API.On("DocsSearch", mock.Anything, "Block Kit Element", 20, "").Return(&api.DocsSearchResponse{ TotalResults: 0, Results: []api.DocsSearchItem{}, Limit: 20, }, nil) }, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20) + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "Block Kit Element", 20, "") + }, + }, + "passes category to API for text output": { + CmdArgs: []string{"search", "chat.postMessage", "--category=reference"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsSearch", mock.Anything, "chat.postMessage", 20, "reference").Return(&api.DocsSearchResponse{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "chat.postMessage", URL: "/reference/methods/chat.postMessage"}, + }, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "chat.postMessage", 20, "reference") + }, + }, + "passes category to API for json output": { + CmdArgs: []string{"search", "events", "--category=reference", "--output=json"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.API.On("DocsSearch", mock.Anything, "events", 20, "reference").Return(&api.DocsSearchResponse{ + TotalResults: 0, + Results: []api.DocsSearchItem{}, + Limit: 20, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DocsSearch", mock.Anything, "events", 20, "reference") + }, + }, + "rejects invalid category": { + CmdArgs: []string{"search", "test", "--category=bogus"}, + ExpectedErrorStrings: []string{ + "Invalid category", + "reference", + }, + }, + "opens browser with category filter": { + CmdArgs: []string{"search", "webhooks", "--category=reference", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/search/?filter=reference&q=webhooks") + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + ExpectedOutputs: []string{ + "https://docs.slack.dev/search/?filter=reference&q=webhooks", }, }, "rejects invalid output format": { diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 951b60bb..3feb315f 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -384,8 +384,8 @@ func (m *APIMock) DeveloperAppInstall(ctx context.Context, IO iostreams.IOStream // DocsClient -func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { - args := m.Called(ctx, query, limit) +func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) { + args := m.Called(ctx, query, limit, category) if args.Get(0) == nil { return nil, args.Error(1) } diff --git a/internal/api/docs.go b/internal/api/docs.go index 9d17b654..05a3a103 100644 --- a/internal/api/docs.go +++ b/internal/api/docs.go @@ -30,8 +30,23 @@ var docsBaseURL = "https://docs.slack.dev" const docsSearchMethod = "api/v1/search" +// DocsSearchCategories lists the categories the docs search endpoint accepts, +// mirroring the filters offered by the docs site search modal. An empty +// category searches across all content. +var DocsSearchCategories = []string{ + "guides", + "reference", + "changelog", + "python", + "javascript", + "java", + "slack_cli", + "slack_github_action", + "deno_slack_sdk", +} + type DocsClient interface { - DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) + DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) } type DocsSearchResponse struct { @@ -45,8 +60,11 @@ type DocsSearchItem struct { Title string `json:"title"` } -func buildDocsSearchURL(baseURL, query string, limit int) (string, error) { +func buildDocsSearchURL(baseURL, query string, limit int, category string) (string, error) { endpoint := fmt.Sprintf("%s?query=%s&limit=%d", docsSearchMethod, url.QueryEscape(query), limit) + if category != "" { + endpoint += fmt.Sprintf("&category=%s", url.QueryEscape(category)) + } sURL, err := url.Parse(baseURL + "/" + endpoint) if err != nil { return "", err @@ -64,12 +82,12 @@ func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*ht } // DocsSearch searches the Slack developer docs API -func (c *Client) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { +func (c *Client) DocsSearch(ctx context.Context, query string, limit int, category string) (*DocsSearchResponse, error) { var span opentracing.Span span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsSearch") defer span.Finish() - urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit) + urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit, category) if err != nil { return nil, errHTTPRequestFailed.WithRootCause(err) } diff --git a/internal/api/docs_test.go b/internal/api/docs_test.go index 4ffbc792..b3da049f 100644 --- a/internal/api/docs_test.go +++ b/internal/api/docs_test.go @@ -28,6 +28,7 @@ func Test_buildDocsSearchURL(t *testing.T) { baseURL string query string limit int + category string expectedURL string expectedErrorContains string }{ @@ -43,6 +44,20 @@ func Test_buildDocsSearchURL(t *testing.T) { limit: 5, expectedURL: "https://docs.slack.dev/api/v1/search?query=messages+%26+webhooks&limit=5", }, + "appends category when set": { + baseURL: "https://docs.slack.dev", + query: "chat.postMessage", + limit: 20, + category: "reference", + expectedURL: "https://docs.slack.dev/api/v1/search?query=chat.postMessage&limit=20&category=reference", + }, + "omits category when empty": { + baseURL: "https://docs.slack.dev", + query: "test", + limit: 20, + category: "", + expectedURL: "https://docs.slack.dev/api/v1/search?query=test&limit=20", + }, "returns error for invalid base URL": { baseURL: "ht!tp://invalid", query: "test", @@ -52,7 +67,7 @@ func Test_buildDocsSearchURL(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit) + url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit, tc.category) if tc.expectedErrorContains != "" { require.Error(t, err) @@ -103,6 +118,7 @@ func Test_Client_DocsSearch(t *testing.T) { tests := map[string]struct { query string limit int + category string response string statusCode int expectedQuerystring string @@ -129,6 +145,23 @@ func Test_Client_DocsSearch(t *testing.T) { }, }, }, + "includes category in querystring": { + query: "chat.postMessage", + limit: 20, + category: "reference", + response: `{"total_results":1,"limit":20,"results":[{"title":"chat.postMessage","url":"/reference/methods/chat.postMessage"}]}`, + expectedQuerystring: "query=chat.postMessage&limit=20&category=reference", + expectedResponse: &DocsSearchResponse{ + TotalResults: 1, + Limit: 20, + Results: []DocsSearchItem{ + { + Title: "chat.postMessage", + URL: "/reference/methods/chat.postMessage", + }, + }, + }, + }, "returns empty results": { query: "nonexistent", limit: 20, @@ -178,7 +211,7 @@ func Test_Client_DocsSearch(t *testing.T) { docsBaseURL = c.Host() defer func() { docsBaseURL = originalURL }() - result, err := c.DocsSearch(ctx, tc.query, tc.limit) + result, err := c.DocsSearch(ctx, tc.query, tc.limit, tc.category) if tc.expectedErrorContains != "" { require.Error(t, err)