Skip to content

Commit f248043

Browse files
go
1 parent 7e4a60f commit f248043

3 files changed

Lines changed: 371 additions & 1 deletion

File tree

cmd/docs/docs.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,18 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
4747
Command: "docs --search",
4848
},
4949
}),
50+
Args: cobra.ArbitraryArgs, // Allow any arguments
5051
RunE: func(cmd *cobra.Command, args []string) error {
5152
return runDocsCommand(clients, cmd, args)
5253
},
54+
// Disable automatic suggestions for unknown commands
55+
DisableSuggestions: true,
5356
}
5457

55-
cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query")
58+
cmd.Flags().BoolVar(&searchMode, "search", false, "[DEPRECATED] open Slack docs search page or search with query (use 'docs search' subcommand instead)")
59+
60+
// Add the experimental search subcommand
61+
cmd.AddCommand(NewSearchCommand(clients))
5662

5763
return cmd
5864
}
@@ -74,6 +80,7 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st
7480
}
7581

7682
if cmd.Flags().Changed("search") {
83+
clients.IO.PrintWarning(ctx, "The `--search` flag is deprecated. Use 'docs search' subcommand instead.")
7784
if len(args) > 0 {
7885
// --search "query" (space-separated) - join all args as the query
7986
query := strings.Join(args, " ")

cmd/docs/search.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package docs
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"net/url"
23+
"strings"
24+
25+
"github.com/slackapi/slack-cli/internal/shared"
26+
"github.com/slackapi/slack-cli/internal/slacktrace"
27+
"github.com/slackapi/slack-cli/internal/style"
28+
"github.com/spf13/cobra"
29+
)
30+
31+
var searchOutputFlag string
32+
var searchLimitFlag int
33+
34+
// response from the Slack docs search API
35+
type DocsSearchResponse struct {
36+
TotalResults int `json:"total_results"`
37+
Results []DocsSearchResult `json:"results"`
38+
Limit int `json:"limit"`
39+
}
40+
41+
// single search result
42+
type DocsSearchResult struct {
43+
URL string `json:"url"`
44+
Title string `json:"title"`
45+
}
46+
47+
func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command {
48+
cmd := &cobra.Command{
49+
Use: "search <query>",
50+
Short: "Search Slack developer docs (experimental)",
51+
Long: "Search the Slack developer docs and return results in browser or JSON format",
52+
Example: style.ExampleCommandsf([]style.ExampleCommand{
53+
{
54+
Meaning: "Search docs and open results in browser",
55+
Command: "docs search \"Block Kit\"",
56+
},
57+
{
58+
Meaning: "Search docs and return JSON results",
59+
Command: "docs search \"webhooks\" --output=json",
60+
},
61+
{
62+
Meaning: "Search docs with limited JSON results",
63+
Command: "docs search \"api\" --output=json --limit=5",
64+
},
65+
}),
66+
Args: cobra.MinimumNArgs(1),
67+
RunE: func(cmd *cobra.Command, args []string) error {
68+
return runDocsSearchCommand(clients, cmd, args, http.DefaultClient)
69+
},
70+
}
71+
72+
cmd.Flags().StringVar(&searchOutputFlag, "output", "json", "output format: browser, json")
73+
cmd.Flags().IntVar(&searchLimitFlag, "limit", 20, "maximum number of search results to return (only applies with --output=json)")
74+
75+
return cmd
76+
}
77+
78+
// handles the docs search subcommand
79+
func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, httpClient *http.Client) error {
80+
ctx := cmd.Context()
81+
82+
query := strings.Join(args, " ")
83+
84+
if searchOutputFlag == "json" {
85+
return fetchAndOutputSearchResults(ctx, clients, query, searchLimitFlag, httpClient)
86+
}
87+
88+
// Browser output - open search results in browser
89+
encodedQuery := url.QueryEscape(query)
90+
docsURL := fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery)
91+
92+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
93+
Emoji: "books",
94+
Text: "Docs Search",
95+
Secondary: []string{
96+
docsURL,
97+
},
98+
}))
99+
100+
clients.Browser().OpenURL(docsURL)
101+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
102+
103+
return nil
104+
}
105+
106+
// fetches search results from the docs API and outputs as JSON
107+
func fetchAndOutputSearchResults(ctx context.Context, clients *shared.ClientFactory, query string, limit int, httpClient *http.Client) error {
108+
// Build API URL with limit parameter
109+
apiURL := fmt.Sprintf("https://docs-slack-d-search-api-duu9zr.herokuapp.com/api/search?q=%s&limit=%d", url.QueryEscape(query), limit)
110+
111+
// Make HTTP request
112+
resp, err := httpClient.Get(apiURL)
113+
if err != nil {
114+
return fmt.Errorf("failed to fetch search results: %w", err)
115+
}
116+
defer resp.Body.Close()
117+
118+
if resp.StatusCode != http.StatusOK {
119+
return fmt.Errorf("API returned status %d", resp.StatusCode)
120+
}
121+
122+
// Parse JSON response
123+
var searchResponse DocsSearchResponse
124+
if err := json.NewDecoder(resp.Body).Decode(&searchResponse); err != nil {
125+
return fmt.Errorf("failed to parse search results: %w", err)
126+
}
127+
128+
// Output as JSON
129+
output, err := json.MarshalIndent(searchResponse, "", " ")
130+
if err != nil {
131+
return fmt.Errorf("failed to marshal JSON output: %w", err)
132+
}
133+
134+
fmt.Println(string(output))
135+
136+
// Trace the successful API call
137+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
138+
139+
return nil
140+
}

cmd/docs/search_test.go

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package docs
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"testing"
24+
25+
"github.com/slackapi/slack-cli/internal/shared"
26+
"github.com/slackapi/slack-cli/internal/slackcontext"
27+
"github.com/slackapi/slack-cli/test/testutil"
28+
"github.com/spf13/cobra"
29+
"github.com/stretchr/testify/assert"
30+
"github.com/stretchr/testify/require"
31+
)
32+
33+
// mockRoundTripper implements http.RoundTripper to mock HTTP responses for testing.
34+
// It allows tests to control the response status code and body without making real network calls.
35+
// It also captures the request URL for assertion purposes.
36+
type mockRoundTripper struct {
37+
response string
38+
status int
39+
capturedURL string
40+
}
41+
42+
// RoundTrip executes a mocked HTTP request and returns a controlled response.
43+
func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
44+
m.capturedURL = req.URL.String()
45+
return &http.Response{
46+
StatusCode: m.status,
47+
Body: io.NopCloser(bytes.NewBufferString(m.response)),
48+
Header: make(http.Header),
49+
}, nil
50+
}
51+
52+
// setupJSONOutputTest creates a mock client and clients factory for JSON output tests.
53+
func setupJSONOutputTest(t *testing.T, response string, status int) (*http.Client, *shared.ClientFactory, *mockRoundTripper) {
54+
clientsMock := shared.NewClientsMock()
55+
clientsMock.AddDefaultMocks()
56+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
57+
58+
mockTransport := &mockRoundTripper{
59+
response: response,
60+
status: status,
61+
}
62+
mockClient := &http.Client{
63+
Transport: mockTransport,
64+
}
65+
66+
return mockClient, clients, mockTransport
67+
}
68+
69+
// JSON Output Tests
70+
71+
// Test_Docs_SearchCommand_JSONOutput_APIError verifies that HTTP errors from the API
72+
// (e.g., 404 Not Found) are properly caught and returned as errors.
73+
func Test_Docs_SearchCommand_JSONOutput_APIError(t *testing.T) {
74+
mockClient, clients, _ := setupJSONOutputTest(t, `{"error": "not found"}`, http.StatusNotFound)
75+
err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent", 20, mockClient)
76+
assert.Error(t, err)
77+
assert.Contains(t, err.Error(), "API returned status 404")
78+
}
79+
80+
// Test_Docs_SearchCommand_JSONOutput_InvalidJSON verifies that malformed JSON responses
81+
// from the API are caught during parsing and returned as errors.
82+
func Test_Docs_SearchCommand_JSONOutput_InvalidJSON(t *testing.T) {
83+
mockClient, clients, _ := setupJSONOutputTest(t, `{invalid json}`, http.StatusOK)
84+
err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "test", 20, mockClient)
85+
assert.Error(t, err)
86+
assert.Contains(t, err.Error(), "failed to parse search results")
87+
}
88+
89+
// Test_Docs_SearchCommand_JSONOutput_EmptyResults verifies that valid JSON responses with no results
90+
// are correctly parsed and output without errors.
91+
func Test_Docs_SearchCommand_JSONOutput_EmptyResults(t *testing.T) {
92+
mockResponse := `{
93+
"total_results": 0,
94+
"limit": 20,
95+
"results": []
96+
}`
97+
98+
mockClient, clients, _ := setupJSONOutputTest(t, mockResponse, http.StatusOK)
99+
err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, "nonexistent query", 20, mockClient)
100+
require.NoError(t, err)
101+
}
102+
103+
// Test_Docs_SearchCommand_JSONOutput_QueryFormats tests JSON output with various query formats
104+
// to ensure proper URL encoding, API parameter handling, and response parsing.
105+
func Test_Docs_SearchCommand_JSONOutput_QueryFormats(t *testing.T) {
106+
mockResponse := `{
107+
"total_results": 2,
108+
"limit": 20,
109+
"results": [
110+
{
111+
"title": "Block Kit",
112+
"url": "https://docs.slack.dev/block-kit"
113+
},
114+
{
115+
"title": "Block Kit Elements",
116+
"url": "https://docs.slack.dev/block-kit/elements"
117+
}
118+
]
119+
}`
120+
121+
tests := map[string]struct {
122+
query string
123+
limit int
124+
expected string
125+
}{
126+
"single word query": {
127+
query: "messaging",
128+
limit: 20,
129+
expected: "messaging",
130+
},
131+
"multiple words": {
132+
query: "socket mode",
133+
limit: 20,
134+
expected: "socket+mode",
135+
},
136+
"special characters": {
137+
query: "messages & webhooks",
138+
limit: 20,
139+
expected: "messages+%26+webhooks",
140+
},
141+
"custom limit": {
142+
query: "Block Kit",
143+
limit: 5,
144+
expected: "Block+Kit",
145+
},
146+
}
147+
148+
for name, tc := range tests {
149+
t.Run(name, func(t *testing.T) {
150+
mockClient, clients, mockTransport := setupJSONOutputTest(t, mockResponse, http.StatusOK)
151+
err := fetchAndOutputSearchResults(slackcontext.MockContext(context.Background()), clients, tc.query, tc.limit, mockClient)
152+
require.NoError(t, err)
153+
assert.Contains(t, mockTransport.capturedURL, "q="+tc.expected)
154+
assert.Contains(t, mockTransport.capturedURL, "limit="+fmt.Sprint(tc.limit))
155+
})
156+
}
157+
}
158+
159+
// Browser Output Tests
160+
161+
// Test_Docs_SearchCommand_BrowserOutput tests the browser output mode with various query formats
162+
// to ensure proper URL encoding and command execution.
163+
func Test_Docs_SearchCommand_BrowserOutput(t *testing.T) {
164+
testutil.TableTestCommand(t, testutil.CommandTests{
165+
"opens browser with search query using space syntax": {
166+
CmdArgs: []string{"search", "messaging", "--output=browser"},
167+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
168+
expectedURL := "https://docs.slack.dev/search/?q=messaging"
169+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
170+
},
171+
ExpectedOutputs: []string{
172+
"Docs Search",
173+
"https://docs.slack.dev/search/?q=messaging",
174+
},
175+
},
176+
"handles search with multiple arguments": {
177+
CmdArgs: []string{"search", "Block", "Kit", "Element", "--output=browser"},
178+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
179+
expectedURL := "https://docs.slack.dev/search/?q=Block+Kit+Element"
180+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
181+
},
182+
ExpectedOutputs: []string{
183+
"Docs Search",
184+
"https://docs.slack.dev/search/?q=Block+Kit+Element",
185+
},
186+
},
187+
"handles search query with multiple words": {
188+
CmdArgs: []string{"search", "socket mode", "--output=browser"},
189+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
190+
expectedURL := "https://docs.slack.dev/search/?q=socket+mode"
191+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
192+
},
193+
ExpectedOutputs: []string{
194+
"Docs Search",
195+
"https://docs.slack.dev/search/?q=socket+mode",
196+
},
197+
},
198+
"handles special characters in search query": {
199+
CmdArgs: []string{"search", "messages & webhooks", "--output=browser"},
200+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
201+
expectedURL := "https://docs.slack.dev/search/?q=messages+%26+webhooks"
202+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
203+
},
204+
ExpectedOutputs: []string{
205+
"Docs Search",
206+
"https://docs.slack.dev/search/?q=messages+%26+webhooks",
207+
},
208+
},
209+
"handles search query with quotes": {
210+
CmdArgs: []string{"search", "webhook \"send message\"", "--output=browser"},
211+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
212+
expectedURL := "https://docs.slack.dev/search/?q=webhook+%22send+message%22"
213+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
214+
},
215+
ExpectedOutputs: []string{
216+
"Docs Search",
217+
"https://docs.slack.dev/search/?q=webhook+%22send+message%22",
218+
},
219+
},
220+
}, func(cf *shared.ClientFactory) *cobra.Command {
221+
return NewCommand(cf)
222+
})
223+
}

0 commit comments

Comments
 (0)