diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9b47c3e8..31ab091f 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -16,7 +16,6 @@ package docs import ( "fmt" - "net/url" "strings" "github.com/slackapi/slack-cli/internal/shared" @@ -26,13 +25,15 @@ import ( "github.com/spf13/cobra" ) +const docsURL = "https://docs.slack.dev" + var searchMode bool func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "docs", Short: "Open Slack developer docs", - Long: "Open the Slack developer docs in your browser, with optional search functionality", + Long: "Open the Slack developer docs in your browser or search them using the search subcommand", Example: style.ExampleCommandsf([]style.ExampleCommand{ { Meaning: "Open Slack developer docs homepage", @@ -40,20 +41,26 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { }, { Meaning: "Search Slack developer docs for Block Kit", - Command: "docs --search \"Block Kit\"", + Command: "docs search \"Block Kit\"", }, { - Meaning: "Open Slack docs search page", - Command: "docs --search", + Meaning: "Search docs and open results in browser", + Command: "docs search \"Block Kit\" --output=browser", }, }), + Args: cobra.ArbitraryArgs, // Allow any arguments RunE: func(cmd *cobra.Command, args []string) error { return runDocsCommand(clients, cmd, args) }, + // Disable automatic suggestions for unknown commands + DisableSuggestions: true, } cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query") + // Add the search subcommand + cmd.AddCommand(NewSearchCommand(clients)) + return cmd } @@ -61,15 +68,17 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { ctx := cmd.Context() - var docsURL string + var finalURL string var sectionText string - // Validate: if there are arguments, --search flag must be used + // Validate: if there are arguments, search subcommand must be used if len(args) > 0 && !cmd.Flags().Changed("search") { query := strings.Join(args, " ") - return slackerror.New(slackerror.ErrDocsSearchFlagRequired).WithRemediation( - "Use --search flag: %s", - style.Commandf(fmt.Sprintf("docs --search \"%s\"", query), false), + return slackerror.New(slackerror.ErrDocsSearchFlagRequired).WithMessage( + "Invalid docs command. Did you mean to search?", + ).WithRemediation( + "Use search subcommand: %s", + style.Commandf(fmt.Sprintf("docs search \"%s\"", query), false), ) } @@ -77,17 +86,16 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st if len(args) > 0 { // --search "query" (space-separated) - join all args as the query query := strings.Join(args, " ") - encodedQuery := url.QueryEscape(query) - docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) + finalURL = buildDocsSearchURL(query) sectionText = "Docs Search" } else { // --search (no argument) - open search page - docsURL = "https://docs.slack.dev/search/" + finalURL = fmt.Sprintf("%s/search/", docsURL) sectionText = "Docs Search" } } else { // No search flag: default homepage - docsURL = "https://docs.slack.dev" + finalURL = docsURL sectionText = "Docs Open" } @@ -95,11 +103,11 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st Emoji: "books", Text: sectionText, Secondary: []string{ - docsURL, + finalURL, }, })) - clients.Browser().OpenURL(docsURL) + clients.Browser().OpenURL(finalURL) if cmd.Flags().Changed("search") { traceValue := "" diff --git a/cmd/docs/search.go b/cmd/docs/search.go new file mode 100644 index 00000000..52672d92 --- /dev/null +++ b/cmd/docs/search.go @@ -0,0 +1,168 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +func buildDocsSearchURL(query string) string { + encodedQuery := url.QueryEscape(query) + return fmt.Sprintf("%s/search/?q=%s", docsURL, encodedQuery) +} + +type searchConfig struct { + output string + limit int +} + +func makeAbsoluteURL(relativeURL string) string { + if strings.HasPrefix(relativeURL, "http") { + return relativeURL + } + return docsURL + relativeURL +} + +func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command { + cfg := &searchConfig{} + + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Search Slack developer docs", + Long: strings.Join([]string{ + "Search the Slack developer docs and return results in text, JSON, or browser", + "format.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Search docs and return text results", + Command: "docs search \"Block Kit\"", + }, + { + Meaning: "Search docs and open results in browser", + Command: "docs search \"webhooks\" --output=browser", + }, + { + Meaning: "Search docs with limited JSON results", + Command: "docs search \"api\" --output=json --limit=5", + }, + }), + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDocsSearchCommand(clients, cmd, args, cfg) + }, + } + + 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") + + return cmd +} + +func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, cfg *searchConfig) error { + ctx := cmd.Context() + + query := strings.Join(args, " ") + + switch cfg.output { + case "json": + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit) + if err != nil { + return err + } + + for i := range searchResponse.Results { + searchResponse.Results[i].URL = makeAbsoluteURL(searchResponse.Results[i].URL) + } + + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(searchResponse); err != nil { + return slackerror.New(slackerror.ErrUnableToParseJSON).WithRootCause(err) + } + + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil + case "text": + searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit) + if err != nil { + return err + } + + if len(searchResponse.Results) == 0 { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + fmt.Sprintf("Found zero results for \"%s\"", query), + }, + })) + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + return nil + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + fmt.Sprintf("Displaying first %d of %d results for \"%s\"", len(searchResponse.Results), searchResponse.TotalResults, query), + }, + })) + + for _, result := range searchResponse.Results { + absoluteURL := makeAbsoluteURL(result.URL) + clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{ + Emoji: "book", + Text: result.Title, + Secondary: []string{absoluteURL}, + })) + } + + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil + case "browser": + docsSearchURL := buildDocsSearchURL(query) + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "Docs Search", + Secondary: []string{ + docsSearchURL, + }, + })) + + clients.Browser().OpenURL(docsSearchURL) + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query) + + return nil + default: + return slackerror.New(slackerror.ErrInvalidFlag).WithMessage( + "Invalid output format: %s", cfg.output, + ).WithRemediation( + "Use one of: text, json, browser", + ) + } +} diff --git a/cmd/docs/search_test.go b/cmd/docs/search_test.go new file mode 100644 index 00000000..1586eab5 --- /dev/null +++ b/cmd/docs/search_test.go @@ -0,0 +1,197 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package docs + +import ( + "context" + "testing" + + "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" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func Test_Docs_SearchCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "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{ + TotalResults: 2, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "Block Kit", URL: "/block-kit"}, + {Title: "Block Kit Elements", URL: "/block-kit/elements"}, + }, + }, nil) + }, + ExpectedStdoutOutputs: []string{ + "Block Kit", + "https://docs.slack.dev/block-kit", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + }, + "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{ + TotalResults: 2, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "Block Kit", URL: "/block-kit"}, + {Title: "Block Kit Elements", URL: "/block-kit/elements"}, + }, + }, nil) + }, + ExpectedStdoutOutputs: []string{ + `{ + "total_results": 2, + "results": [ + { + "url": "https://docs.slack.dev/block-kit", + "title": "Block Kit" + }, + { + "url": "https://docs.slack.dev/block-kit/elements", + "title": "Block Kit Elements" + } + ], + "limit": 20 +} +`, + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + }, + "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{ + TotalResults: 1, + Limit: 20, + Results: []api.DocsSearchItem{ + {Title: "Test", URL: "https://docs.slack.dev/test"}, + }, + }, nil) + }, + ExpectedStdoutOutputs: []string{ + `"url": "https://docs.slack.dev/test"`, + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + }, + "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{ + TotalResults: 0, + Results: []api.DocsSearchItem{}, + Limit: 20, + }, nil) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + }, + "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)) + }, + 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)) + }, + 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{ + 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) + }, + }, + "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{ + 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) + }, + }, + "rejects invalid output format": { + CmdArgs: []string{"search", "test", "--output=invalid"}, + ExpectedErrorStrings: []string{ + "Invalid output format", + "Use one of: text, json, browser", + }, + }, + "opens browser with search query": { + CmdArgs: []string{"search", "messaging", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/search/?q=messaging") + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything) + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=messaging", + }, + }, + "opens browser with special characters": { + CmdArgs: []string{"search", "messages & webhooks", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/search/?q=messages+%26+webhooks") + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=messages+%26+webhooks", + }, + }, + "opens browser with multiple arguments": { + CmdArgs: []string{"search", "Block", "Kit", "Element", "--output=browser"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Browser.AssertCalled(t, "OpenURL", "https://docs.slack.dev/search/?q=Block+Kit+Element") + }, + ExpectedOutputs: []string{ + "Docs Search", + "https://docs.slack.dev/search/?q=Block+Kit+Element", + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCommand(cf) + }) +} diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index c68b8953..951b60bb 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -381,3 +381,13 @@ func (m *APIMock) DeveloperAppInstall(ctx context.Context, IO iostreams.IOStream args := m.Called(ctx, IO, token, app, botScopes, outgoingDomains, orgGrantWorkspaceID, autoAAARequest) return args.Get(0).(DeveloperAppInstallResult), args.Get(1).(types.InstallState), args.Error(2) } + +// DocsClient + +func (m *APIMock) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { + args := m.Called(ctx, query, limit) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*DocsSearchResponse), args.Error(1) +} diff --git a/internal/api/docs.go b/internal/api/docs.go new file mode 100644 index 00000000..25d0b214 --- /dev/null +++ b/internal/api/docs.go @@ -0,0 +1,103 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "runtime" + + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/slackcontext" +) + +var docsBaseURL = "https://docs.slack.dev" + +const docsSearchMethod = "api/v1/search" + +type DocsClient interface { + DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) +} + +type DocsSearchResponse struct { + TotalResults int `json:"total_results"` + Results []DocsSearchItem `json:"results"` + Limit int `json:"limit"` +} + +type DocsSearchItem struct { + URL string `json:"url"` + Title string `json:"title"` +} + +func buildDocsSearchURL(baseURL, query string, limit int) (string, error) { + endpoint := fmt.Sprintf("%s?query=%s&limit=%d", docsSearchMethod, url.QueryEscape(query), limit) + sURL, err := url.Parse(baseURL + "/" + endpoint) + if err != nil { + return "", err + } + return sURL.String(), nil +} + +func buildDocsSearchRequest(ctx context.Context, urlStr, cliVersion string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Add("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)) + return req, nil +} + +// DocsSearch searches the Slack developer docs API +func (c *Client) DocsSearch(ctx context.Context, query string, limit int) (*DocsSearchResponse, error) { + var span opentracing.Span + span, _ = opentracing.StartSpanFromContext(ctx, "apiclient.DocsSearch") + defer span.Finish() + + urlStr, err := buildDocsSearchURL(docsBaseURL, query, limit) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + sURL, _ := url.Parse(urlStr) + span.SetTag("request_url", sURL) + + cliVersion, err := slackcontext.Version(ctx) + if err != nil { + return nil, err + } + + req, err := buildDocsSearchRequest(ctx, urlStr, cliVersion) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + c.printRequest(ctx, req, false) + + respBytes, err := c.DoWithRetry(ctx, req, span, false, sURL) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + var searchResponse DocsSearchResponse + if err := json.Unmarshal(respBytes, &searchResponse); err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err) + } + + return &searchResponse, nil +} diff --git a/internal/api/docs_test.go b/internal/api/docs_test.go new file mode 100644 index 00000000..4ffbc792 --- /dev/null +++ b/internal/api/docs_test.go @@ -0,0 +1,192 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/stretchr/testify/require" +) + +func Test_buildDocsSearchURL(t *testing.T) { + tests := map[string]struct { + baseURL string + query string + limit int + expectedURL string + expectedErrorContains string + }{ + "builds valid URL": { + baseURL: "https://docs.slack.dev", + query: "Block Kit", + limit: 20, + expectedURL: "https://docs.slack.dev/api/v1/search?query=Block+Kit&limit=20", + }, + "encodes special characters": { + baseURL: "https://docs.slack.dev", + query: "messages & webhooks", + limit: 5, + expectedURL: "https://docs.slack.dev/api/v1/search?query=messages+%26+webhooks&limit=5", + }, + "returns error for invalid base URL": { + baseURL: "ht!tp://invalid", + query: "test", + limit: 20, + expectedErrorContains: "invalid", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + url, err := buildDocsSearchURL(tc.baseURL, tc.query, tc.limit) + + if tc.expectedErrorContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedURL, url) + } + }) + } +} + +func Test_buildDocsSearchRequest(t *testing.T) { + tests := map[string]struct { + url string + cliVersion string + expectedErrorContains string + }{ + "builds valid request": { + url: "https://docs.slack.dev/api/v1/search?query=test&limit=20", + cliVersion: "1.0.0", + }, + "returns error for invalid URL": { + url: "ht!tp://invalid", + cliVersion: "1.0.0", + expectedErrorContains: "invalid", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + req, err := buildDocsSearchRequest(ctx, tc.url, tc.cliVersion) + + if tc.expectedErrorContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrorContains) + } else { + require.NoError(t, err) + require.NotNil(t, req) + require.Equal(t, "GET", req.Method) + require.Contains(t, req.Header.Get("User-Agent"), "slack-cli/1.0.0") + } + }) + } +} + +func Test_Client_DocsSearch(t *testing.T) { + tests := map[string]struct { + query string + limit int + response string + statusCode int + expectedQuerystring string + expectedResponse *DocsSearchResponse + expectedErrorContains string + }{ + "returns search results": { + query: "Block Kit", + limit: 20, + response: `{"total_results":2,"limit":20,"results":[{"title":"Block Kit","url":"/block-kit"},{"title":"Block Kit Elements","url":"/block-kit/elements"}]}`, + expectedQuerystring: "query=Block+Kit&limit=20", + expectedResponse: &DocsSearchResponse{ + TotalResults: 2, + Limit: 20, + Results: []DocsSearchItem{ + { + Title: "Block Kit", + URL: "/block-kit", + }, + { + Title: "Block Kit Elements", + URL: "/block-kit/elements", + }, + }, + }, + }, + "returns empty results": { + query: "nonexistent", + limit: 20, + response: `{"total_results":0,"limit":20,"results":[]}`, + expectedResponse: &DocsSearchResponse{ + TotalResults: 0, + Limit: 20, + Results: []DocsSearchItem{}, + }, + }, + "encodes query parameters": { + query: "messages & webhooks", + limit: 5, + response: `{"total_results":0,"limit":5,"results":[]}`, + expectedQuerystring: "query=messages+%26+webhooks&limit=5", + expectedResponse: &DocsSearchResponse{ + TotalResults: 0, + Limit: 5, + Results: []DocsSearchItem{}, + }, + }, + "returns error for non-OK status": { + query: "test", + limit: 20, + statusCode: 404, + expectedErrorContains: slackerror.ErrHTTPRequestFailed, + }, + "returns error for invalid JSON": { + query: "test", + limit: 20, + response: `{invalid json}`, + expectedErrorContains: slackerror.ErrHTTPResponseInvalid, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: docsSearchMethod, + ExpectedQuerystring: tc.expectedQuerystring, + Response: tc.response, + StatusCode: tc.statusCode, + }) + defer teardown() + + originalURL := docsBaseURL + docsBaseURL = c.Host() + defer func() { docsBaseURL = originalURL }() + + result, err := c.DocsSearch(ctx, tc.query, tc.limit) + + if tc.expectedErrorContains != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErrorContains) + } else { + require.NoError(t, err) + require.Equal(t, tc.expectedResponse, result) + } + }) + } +} diff --git a/internal/api/types.go b/internal/api/types.go index 22f0431c..df5e570c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -26,6 +26,7 @@ type APIInterface interface { ChannelClient CollaboratorsClient DatastoresClient + DocsClient ExternalAuthClient FunctionDistributionClient SandboxClient diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 807eb16b..221af014 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -693,7 +693,7 @@ Otherwise start your app for local development with: %s`, ErrDocsSearchFlagRequired: { Code: ErrDocsSearchFlagRequired, Message: "Invalid docs command. Did you mean to search?", - Remediation: fmt.Sprintf("Use --search flag: %s", style.Commandf("docs --search \"\"", false)), + Remediation: fmt.Sprintf("Use search subcommand: %s", style.Commandf("docs search \"\"", false)), }, ErrDotEnvFileParse: {