diff --git a/README.md b/README.md index 167de2d..7d3b6fe 100644 --- a/README.md +++ b/README.md @@ -139,12 +139,13 @@ Text output is the default. JSON output is available through the global `--outpu ```sh $ dbxcli --output=json $ dbxcli ls --output=json / +$ dbxcli search --output=json report /Reports $ dbxcli mkdir --output=json /new-folder $ dbxcli rm --output=json /old-file.txt $ dbxcli restore --output=json /Reports/old.pdf 015f... ``` -JSON support is rolling out command by command. Currently migrated commands are `ls`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`. +JSON support is rolling out command by command. Currently migrated commands are `ls`, `search`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`. Command results are written to stdout. Status, progress, warnings, diagnostics, and verbose logs are written to stderr. @@ -190,7 +191,7 @@ Commands that operate on multiple paths return a `results` array: } ``` -List commands such as `ls` return an `input` object and an `entries` array: +Commands that return entry lists, such as `ls` and `search`, return an `input` object and an `entries` array. `ls` input includes the listed path; `search` input includes the query and optional path scope: ```json { diff --git a/cmd/json_metadata.go b/cmd/json_metadata.go index 2915662..3dccd2a 100644 --- a/cmd/json_metadata.go +++ b/cmd/json_metadata.go @@ -51,6 +51,14 @@ func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata { } } +func jsonMetadataListFromDropbox(entries []files.IsMetadata) []jsonMetadata { + result := make([]jsonMetadata, 0, len(entries)) + for _, entry := range entries { + result = append(result, jsonMetadataFromDropbox(entry)) + } + return result +} + func jsonTime(t time.Time) *string { if t.IsZero() { return nil diff --git a/cmd/search.go b/cmd/search.go index e9a646f..42dd17f 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -21,10 +21,26 @@ import ( "strings" "text/tabwriter" + "github.com/dropbox/dbxcli/internal/output" "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"` + Long bool `json:"long"` + Sort string `json:"sort,omitempty"` + Reverse bool `json:"reverse"` + Time string `json:"time,omitempty"` + TimeFormat string `json:"time_format,omitempty"` +} + +type searchOutput struct { + Input searchInput `json:"input"` + Entries []jsonMetadata `json:"entries"` +} + func search(cmd *cobra.Command, args []string) (err error) { if len(args) == 0 { return errors.New("`search` requires a `query` argument") @@ -74,11 +90,35 @@ func search(cmd *cobra.Command, args []string) (err error) { opts := parseLsOptions(cmd) sortEntries(entries, opts) - return commandOutput(cmd).RenderText(func(w io.Writer) error { - return renderSearchResults(w, entries, opts) + return renderSearchOutput(cmd, args[0], scope, entries, opts) +} + +func renderSearchOutput(cmd *cobra.Command, query, scope string, entries []files.IsMetadata, opts listOptions) error { + out := commandOutput(cmd) + if commandOutputFormat(cmd) != output.FormatJSON { + return out.RenderText(func(w io.Writer) error { + return renderSearchResults(w, entries, opts) + }) + } + + return out.Render(nil, searchOutput{ + Input: newSearchInput(query, scope, opts), + Entries: jsonMetadataListFromDropbox(entries), }) } +func newSearchInput(query, scope string, opts listOptions) searchInput { + return searchInput{ + Query: query, + Path: scope, + Long: opts.long, + Sort: opts.sortBy, + Reverse: opts.reverse, + Time: opts.timeField, + TimeFormat: opts.timeFormat, + } +} + func renderSearchResults(out io.Writer, entries []files.IsMetadata, opts listOptions) error { w := new(tabwriter.Writer) w.Init(out, 4, 8, 1, ' ', 0) @@ -115,4 +155,5 @@ func init() { searchCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order") searchCmd.Flags().String("time", "server", "Time field: server, client") searchCmd.Flags().String("time-format", "", "Time format: short (2006-01-02 15:04), rfc3339") + enableStructuredOutput(searchCmd) } diff --git a/cmd/search_test.go b/cmd/search_test.go index 1a67edc..ecf9bd8 100644 --- a/cmd/search_test.go +++ b/cmd/search_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "encoding/json" + "fmt" "strings" "testing" @@ -126,6 +128,132 @@ func TestSearchUsesSearchV2AndCommandOutput(t *testing.T) { } } +func TestSearchJSONOutputsInputAndEntries(t *testing.T) { + cmd, stdout := testSearchCmd() + setSearchOutputJSON(t, cmd) + setSearchFlag(t, cmd, "long", "true") + setSearchFlag(t, cmd, "sort", "name") + setSearchFlag(t, cmd, "reverse", "true") + setSearchFlag(t, cmd, "time", "client") + setSearchFlag(t, cmd, "time-format", "rfc3339") + var firstArg *files.SearchV2Arg + + mock := &mockFilesClient{ + searchV2Fn: func(arg *files.SearchV2Arg) (*files.SearchV2Result, error) { + firstArg = arg + return files.NewSearchV2Result([]*files.SearchMatchV2{ + searchMatch(&files.FileMetadata{ + Metadata: files.Metadata{ + PathDisplay: "/docs/report.txt", + PathLower: "/docs/report.txt", + }, + Id: "id:file", + Rev: "rev-file", + Size: 42, + }), + searchMatch(&files.FolderMetadata{ + Metadata: files.Metadata{ + PathDisplay: "/docs/archive", + PathLower: "/docs/archive", + }, + Id: "id:folder", + }), + }, false), nil + }, + } + stubFilesClient(t, mock) + + if err := search(cmd, []string{"report", "/docs"}); err != nil { + t.Fatalf("search error: %v", err) + } + if firstArg == nil { + t.Fatal("SearchV2 was not called") + } + if firstArg.Query != "report" { + t.Fatalf("query = %q, want report", firstArg.Query) + } + if firstArg.Options == nil || firstArg.Options.Path != "/docs" { + t.Fatalf("options path = %#v, want /docs", firstArg.Options) + } + + 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 len(got.Entries) != 2 { + t.Fatalf("entries = %d, want 2", len(got.Entries)) + } + if got.Entries[0].Type != "file" || got.Entries[0].Rev != "rev-file" || got.Entries[0].Size == nil || *got.Entries[0].Size != 42 { + t.Fatalf("first entry = %#v, want file metadata", got.Entries[0]) + } + if got.Entries[1].Type != "folder" || got.Entries[1].ID != "id:folder" { + t.Fatalf("second entry = %#v, want folder metadata", got.Entries[1]) + } +} + +func TestSearchJSONOmitsPathWithoutScope(t *testing.T) { + cmd, stdout := testSearchCmd() + setSearchOutputJSON(t, cmd) + + mock := &mockFilesClient{ + searchV2Fn: func(arg *files.SearchV2Arg) (*files.SearchV2Result, error) { + if arg.Options != nil && arg.Options.Path != "" { + t.Fatalf("options path = %q, want empty", arg.Options.Path) + } + return files.NewSearchV2Result(nil, false), nil + }, + } + stubFilesClient(t, mock) + + if err := search(cmd, []string{"report"}); err != nil { + t.Fatalf("search error: %v", err) + } + output := append([]byte(nil), stdout.Bytes()...) + got := decodeSearchOutput(t, stdout) + if got.Input.Query != "report" || got.Input.Path != "" { + t.Fatalf("input = %#v, want query report and empty path", got.Input) + } + var raw map[string]any + if err := json.Unmarshal(output, &raw); err != nil { + t.Fatalf("decode raw JSON output: %v\noutput: %s", err, string(output)) + } + input, ok := raw["input"].(map[string]any) + if !ok { + t.Fatalf("raw input = %#v, want object", raw["input"]) + } + if _, ok := input["path"]; ok { + t.Fatalf("input path key is present in %s, want omitted", string(output)) + } +} + +func TestSearchJSONErrorWritesNoOutput(t *testing.T) { + cmd, stdout := testSearchCmd() + setSearchOutputJSON(t, cmd) + + mock := &mockFilesClient{ + searchV2Fn: func(arg *files.SearchV2Arg) (*files.SearchV2Result, error) { + return nil, fmt.Errorf("search failed") + }, + } + stubFilesClient(t, mock) + + if err := search(cmd, []string{"report"}); err == nil { + t.Fatal("expected search error") + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty output on error", got) + } +} + +func TestSearchCommandSupportsStructuredOutput(t *testing.T) { + if !commandSupportsStructuredOutput(searchCmd) { + t.Fatal("search command should support structured output") + } +} + func testSearchCmd() (*cobra.Command, *bytes.Buffer) { var stdout bytes.Buffer cmd := &cobra.Command{Use: "search"} @@ -135,9 +263,32 @@ func testSearchCmd() (*cobra.Command, *bytes.Buffer) { cmd.Flags().BoolP("reverse", "r", false, "") cmd.Flags().String("time", "server", "") cmd.Flags().String("time-format", "", "") + cmd.Flags().String(outputFlag, "text", "") return cmd, &stdout } +func setSearchOutputJSON(t *testing.T, cmd *cobra.Command) { + t.Helper() + setSearchFlag(t, cmd, outputFlag, "json") +} + +func setSearchFlag(t *testing.T, cmd *cobra.Command, name, value string) { + t.Helper() + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } +} + +func decodeSearchOutput(t *testing.T, out *bytes.Buffer) searchOutput { + t.Helper() + + var got searchOutput + if err := json.NewDecoder(out).Decode(&got); err != nil { + t.Fatalf("decode JSON output: %v\noutput: %s", err, out.String()) + } + return got +} + func searchMatch(metadata files.IsMetadata) *files.SearchMatchV2 { return files.NewSearchMatchV2(&files.MetadataV2{ Tagged: dropbox.Tagged{Tag: files.MetadataV2Metadata},