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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ Text output is the default. JSON output is available through the global `--outpu

```sh
$ dbxcli <command> --output=json
$ dbxcli rm --output=json /old-file.txt
$ dbxcli restore --output=json /Reports/old.pdf 015f...
```

JSON support is rolling out command by command. Commands that have not been migrated return `structured output is not supported for this command yet` when used with `--output=json`.
Expand Down
22 changes: 22 additions & 0 deletions cmd/metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright © 2016 Dropbox, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

func metadataDisplayPath(inputPath, metadataPath string) string {
if metadataPath != "" {
return metadataPath
}
return inputPath
}
65 changes: 32 additions & 33 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ package cmd

import (
"errors"
"fmt"
"io"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
Expand All @@ -27,19 +29,9 @@ type restoreInput struct {
Revision string `json:"revision"`
}

type restoreMetadata struct {
Type string `json:"type"`
PathDisplay string `json:"path_display,omitempty"`
ID string `json:"id,omitempty"`
Rev string `json:"rev,omitempty"`
Size uint64 `json:"size,omitempty"`
ClientModified time.Time `json:"client_modified"`
ServerModified time.Time `json:"server_modified"`
}

type restoreResult struct {
Input restoreInput `json:"input"`
Result restoreMetadata `json:"result"`
Input restoreInput `json:"input"`
Result jsonMetadata `json:"result"`
}

func restore(cmd *cobra.Command, args []string) (err error) {
Expand All @@ -63,11 +55,14 @@ func restore(cmd *cobra.Command, args []string) (err error) {
}

verbose, _ := cmd.Flags().GetBool("verbose")
if verbose {
printRestoreResult(cmd, newRestoreResult(path, rev, metadata))
}
result := newRestoreResult(path, rev, metadata)

return
return commandOutput(cmd).Render(func(w io.Writer) error {
if !verbose {
return nil
}
return renderRestoreResult(w, result)
}, result)
}

func newRestoreResult(path, revision string, metadata *files.FileMetadata) restoreResult {
Expand All @@ -80,38 +75,41 @@ func newRestoreResult(path, revision string, metadata *files.FileMetadata) resto
}
}

func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) restoreMetadata {
func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) jsonMetadata {
if metadata == nil {
return restoreMetadata{
return jsonMetadata{
Type: "file",
PathDisplay: path,
}
}
return restoreMetadata{
Type: "file",
PathDisplay: metadataDisplayPath(path, metadata.PathDisplay),
ID: metadata.Id,
Rev: metadata.Rev,
Size: metadata.Size,
ClientModified: metadata.ClientModified,
ServerModified: metadata.ServerModified,
}

result := jsonMetadataFromDropbox(metadata)
result.PathDisplay = metadataDisplayPath(path, result.PathDisplay)
return result
}

func printRestoreResult(cmd *cobra.Command, result restoreResult) {
func renderRestoreResult(w io.Writer, result restoreResult) error {
path := result.Result.PathDisplay
if path == "" {
path = result.Input.Path
}

if result.Result.Rev != "" && result.Result.Rev != result.Input.Revision {
commandOutput(cmd).Info("Restored %s to revision %s (current revision %s, server modified %s)",
path, result.Input.Revision, result.Result.Rev, result.Result.ServerModified.Format(time.RFC3339))
return
_, err := fmt.Fprintf(w, "Restored %s to revision %s (current revision %s, server modified %s)\n",
path, result.Input.Revision, result.Result.Rev, restoreResultServerModified(result))
return err
}

commandOutput(cmd).Info("Restored %s to revision %s (server modified %s)",
path, result.Input.Revision, result.Result.ServerModified.Format(time.RFC3339))
_, err := fmt.Fprintf(w, "Restored %s to revision %s (server modified %s)\n",
path, result.Input.Revision, restoreResultServerModified(result))
return err
}

func restoreResultServerModified(result restoreResult) string {
if result.Result.ServerModified != nil {
return *result.Result.ServerModified
}
return time.Time{}.Format(time.RFC3339)
}

// restoreCmd represents the restore command
Expand All @@ -129,4 +127,5 @@ Use "dbxcli revs <target-path>" to list available revisions.`,

func init() {
RootCmd.AddCommand(restoreCmd)
enableStructuredOutput(restoreCmd)
}
162 changes: 157 additions & 5 deletions cmd/restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -45,7 +47,7 @@ func TestRestoreQuietByDefault(t *testing.T) {
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
restoreArg = arg
return &files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf", PathLower: "/reports/old.pdf"},
Rev: "current-rev",
ServerModified: serverModified,
}, nil
Expand Down Expand Up @@ -77,7 +79,7 @@ func TestRestoreVerbosePrintsRevisionAndServerModifiedTime(t *testing.T) {
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
return &files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf", PathLower: "/reports/old.pdf"},
Rev: "current-rev",
ServerModified: serverModified,
}, nil
Expand All @@ -101,6 +103,7 @@ func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) {
result := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports/old.pdf",
PathLower: "/reports/old.pdf",
},
Id: "id:abc",
Rev: "current-rev",
Expand All @@ -115,11 +118,141 @@ func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) {
if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" {
t.Fatalf("metadata = %#v, want file path metadata", result.Result)
}
if result.Result.ID != "id:abc" || result.Result.Rev != "current-rev" || result.Result.Size != 123 {
if result.Result.ID != "id:abc" || result.Result.Rev != "current-rev" ||
result.Result.Size == nil || *result.Result.Size != 123 {
t.Fatalf("metadata = %#v, want id, current rev, and size", result.Result)
}
if !result.Result.ClientModified.Equal(clientModified) || !result.Result.ServerModified.Equal(serverModified) {
t.Fatalf("metadata times = %#v, want client and server modified times", result.Result)
if result.Result.ServerModified == nil || *result.Result.ServerModified != "2026-06-17T12:30:00Z" {
t.Fatalf("server modified = %v, want 2026-06-17T12:30:00Z", result.Result.ServerModified)
}
if result.Result.ClientModified == nil || *result.Result.ClientModified != "2026-06-16T10:00:00Z" {
t.Fatalf("client modified = %v, want 2026-06-16T10:00:00Z", result.Result.ClientModified)
}
}

func TestRestoreJSONOutputsInputAndMetadata(t *testing.T) {
cmd, stdout := testRestoreCmd()
setRestoreOutputJSON(t, cmd)
var restoreArg *files.RestoreArg
clientModified := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC)
serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC)
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
restoreArg = arg
return &files.FileMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports/old.pdf",
PathLower: "/reports/old.pdf",
},
Id: "id:abc",
Rev: "current-rev",
Size: 123,
ClientModified: clientModified,
ServerModified: serverModified,
}, nil
},
}
stubFilesClient(t, mock)

if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
t.Fatalf("restore error: %v", err)
}
if restoreArg == nil || restoreArg.Path != "/Reports/old.pdf" || restoreArg.Rev != "target-rev" {
t.Fatalf("restore arg = %#v, want path /Reports/old.pdf and rev target-rev", restoreArg)
}

got := decodeRestoreOutput(t, stdout)
if got.Input.Path != "/Reports/old.pdf" || got.Input.Revision != "target-rev" {
t.Fatalf("input = %#v, want path and target revision", got.Input)
}
if got.Result.Type != "file" || got.Result.PathDisplay != "/Reports/old.pdf" || got.Result.PathLower != "/reports/old.pdf" {
t.Fatalf("metadata = %#v, want file path metadata", got.Result)
}
if got.Result.ID != "id:abc" || got.Result.Rev != "current-rev" {
t.Fatalf("metadata = %#v, want returned id and current revision", got.Result)
}
if got.Result.Size == nil || *got.Result.Size != 123 {
t.Fatalf("size = %v, want 123", got.Result.Size)
}
if got.Result.ServerModified == nil || *got.Result.ServerModified != "2026-06-17T12:30:00Z" {
t.Fatalf("server modified = %v, want 2026-06-17T12:30:00Z", got.Result.ServerModified)
}
if got.Result.ClientModified == nil || *got.Result.ClientModified != "2026-06-16T10:00:00Z" {
t.Fatalf("client modified = %v, want 2026-06-16T10:00:00Z", got.Result.ClientModified)
}
}

func TestRestoreJSONUsesInputPathWhenMetadataPathDisplayMissing(t *testing.T) {
cmd, stdout := testRestoreCmd()
setRestoreOutputJSON(t, cmd)
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
return &files.FileMetadata{Rev: "current-rev"}, nil
},
}
stubFilesClient(t, mock)

if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
t.Fatalf("restore error: %v", err)
}

got := decodeRestoreOutput(t, stdout)
if got.Result.PathDisplay != "/Reports/old.pdf" {
t.Fatalf("path_display = %q, want fallback input path", got.Result.PathDisplay)
}
}

func TestRestoreJSONVerboseDoesNotPrintText(t *testing.T) {
cmd, stdout := testRestoreCmd()
setRestoreOutputJSON(t, cmd)
if err := cmd.Flags().Set("verbose", "true"); err != nil {
t.Fatalf("set verbose: %v", err)
}

mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
return &files.FileMetadata{
Metadata: files.Metadata{PathDisplay: "/Reports/old.pdf"},
Rev: "current-rev",
ServerModified: time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC),
}, nil
},
}
stubFilesClient(t, mock)

if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil {
t.Fatalf("restore error: %v", err)
}
if strings.Contains(stdout.String(), "Restored ") {
t.Fatalf("stdout = %q, want JSON only", stdout.String())
}
got := decodeRestoreOutput(t, stdout)
if got.Result.Rev != "current-rev" {
t.Fatalf("rev = %q, want current-rev", got.Result.Rev)
}
}

func TestRestoreJSONErrorWritesNoOutput(t *testing.T) {
cmd, stdout := testRestoreCmd()
setRestoreOutputJSON(t, cmd)
mock := &mockFilesClient{
restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) {
return nil, errors.New("restore failed")
},
}
stubFilesClient(t, mock)

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

func TestRestoreCommandSupportsStructuredOutput(t *testing.T) {
if !commandSupportsStructuredOutput(restoreCmd) {
t.Fatal("restore command should support structured output")
}
}

Expand All @@ -128,5 +261,24 @@ func testRestoreCmd() (*cobra.Command, *bytes.Buffer) {
cmd := &cobra.Command{Use: "restore"}
cmd.SetOut(&stdout)
cmd.Flags().BoolP("verbose", "v", false, "")
cmd.Flags().String(outputFlag, "text", "")
return cmd, &stdout
}

func setRestoreOutputJSON(t *testing.T, cmd *cobra.Command) {
t.Helper()

if err := cmd.Flags().Set(outputFlag, "json"); err != nil {
t.Fatal(err)
}
}

func decodeRestoreOutput(t *testing.T, stdout *bytes.Buffer) restoreResult {
t.Helper()

var got restoreResult
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode JSON output: %v\noutput: %s", err, stdout.String())
}
return got
}
Loading