Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 101 additions & 26 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ import (
"text/tabwriter"

"github.com/dropbox/dbxcli/internal/output"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
"github.com/spf13/cobra"
)

type searchInput struct {
Query string `json:"query"`
Path string `json:"path,omitempty"`
Content bool `json:"content"`
Limit uint64 `json:"limit,omitempty"`
OrderBy string `json:"order_by,omitempty"`
Long bool `json:"long"`
Sort string `json:"sort,omitempty"`
Reverse bool `json:"reverse"`
Expand All @@ -37,10 +41,20 @@ type searchInput struct {

const searchJSONStatusFound = "found"

type searchCommandOptions struct {
list listOptions
content bool
limit uint64
orderBy string
}

func search(cmd *cobra.Command, args []string) (err error) {
if len(args) == 0 {
return invalidArgumentsErrorWithDetails("`search` requires a `query` argument", argumentErrorDetails("query"))
}
if len(args) > 2 {
return invalidArgumentsErrorWithDetails("`search` accepts at most one optional `path-scope` argument", argumentErrorDetails("path-scope"))
}

var scope string
if len(args) == 2 {
Expand All @@ -50,12 +64,11 @@ func search(cmd *cobra.Command, args []string) (err error) {
}
}

arg := files.NewSearchV2Arg(args[0])
if scope != "" {
opts := files.NewSearchOptions()
opts.Path = scope
arg.Options = opts
opts, err := parseSearchOptions(cmd)
if err != nil {
return err
}
arg := newSearchV2Arg(args[0], scope, opts)

dbx := filesNewFunc(config)
res, err := dbx.SearchV2(arg)
Expand All @@ -64,36 +77,92 @@ func search(cmd *cobra.Command, args []string) (err error) {
}

var entries []files.IsMetadata
for _, m := range res.Matches {
if m.Metadata != nil && m.Metadata.Metadata != nil {
entries = append(entries, m.Metadata.Metadata)
}
}
entries = appendSearchMatches(entries, res.Matches, opts.limit)

for res.HasMore {
for res.HasMore && !searchLimitReached(entries, opts.limit) {
contArg := files.NewSearchV2ContinueArg(res.Cursor)
res, err = dbx.SearchContinueV2(contArg)
if err != nil {
return err
}
for _, m := range res.Matches {
if m.Metadata != nil && m.Metadata.Metadata != nil {
entries = append(entries, m.Metadata.Metadata)
}
}
entries = appendSearchMatches(entries, res.Matches, opts.limit)
}

opts := parseLsOptions(cmd)
sortEntries(entries, opts)
sortEntries(entries, opts.list)

return renderSearchOutput(cmd, args[0], scope, entries, opts)
}

func renderSearchOutput(cmd *cobra.Command, query, scope string, entries []files.IsMetadata, opts listOptions) error {
func parseSearchOptions(cmd *cobra.Command) (searchCommandOptions, error) {
content, _ := cmd.Flags().GetBool("content")
limit, _ := cmd.Flags().GetUint64("limit")
orderBy, _ := cmd.Flags().GetString("order-by")
if !validSearchOrderBy(orderBy) {
return searchCommandOptions{}, invalidArgumentsErrorWithDetails("`search --order-by` must be one of: relevance, modified", flagErrorDetails("order-by"))
}

return searchCommandOptions{
list: parseLsOptions(cmd),
content: content,
limit: limit,
orderBy: orderBy,
}, nil
}

func newSearchV2Arg(query, scope string, opts searchCommandOptions) *files.SearchV2Arg {
arg := files.NewSearchV2Arg(query)
searchOpts := files.NewSearchOptions()
searchOpts.Path = scope
searchOpts.FilenameOnly = !opts.content
if opts.limit > 0 && opts.limit < searchOpts.MaxResults {
searchOpts.MaxResults = opts.limit
}
if opts.orderBy != "" {
searchOpts.OrderBy = searchOrderBy(opts.orderBy)
}
arg.Options = searchOpts
return arg
}

func validSearchOrderBy(orderBy string) bool {
switch orderBy {
case "", "relevance", "modified":
return true
default:
return false
}
}

func searchOrderBy(orderBy string) *files.SearchOrderBy {
switch orderBy {
case "modified":
return &files.SearchOrderBy{Tagged: dropbox.Tagged{Tag: files.SearchOrderByLastModifiedTime}}
default:
return &files.SearchOrderBy{Tagged: dropbox.Tagged{Tag: files.SearchOrderByRelevance}}
}
}

func appendSearchMatches(entries []files.IsMetadata, matches []*files.SearchMatchV2, limit uint64) []files.IsMetadata {
for _, m := range matches {
if searchLimitReached(entries, limit) {
break
}
if m.Metadata != nil && m.Metadata.Metadata != nil {
entries = append(entries, m.Metadata.Metadata)
}
}
return entries
}

func searchLimitReached(entries []files.IsMetadata, limit uint64) bool {
return limit > 0 && uint64(len(entries)) >= limit
}

func renderSearchOutput(cmd *cobra.Command, query, scope string, entries []files.IsMetadata, opts searchCommandOptions) error {
out := commandOutput(cmd)
if commandOutputFormat(cmd) != output.FormatJSON {
return out.RenderText(func(w io.Writer) error {
return renderSearchResults(w, entries, opts)
return renderSearchResults(w, entries, opts.list)
})
}

Expand All @@ -106,15 +175,18 @@ func renderSearchOutput(cmd *cobra.Command, query, scope string, entries []files
return renderJSONOperationOutput(cmd, input, results)
}

func newSearchInput(query, scope string, opts listOptions) searchInput {
func newSearchInput(query, scope string, opts searchCommandOptions) searchInput {
return searchInput{
Query: query,
Path: scope,
Long: opts.long,
Sort: opts.sortBy,
Reverse: opts.reverse,
Time: opts.timeField,
TimeFormat: opts.timeFormat,
Content: opts.content,
Limit: opts.limit,
OrderBy: opts.orderBy,
Long: opts.list.long,
Sort: opts.list.sortBy,
Reverse: opts.list.reverse,
Time: opts.list.timeField,
TimeFormat: opts.list.timeFormat,
}
}

Expand Down Expand Up @@ -149,6 +221,9 @@ var searchCmd = &cobra.Command{

func init() {
RootCmd.AddCommand(searchCmd)
searchCmd.Flags().BoolP("content", "c", false, "Search file contents in addition to filenames")
searchCmd.Flags().Uint64("limit", 0, "Maximum number of matches to return")
searchCmd.Flags().String("order-by", "", "Server-side search ordering: relevance, modified")
searchCmd.Flags().BoolP("long", "l", false, "Long listing")
searchCmd.Flags().String("sort", "", "Sort by: name, size, time, type")
searchCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order")
Expand Down
91 changes: 88 additions & 3 deletions cmd/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ func TestSearchPathScopeValidation(t *testing.T) {
}
}

func TestSearchRejectsExtraArgs(t *testing.T) {
err := search(searchCmd, []string{"query", "/docs", "extra"})
if err == nil || !strings.Contains(err.Error(), "path-scope") {
t.Fatalf("error = %v, want extra path-scope error", err)
}
}

func TestSearchOrderByValidation(t *testing.T) {
cmd, _ := testSearchCmd()
setSearchFlag(t, cmd, "order-by", "name")

err := search(cmd, []string{"query"})
if err == nil || !strings.Contains(err.Error(), "order-by") {
t.Fatalf("error = %v, want order-by validation error", err)
}
}

func TestRenderSearchResultsSeparatesMatchesWithNewlines(t *testing.T) {
entries := []files.IsMetadata{
&files.FileMetadata{
Expand Down Expand Up @@ -116,6 +133,9 @@ func TestSearchUsesSearchV2AndCommandOutput(t *testing.T) {
if firstArg.Options == nil || firstArg.Options.Path != "/docs" {
t.Fatalf("options path = %#v, want /docs", firstArg.Options)
}
if !firstArg.Options.FilenameOnly {
t.Fatal("filename_only = false, want true by default")
}
if continueCursor != "cursor-1" {
t.Errorf("continue cursor = %q, want cursor-1", continueCursor)
}
Expand All @@ -136,6 +156,9 @@ func TestSearchJSONOutputsInputAndResults(t *testing.T) {
setSearchFlag(t, cmd, "reverse", "true")
setSearchFlag(t, cmd, "time", "client")
setSearchFlag(t, cmd, "time-format", "rfc3339")
setSearchFlag(t, cmd, "content", "true")
setSearchFlag(t, cmd, "limit", "2")
setSearchFlag(t, cmd, "order-by", "modified")
var firstArg *files.SearchV2Arg

mock := &mockFilesClient{
Expand Down Expand Up @@ -175,13 +198,22 @@ func TestSearchJSONOutputsInputAndResults(t *testing.T) {
if firstArg.Options == nil || firstArg.Options.Path != "/docs" {
t.Fatalf("options path = %#v, want /docs", firstArg.Options)
}
if firstArg.Options.FilenameOnly {
t.Fatal("filename_only = true, want false with --content")
}
if firstArg.Options.MaxResults != 2 {
t.Fatalf("max_results = %d, want 2", firstArg.Options.MaxResults)
}
if firstArg.Options.OrderBy == nil || firstArg.Options.OrderBy.Tag != files.SearchOrderByLastModifiedTime {
t.Fatalf("order_by = %#v, want last_modified_time", firstArg.Options.OrderBy)
}

got := decodeSearchOutput(t, stdout)
if got.Input.Query != "report" || got.Input.Path != "/docs" {
t.Fatalf("input = %#v, want query report path /docs", got.Input)
}
if !got.Input.Long || got.Input.Sort != "name" || !got.Input.Reverse || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" {
t.Fatalf("input options = %#v, want long/sort/reverse/time/time-format", got.Input)
if !got.Input.Content || got.Input.Limit != 2 || got.Input.OrderBy != "modified" || !got.Input.Long || got.Input.Sort != "name" || !got.Input.Reverse || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" {
t.Fatalf("input options = %#v, want content/limit/order-by/long/sort/reverse/time/time-format", got.Input)
}
if len(got.Results) != 2 {
t.Fatalf("results = %d, want 2", len(got.Results))
Expand All @@ -205,9 +237,15 @@ func TestSearchJSONOmitsPathWithoutScope(t *testing.T) {

mock := &mockFilesClient{
searchV2Fn: func(arg *files.SearchV2Arg) (*files.SearchV2Result, error) {
if arg.Options != nil && arg.Options.Path != "" {
if arg.Options == nil {
t.Fatal("options = nil, want search options")
}
if arg.Options.Path != "" {
t.Fatalf("options path = %q, want empty", arg.Options.Path)
}
if !arg.Options.FilenameOnly {
t.Fatal("filename_only = false, want true by default")
}
return files.NewSearchV2Result(nil, false), nil
},
}
Expand All @@ -234,6 +272,50 @@ func TestSearchJSONOmitsPathWithoutScope(t *testing.T) {
}
}

func TestSearchLimitCapsResultsAndStopsPagination(t *testing.T) {
cmd, stdout := testSearchCmd()
setSearchFlag(t, cmd, "limit", "1")
var firstArg *files.SearchV2Arg

mock := &mockFilesClient{
searchV2Fn: func(arg *files.SearchV2Arg) (*files.SearchV2Result, error) {
firstArg = arg
res := files.NewSearchV2Result([]*files.SearchMatchV2{
searchMatch(&files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/docs/first.txt"},
}),
searchMatch(&files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/docs/second.txt"},
}),
}, true)
res.Cursor = "cursor-1"
return res, nil
},
searchContinueV2Fn: func(arg *files.SearchV2ContinueArg) (*files.SearchV2Result, error) {
t.Fatalf("SearchContinueV2 was called with cursor %q, want stop after limit", arg.Cursor)
return nil, nil
},
}
stubFilesClient(t, mock)

if err := search(cmd, []string{"needle", "/docs"}); err != nil {
t.Fatalf("search error: %v", err)
}
if firstArg == nil || firstArg.Options == nil {
t.Fatal("SearchV2 options were not set")
}
if firstArg.Options.MaxResults != 1 {
t.Fatalf("max_results = %d, want 1", firstArg.Options.MaxResults)
}
got := stdout.String()
if !strings.Contains(got, "/docs/first.txt") {
t.Fatalf("stdout = %q, want first result", got)
}
if strings.Contains(got, "/docs/second.txt") {
t.Fatalf("stdout = %q, want second result capped by --limit", got)
}
}

func TestSearchJSONErrorWritesNoOutput(t *testing.T) {
cmd, stdout := testSearchCmd()
setSearchOutputJSON(t, cmd)
Expand Down Expand Up @@ -263,6 +345,9 @@ func testSearchCmd() (*cobra.Command, *bytes.Buffer) {
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "search"}
cmd.SetOut(&stdout)
cmd.Flags().BoolP("content", "c", false, "")
cmd.Flags().Uint64("limit", 0, "")
cmd.Flags().String("order-by", "", "")
cmd.Flags().BoolP("long", "l", false, "")
cmd.Flags().String("sort", "", "")
cmd.Flags().BoolP("reverse", "r", false, "")
Expand Down
2 changes: 1 addition & 1 deletion cmd/testdata/json_contract/success_outputs.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"restore": {"ok":true,"schema_version":"1","command":"restore","input":{"path":"/Reports/old.pdf","revision":"015f"},"results":[{"status":"restored","kind":"file","input":{"path":"/Reports/old.pdf","revision":"015f"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]},
"revs": {"ok":true,"schema_version":"1","command":"revs","input":{"path":"/Reports/old.pdf","long":true,"time":"server","time_format":"2006-01-02"},"results":[{"status":"revision","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]},
"rm": {"ok":true,"schema_version":"1","command":"rm","input":{},"results":[{"input":{"path":"/Reports/old.pdf","permanent":false,"recursive":false,"force":false},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"deleted","kind":"file"}],"warnings":[]},
"search": {"ok":true,"schema_version":"1","command":"search","input":{"query":"report","path":"/Reports","long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"},"input":{}}],"warnings":[]},
"search": {"ok":true,"schema_version":"1","command":"search","input":{"query":"report","path":"/Reports","content":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"found","kind":"folder","result":{"type":"folder","path_display":"/Reports","path_lower":"/reports","id":"id:folder"},"input":{}}],"warnings":[]},
"share list folder": {"ok":true,"schema_version":"1","command":"share list folder","input":{},"results":[{"status":"listed","kind":"shared_folder","result":{"type":"shared_folder","name":"Reports","path_lower":"/reports","shared_folder_id":"sfid:reports","preview_url":"https://www.dropbox.com/preview","access_type":"owner","is_inside_team_folder":false,"is_team_folder":true,"owner_display_names":["Ada Lovelace"],"parent_shared_folder_id":"sfid:parent","parent_folder_name":"Parent","time_invited":"2026-06-25T10:00:00Z","access_inheritance":"inherit"},"input":{}}],"warnings":[]},
"share list link": {"ok":true,"schema_version":"1","command":"share list link","input":{"path":"/Reports/old.pdf","direct_only":true},"results":[{"status":"listed","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[{"code":"deprecated_command","message":"use `dbxcli share-link list` instead"}]},
"share-link create": {"ok":true,"schema_version":"1","command":"share-link create","input":{"path":"/Reports/old.pdf","access":"viewer","audience":"public","expires":"2026-07-01T00:00:00Z","allow_download":true,"password":true},"results":[{"status":"created","kind":"file","result":{"type":"file","url":"https://www.dropbox.com/s/example/old.pdf","name":"old.pdf","path_lower":"/reports/old.pdf","id":"id:shared-file","expires":"2026-07-01T00:00:00Z","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z","permissions":{"resolved_visibility":"public","requested_visibility":"public","effective_audience":"public","access_level":"viewer","can_revoke":true,"allow_download":true,"can_set_expiry":true,"can_remove_expiry":true,"can_allow_download":true,"can_disallow_download":true,"allow_comments":false,"can_set_password":true,"can_remove_password":true,"require_password":true,"can_use_extended_sharing_controls":true}},"input":{}}],"warnings":[]},
Expand Down
3 changes: 3 additions & 0 deletions cmd/testdata/json_contract/success_schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@
"time_format"
],
"search_input": [
"content",
"limit",
"long",
"order_by",
"path",
"query",
"reverse",
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/dbxcli_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ dbxcli search [flags] <query> [path-scope]
### Options

```
-c, --content Search file contents in addition to filenames
-h, --help help for search
--limit uint Maximum number of matches to return
-l, --long Long listing
--order-by string Server-side search ordering: relevance, modified
-r, --reverse Reverse sort order
--sort string Sort by: name, size, time, type
--time string Time field: server, client (default "server")
Expand Down
3 changes: 3 additions & 0 deletions docs/json-schema/v1/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@
"time_format"
],
"search_input": [
"content",
"limit",
"long",
"order_by",
"path",
"query",
"reverse",
Expand Down
Loading