diff --git a/cmd/search.go b/cmd/search.go index 919bf6e..4b6e7d7 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -21,6 +21,7 @@ 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" ) @@ -28,6 +29,9 @@ import ( 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"` @@ -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 { @@ -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) @@ -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) }) } @@ -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, } } @@ -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") diff --git a/cmd/search_test.go b/cmd/search_test.go index fa25a02..9da50d8 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -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{ @@ -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) } @@ -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{ @@ -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)) @@ -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 }, } @@ -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) @@ -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, "") diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index f6a7d2b..732a3ba 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -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":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 5970559..1d3172a 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -152,7 +152,10 @@ "time_format" ], "search_input": [ + "content", + "limit", "long", + "order_by", "path", "query", "reverse", diff --git a/docs/commands/dbxcli_search.md b/docs/commands/dbxcli_search.md index 31d2c8c..9d41d77 100644 --- a/docs/commands/dbxcli_search.md +++ b/docs/commands/dbxcli_search.md @@ -11,8 +11,11 @@ dbxcli search [flags] [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") diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 5970559..1d3172a 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -152,7 +152,10 @@ "time_format" ], "search_input": [ + "content", + "limit", "long", + "order_by", "path", "query", "reverse",