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 @@ -140,12 +140,13 @@ Text output is the default. JSON output is available through the global `--outpu
$ dbxcli <command> --output=json
$ dbxcli ls --output=json /
$ dbxcli search --output=json report /Reports
$ dbxcli revs --output=json /Reports/old.pdf
$ 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`, `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`.
JSON support is rolling out command by command. Currently migrated commands are `ls`, `search`, `revs`, `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 @@ -191,7 +192,7 @@ Commands that operate on multiple paths return a `results` 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:
Commands that return entry lists, such as `ls`, `search`, and `revs`, return an `input` object and an `entries` array. `ls` input includes the listed path; `search` input includes the query and optional path scope; `revs` input includes the file path:

```json
{
Expand Down
47 changes: 45 additions & 2 deletions cmd/revs.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,23 @@ import (
"io"
"text/tabwriter"

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

type revsInput struct {
Path string `json:"path"`
Long bool `json:"long"`
Time string `json:"time,omitempty"`
TimeFormat string `json:"time_format,omitempty"`
}

type revsOutput struct {
Input revsInput `json:"input"`
Entries []jsonMetadata `json:"entries"`
}

func revs(cmd *cobra.Command, args []string) (err error) {
if len(args) != 1 {
return errors.New("`revs` requires a `file` argument")
Expand All @@ -44,11 +57,40 @@ func revs(cmd *cobra.Command, args []string) (err error) {

opts := parseLsOptions(cmd)

return commandOutput(cmd).RenderText(func(w io.Writer) error {
return renderRevisionResults(w, res.Entries, opts)
return renderRevisionsOutput(cmd, path, res.Entries, opts)
}

func renderRevisionsOutput(cmd *cobra.Command, path string, entries []*files.FileMetadata, opts listOptions) error {
out := commandOutput(cmd)
if commandOutputFormat(cmd) != output.FormatJSON {
return out.RenderText(func(w io.Writer) error {
return renderRevisionResults(w, entries, opts)
})
}

return out.Render(nil, revsOutput{
Input: newRevsInput(path, opts),
Entries: jsonMetadataListFromRevisions(entries),
})
}

func newRevsInput(path string, opts listOptions) revsInput {
return revsInput{
Path: path,
Long: opts.long,
Time: opts.timeField,
TimeFormat: opts.timeFormat,
}
}

func jsonMetadataListFromRevisions(entries []*files.FileMetadata) []jsonMetadata {
result := make([]jsonMetadata, 0, len(entries))
for _, entry := range entries {
result = append(result, jsonMetadataFromDropbox(entry))
}
return result
}

func renderRevisionResults(out io.Writer, entries []*files.FileMetadata, opts listOptions) error {
w := new(tabwriter.Writer)
w.Init(out, 4, 8, 1, ' ', 0)
Expand Down Expand Up @@ -82,4 +124,5 @@ func init() {
revsCmd.Flags().BoolP("long", "l", false, "Long listing")
revsCmd.Flags().String("time", "server", "Time field: server, client")
revsCmd.Flags().String("time-format", "", "Time format: short (2006-01-02 15:04), rfc3339")
enableStructuredOutput(revsCmd)
}
99 changes: 99 additions & 0 deletions cmd/revs_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"
"time"
Expand Down Expand Up @@ -94,12 +96,109 @@ func TestRevsUsesListRevisionsAndCommandOutput(t *testing.T) {
}
}

func TestRevsJSONOutputsInputAndEntries(t *testing.T) {
cmd, stdout := testRevsCmd()
setRevsOutputJSON(t, cmd)
setRevsFlag(t, cmd, "long", "true")
setRevsFlag(t, cmd, "time", "client")
setRevsFlag(t, cmd, "time-format", "rfc3339")
var gotPath string
clientModified := time.Date(2026, 6, 22, 10, 0, 0, 0, time.UTC)

stubFilesClient(t, &mockFilesClient{
listRevisionsFn: func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) {
gotPath = arg.Path
return files.NewListRevisionsResult(false, []*files.FileMetadata{
{
Metadata: files.Metadata{
PathDisplay: "/report.pdf",
PathLower: "/report.pdf",
},
Id: "id:file",
Rev: "rev-a",
Size: 42,
ClientModified: clientModified,
},
}), nil
},
})

if err := revs(cmd, []string{"/report.pdf"}); err != nil {
t.Fatalf("revs returned error: %v", err)
}
if gotPath != "/report.pdf" {
t.Fatalf("ListRevisions path = %q, want /report.pdf", gotPath)
}

got := decodeRevsOutput(t, stdout)
if got.Input.Path != "/report.pdf" || !got.Input.Long || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" {
t.Fatalf("input = %#v, want path/long/time/time_format", got.Input)
}
if len(got.Entries) != 1 {
t.Fatalf("entries = %d, want 1", len(got.Entries))
}
entry := got.Entries[0]
if entry.Type != "file" || entry.PathDisplay != "/report.pdf" || entry.Rev != "rev-a" || entry.Size == nil || *entry.Size != 42 {
t.Fatalf("entry = %#v, want file revision metadata", entry)
}
if entry.ClientModified == nil || *entry.ClientModified != "2026-06-22T10:00:00Z" {
t.Fatalf("client_modified = %#v, want RFC3339 timestamp", entry.ClientModified)
}
}

func TestRevsJSONErrorWritesNoOutput(t *testing.T) {
cmd, stdout := testRevsCmd()
setRevsOutputJSON(t, cmd)

stubFilesClient(t, &mockFilesClient{
listRevisionsFn: func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) {
return nil, fmt.Errorf("revs failed")
},
})

if err := revs(cmd, []string{"/report.pdf"}); err == nil {
t.Fatal("expected revs error")
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout = %q, want empty output on error", got)
}
}

func TestRevsCommandSupportsStructuredOutput(t *testing.T) {
if !commandSupportsStructuredOutput(revsCmd) {
t.Fatal("revs command should support structured output")
}
}

func testRevsCmd() (*cobra.Command, *bytes.Buffer) {
var stdout bytes.Buffer
cmd := &cobra.Command{Use: "revs"}
cmd.SetOut(&stdout)
cmd.Flags().BoolP("long", "l", false, "")
cmd.Flags().String("time", "server", "")
cmd.Flags().String("time-format", "", "")
cmd.Flags().String(outputFlag, "text", "")
return cmd, &stdout
}

func setRevsOutputJSON(t *testing.T, cmd *cobra.Command) {
t.Helper()
setRevsFlag(t, cmd, outputFlag, "json")
}

func setRevsFlag(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 decodeRevsOutput(t *testing.T, out *bytes.Buffer) revsOutput {
t.Helper()

var got revsOutput
if err := json.NewDecoder(out).Decode(&got); err != nil {
t.Fatalf("decode JSON output: %v\noutput: %s", err, out.String())
}
return got
}
Loading