diff --git a/README.md b/README.md index 7d3b6fe..830f3a5 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,13 @@ Text output is the default. JSON output is available through the global `--outpu $ dbxcli --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. @@ -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 { diff --git a/cmd/revs.go b/cmd/revs.go index a76af59..f47e56d 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -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") @@ -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) @@ -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) } diff --git a/cmd/revs_test.go b/cmd/revs_test.go index 03cb9bb..096e812 100644 --- a/cmd/revs_test.go +++ b/cmd/revs_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "encoding/json" + "fmt" "strings" "testing" "time" @@ -94,6 +96,80 @@ 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"} @@ -101,5 +177,28 @@ func testRevsCmd() (*cobra.Command, *bytes.Buffer) { 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 +}