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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,13 @@ Text output is the default. JSON output is available through the global `--outpu
```sh
$ dbxcli <command> --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.

Expand Down Expand Up @@ -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
{
Expand Down
8 changes: 8 additions & 0 deletions cmd/json_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 43 additions & 2 deletions cmd/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
151 changes: 151 additions & 0 deletions cmd/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"

Expand Down Expand Up @@ -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"}
Expand All @@ -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},
Expand Down
Loading