Skip to content

Commit 5bfa63d

Browse files
feat: add slack docs search subcommand (#433)
* go * refactoring * go * more test coverage * refactor * remove deprecation warning * go * test * resty api * test: replace docs base url for fake client runner * feat: output request and response details in verbose * test: confirm query string keeps capitals * test: command test cases in a single table * Apply suggestions from code review Co-authored-by: Eden Zimbelman <zim@o526.net> * feedback * go * go * tests * real docs URL nowgit add . * moves search stuff into search per request * error * wrong docgen * refactor for test * coverage * chore!: remove search flag from docs command (#470) * woo * code coverage * removes custom error --------- Co-authored-by: Eden Zimbelman <eden.zimbelman@salesforce.com> Co-authored-by: Eden Zimbelman <zim@o526.net>
1 parent 1ee05cd commit 5bfa63d

File tree

9 files changed

+691
-148
lines changed

9 files changed

+691
-148
lines changed

cmd/docs/docs.go

Lines changed: 17 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -15,101 +15,64 @@
1515
package docs
1616

1717
import (
18-
"fmt"
19-
"net/url"
20-
"strings"
21-
2218
"github.com/slackapi/slack-cli/internal/shared"
23-
"github.com/slackapi/slack-cli/internal/slackerror"
2419
"github.com/slackapi/slack-cli/internal/slacktrace"
2520
"github.com/slackapi/slack-cli/internal/style"
2621
"github.com/spf13/cobra"
2722
)
2823

29-
var searchMode bool
24+
const docsURL = "https://docs.slack.dev"
3025

3126
func NewCommand(clients *shared.ClientFactory) *cobra.Command {
3227
cmd := &cobra.Command{
3328
Use: "docs",
3429
Short: "Open Slack developer docs",
35-
Long: "Open the Slack developer docs in your browser, with optional search functionality",
30+
Long: "Open the Slack developer docs in your browser or search them using the search subcommand",
3631
Example: style.ExampleCommandsf([]style.ExampleCommand{
3732
{
3833
Meaning: "Open Slack developer docs homepage",
3934
Command: "docs",
4035
},
4136
{
4237
Meaning: "Search Slack developer docs for Block Kit",
43-
Command: "docs --search \"Block Kit\"",
38+
Command: "docs search \"Block Kit\"",
4439
},
4540
{
46-
Meaning: "Open Slack docs search page",
47-
Command: "docs --search",
41+
Meaning: "Search docs and open results in browser",
42+
Command: "docs search \"Block Kit\" --output=browser",
4843
},
4944
}),
45+
Args: cobra.NoArgs,
5046
RunE: func(cmd *cobra.Command, args []string) error {
51-
return runDocsCommand(clients, cmd, args)
47+
return runDocsCommand(clients, cmd)
5248
},
49+
// Disable automatic suggestions for unknown commands
50+
DisableSuggestions: true,
5351
}
5452

55-
cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query")
53+
// Add the search subcommand
54+
cmd.AddCommand(NewSearchCommand(clients))
55+
56+
// Catch removed --search flag
57+
cmd.Flags().BoolP("search", "", false, "DEPRECATED: use 'docs search' subcommand instead")
5658

5759
return cmd
5860
}
5961

6062
// runDocsCommand opens Slack developer docs in the browser
61-
func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
63+
func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command) error {
6264
ctx := cmd.Context()
6365

64-
var docsURL string
65-
var sectionText string
66-
67-
// Validate: if there are arguments, --search flag must be used
68-
if len(args) > 0 && !cmd.Flags().Changed("search") {
69-
query := strings.Join(args, " ")
70-
return slackerror.New(slackerror.ErrDocsSearchFlagRequired).WithRemediation(
71-
"Use --search flag: %s",
72-
style.Commandf(fmt.Sprintf("docs --search \"%s\"", query), false),
73-
)
74-
}
75-
76-
if cmd.Flags().Changed("search") {
77-
if len(args) > 0 {
78-
// --search "query" (space-separated) - join all args as the query
79-
query := strings.Join(args, " ")
80-
encodedQuery := url.QueryEscape(query)
81-
docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery)
82-
sectionText = "Docs Search"
83-
} else {
84-
// --search (no argument) - open search page
85-
docsURL = "https://docs.slack.dev/search/"
86-
sectionText = "Docs Search"
87-
}
88-
} else {
89-
// No search flag: default homepage
90-
docsURL = "https://docs.slack.dev"
91-
sectionText = "Docs Open"
92-
}
93-
9466
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
9567
Emoji: "books",
96-
Text: sectionText,
68+
Text: "Docs Open",
9769
Secondary: []string{
9870
docsURL,
9971
},
10072
}))
10173

10274
clients.Browser().OpenURL(docsURL)
103-
104-
if cmd.Flags().Changed("search") {
105-
traceValue := ""
106-
if len(args) > 0 {
107-
traceValue = strings.Join(args, " ")
108-
}
109-
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, traceValue)
110-
} else {
111-
clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess)
112-
}
75+
clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess)
11376

11477
return nil
11578
}

cmd/docs/docs_test.go

Lines changed: 3 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import (
2727

2828
func Test_Docs_DocsCommand(t *testing.T) {
2929
testutil.TableTestCommand(t, testutil.CommandTests{
30-
"opens docs homepage without search": {
30+
"opens docs homepage": {
3131
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
3232
},
3333
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
@@ -40,93 +40,9 @@ func Test_Docs_DocsCommand(t *testing.T) {
4040
"https://docs.slack.dev",
4141
},
4242
},
43-
"fails when positional argument provided without search flag": {
43+
"rejects positional arguments": {
4444
CmdArgs: []string{"Block Kit"},
45-
ExpectedErrorStrings: []string{"Invalid docs command. Did you mean to search?"},
46-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
47-
// No browser calls should be made when command fails
48-
cm.Browser.AssertNotCalled(t, "OpenURL")
49-
},
50-
},
51-
"fails when multiple positional arguments provided without search flag": {
52-
CmdArgs: []string{"webhook", "send", "message"},
53-
ExpectedErrorStrings: []string{"Invalid docs command. Did you mean to search?"},
54-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
55-
// No browser calls should be made when command fails
56-
cm.Browser.AssertNotCalled(t, "OpenURL")
57-
},
58-
},
59-
"opens docs with search query using space syntax": {
60-
CmdArgs: []string{"--search", "messaging"},
61-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
62-
expectedURL := "https://docs.slack.dev/search/?q=messaging"
63-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
64-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
65-
},
66-
ExpectedOutputs: []string{
67-
"Docs Search",
68-
"https://docs.slack.dev/search/?q=messaging",
69-
},
70-
},
71-
"handles search with multiple arguments": {
72-
CmdArgs: []string{"--search", "Block", "Kit", "Element"},
73-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
74-
expectedURL := "https://docs.slack.dev/search/?q=Block+Kit+Element"
75-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
76-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
77-
},
78-
ExpectedOutputs: []string{
79-
"Docs Search",
80-
"https://docs.slack.dev/search/?q=Block+Kit+Element",
81-
},
82-
},
83-
"handles search query with multiple words": {
84-
CmdArgs: []string{"--search", "socket mode"},
85-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
86-
expectedURL := "https://docs.slack.dev/search/?q=socket+mode"
87-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
88-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
89-
},
90-
ExpectedOutputs: []string{
91-
"Docs Search",
92-
"https://docs.slack.dev/search/?q=socket+mode",
93-
},
94-
},
95-
"handles special characters in search query": {
96-
CmdArgs: []string{"--search", "messages & webhooks"},
97-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
98-
expectedURL := "https://docs.slack.dev/search/?q=messages+%26+webhooks"
99-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
100-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
101-
},
102-
ExpectedOutputs: []string{
103-
"Docs Search",
104-
"https://docs.slack.dev/search/?q=messages+%26+webhooks",
105-
},
106-
},
107-
"handles search query with quotes": {
108-
CmdArgs: []string{"--search", "webhook \"send message\""},
109-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
110-
expectedURL := "https://docs.slack.dev/search/?q=webhook+%22send+message%22"
111-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
112-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
113-
},
114-
ExpectedOutputs: []string{
115-
"Docs Search",
116-
"https://docs.slack.dev/search/?q=webhook+%22send+message%22",
117-
},
118-
},
119-
"handles search flag without argument": {
120-
CmdArgs: []string{"--search"},
121-
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
122-
expectedURL := "https://docs.slack.dev/search/"
123-
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
124-
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.DocsSearchSuccess, mock.Anything)
125-
},
126-
ExpectedOutputs: []string{
127-
"Docs Search",
128-
"https://docs.slack.dev/search/",
129-
},
45+
ExpectedErrorStrings: []string{"unknown command"},
13046
},
13147
}, func(cf *shared.ClientFactory) *cobra.Command {
13248
return NewCommand(cf)

cmd/docs/search.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
"encoding/json"
19+
"fmt"
20+
"net/url"
21+
"strings"
22+
23+
"github.com/slackapi/slack-cli/internal/shared"
24+
"github.com/slackapi/slack-cli/internal/slackerror"
25+
"github.com/slackapi/slack-cli/internal/slacktrace"
26+
"github.com/slackapi/slack-cli/internal/style"
27+
"github.com/spf13/cobra"
28+
)
29+
30+
func buildDocsSearchURL(query string) string {
31+
encodedQuery := url.QueryEscape(query)
32+
return fmt.Sprintf("%s/search/?q=%s", docsURL, encodedQuery)
33+
}
34+
35+
type searchConfig struct {
36+
output string
37+
limit int
38+
}
39+
40+
func makeAbsoluteURL(relativeURL string) string {
41+
if strings.HasPrefix(relativeURL, "http") {
42+
return relativeURL
43+
}
44+
return docsURL + relativeURL
45+
}
46+
47+
func NewSearchCommand(clients *shared.ClientFactory) *cobra.Command {
48+
cfg := &searchConfig{}
49+
50+
cmd := &cobra.Command{
51+
Use: "search [query]",
52+
Short: "Search Slack developer docs",
53+
Long: strings.Join([]string{
54+
"Search the Slack developer docs and return results in text, JSON, or browser",
55+
"format.",
56+
}, "\n"),
57+
Example: style.ExampleCommandsf([]style.ExampleCommand{
58+
{
59+
Meaning: "Search docs and return text results",
60+
Command: "docs search \"Block Kit\"",
61+
},
62+
{
63+
Meaning: "Search docs and open results in browser",
64+
Command: "docs search \"webhooks\" --output=browser",
65+
},
66+
{
67+
Meaning: "Search docs with limited JSON results",
68+
Command: "docs search \"api\" --output=json --limit=5",
69+
},
70+
}),
71+
Args: cobra.MinimumNArgs(1),
72+
RunE: func(cmd *cobra.Command, args []string) error {
73+
return runDocsSearchCommand(clients, cmd, args, cfg)
74+
},
75+
}
76+
77+
cmd.Flags().StringVar(&cfg.output, "output", "text", "output format: text, json, browser")
78+
cmd.Flags().IntVar(&cfg.limit, "limit", 20, "maximum number of text or json search results to return")
79+
80+
return cmd
81+
}
82+
83+
func runDocsSearchCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string, cfg *searchConfig) error {
84+
ctx := cmd.Context()
85+
86+
query := strings.Join(args, " ")
87+
88+
switch cfg.output {
89+
case "json":
90+
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit)
91+
if err != nil {
92+
return err
93+
}
94+
95+
for i := range searchResponse.Results {
96+
searchResponse.Results[i].URL = makeAbsoluteURL(searchResponse.Results[i].URL)
97+
}
98+
99+
encoder := json.NewEncoder(clients.IO.WriteOut())
100+
encoder.SetIndent("", " ")
101+
if err := encoder.Encode(searchResponse); err != nil {
102+
return slackerror.New(slackerror.ErrUnableToParseJSON).WithRootCause(err)
103+
}
104+
105+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
106+
107+
return nil
108+
case "text":
109+
searchResponse, err := clients.API().DocsSearch(ctx, query, cfg.limit)
110+
if err != nil {
111+
return err
112+
}
113+
114+
if len(searchResponse.Results) == 0 {
115+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
116+
Emoji: "books",
117+
Text: "Docs Search",
118+
Secondary: []string{
119+
fmt.Sprintf("Found zero results for \"%s\"", query),
120+
},
121+
}))
122+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
123+
return nil
124+
}
125+
126+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
127+
Emoji: "books",
128+
Text: "Docs Search",
129+
Secondary: []string{
130+
fmt.Sprintf("Displaying first %d of %d results for \"%s\"", len(searchResponse.Results), searchResponse.TotalResults, query),
131+
},
132+
}))
133+
134+
for _, result := range searchResponse.Results {
135+
absoluteURL := makeAbsoluteURL(result.URL)
136+
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(style.TextSection{
137+
Emoji: "book",
138+
Text: result.Title,
139+
Secondary: []string{absoluteURL},
140+
}))
141+
}
142+
143+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
144+
145+
return nil
146+
case "browser":
147+
docsSearchURL := buildDocsSearchURL(query)
148+
149+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
150+
Emoji: "books",
151+
Text: "Docs Search",
152+
Secondary: []string{
153+
docsSearchURL,
154+
},
155+
}))
156+
157+
clients.Browser().OpenURL(docsSearchURL)
158+
clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, query)
159+
160+
return nil
161+
default:
162+
return slackerror.New(slackerror.ErrInvalidFlag).WithMessage(
163+
"Invalid output format: %s", cfg.output,
164+
).WithRemediation(
165+
"Use one of: text, json, browser",
166+
)
167+
}
168+
}

0 commit comments

Comments
 (0)