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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ 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
```

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
65 changes: 25 additions & 40 deletions cmd/rm.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package cmd
import (
"errors"
"fmt"
"io"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"

Expand All @@ -42,17 +43,13 @@ type removeInput struct {
Force bool `json:"force"`
}

type removeMetadata 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"`
type removeResult struct {
Input removeInput `json:"input"`
Result jsonMetadata `json:"result"`
}

type removeResult struct {
Input removeInput `json:"input"`
Result removeMetadata `json:"result"`
type removeOutput struct {
Results []removeResult `json:"results"`
}

func rm(cmd *cobra.Command, args []string) error {
Expand All @@ -77,11 +74,12 @@ func rm(cmd *cobra.Command, args []string) error {
return err
}

if opts.verbose {
printRemoveResults(cmd, results)
}

return nil
return commandOutput(cmd).Render(func(w io.Writer) error {
if !opts.verbose {
return nil
}
return renderRemoveResults(w, results)
}, removeOutput{Results: results})
}

func parseRemoveOptions(cmd *cobra.Command) (removeOptions, error) {
Expand Down Expand Up @@ -180,28 +178,10 @@ func newRemoveResult(path string, metadata files.IsMetadata, opts removeOptions)
}
}

func removeMetadataFromDropbox(path string, metadata files.IsMetadata) removeMetadata {
switch m := metadata.(type) {
case *files.FileMetadata:
return removeMetadata{
Type: "file",
PathDisplay: metadataDisplayPath(path, m.PathDisplay),
ID: m.Id,
Rev: m.Rev,
Size: m.Size,
}
case *files.FolderMetadata:
return removeMetadata{
Type: "folder",
PathDisplay: metadataDisplayPath(path, m.PathDisplay),
ID: m.Id,
}
default:
return removeMetadata{
Type: "unknown",
PathDisplay: path,
}
}
func removeMetadataFromDropbox(path string, metadata files.IsMetadata) jsonMetadata {
result := jsonMetadataFromDropbox(metadata)
result.PathDisplay = metadataDisplayPath(path, result.PathDisplay)
return result
}

func metadataDisplayPath(inputPath, metadataPath string) string {
Expand All @@ -211,15 +191,19 @@ func metadataDisplayPath(inputPath, metadataPath string) string {
return inputPath
}

func printRemoveResults(cmd *cobra.Command, results []removeResult) {
out := commandOutput(cmd)
func renderRemoveResults(w io.Writer, results []removeResult) error {
for _, result := range results {
if result.Input.Permanent {
out.Info("Permanently deleted %s", result.displayPath())
if _, err := fmt.Fprintf(w, "Permanently deleted %s\n", result.displayPath()); err != nil {
return err
}
continue
}
out.Info("Deleted %s", result.displayPath())
if _, err := fmt.Fprintf(w, "Deleted %s\n", result.displayPath()); err != nil {
return err
}
}
return nil
}

func (r removeResult) displayPath() string {
Expand All @@ -242,6 +226,7 @@ var rmCmd = &cobra.Command{

func init() {
RootCmd.AddCommand(rmCmd)
enableStructuredOutput(rmCmd)
rmCmd.Flags().BoolP("force", "f", false, "Allow removing non-empty folders; same as --recursive")
rmCmd.Flags().BoolP("recursive", "r", false, "Recursively remove folders")
rmCmd.Flags().Bool("permanent", false, "Permanently delete instead of moving to Dropbox trash")
Expand Down
241 changes: 241 additions & 0 deletions cmd/rm_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"
"fmt"
"strings"
"testing"
Expand All @@ -20,6 +22,7 @@ func testRmCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) {
cmd.Flags().BoolP("recursive", "r", false, "")
cmd.Flags().Bool("permanent", false, "")
cmd.Flags().BoolP("verbose", "v", false, "")
cmd.Flags().String(outputFlag, "text", "")
return cmd, &stdout
}

Expand All @@ -36,6 +39,7 @@ func rmFileMetadata(path string) *files.FileMetadata {
Metadata: files.Metadata{
Name: strings.TrimPrefix(path, "/"),
PathDisplay: path,
PathLower: strings.ToLower(path),
},
Id: "id:file",
Rev: "rev",
Expand All @@ -48,11 +52,36 @@ func rmFolderMetadata(path string) *files.FolderMetadata {
Metadata: files.Metadata{
Name: strings.TrimPrefix(path, "/"),
PathDisplay: path,
PathLower: strings.ToLower(path),
},
Id: "id:folder",
}
}

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

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

func decodeRemoveOutput(t *testing.T, stdout *bytes.Buffer) removeOutput {
t.Helper()

return decodeRemoveOutputString(t, stdout.String())
}

func decodeRemoveOutputString(t *testing.T, output string) removeOutput {
t.Helper()

var got removeOutput
if err := json.Unmarshal([]byte(output), &got); err != nil {
t.Fatalf("decode JSON output: %v\noutput: %s", err, output)
}
return got
}

func rmNonEmptyFolderResult() *files.ListFolderResult {
return &files.ListFolderResult{
Entries: []files.IsMetadata{rmFileMetadata("/folder/file.txt")},
Expand Down Expand Up @@ -380,3 +409,215 @@ func TestRmVerbosePrintsPermanentDeleteResults(t *testing.T) {
t.Fatalf("stdout = %q, want %q", got, want)
}
}

func TestRmJSONDeletesFile(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)
file := rmFileMetadata("/File.txt")
deletedFile := rmFileMetadata("/File.txt")
deletedFile.Rev = "deleted-rev"
deletedFile.Size = 456

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return file, nil
},
deleteV2Fn: func(arg *files.DeleteArg) (*files.DeleteResult, error) {
return files.NewDeleteResult(deletedFile), nil
},
}
stubFilesClient(t, mock)

if err := rm(cmd, []string{"/File.txt"}); err != nil {
t.Fatalf("rm error: %v", err)
}

got := decodeRemoveOutput(t, stdout)
if len(got.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(got.Results))
}
result := got.Results[0]
if result.Input.Path != "/File.txt" {
t.Fatalf("input path = %q, want /File.txt", result.Input.Path)
}
if result.Input.Permanent || result.Input.Recursive || result.Input.Force {
t.Fatalf("input flags = %+v, want all false", result.Input)
}
if result.Result.Type != "file" {
t.Fatalf("result type = %q, want file", result.Result.Type)
}
if result.Result.PathDisplay != "/File.txt" {
t.Fatalf("path_display = %q, want /File.txt", result.Result.PathDisplay)
}
if result.Result.PathLower != "/file.txt" {
t.Fatalf("path_lower = %q, want /file.txt", result.Result.PathLower)
}
if result.Result.ID != "id:file" {
t.Fatalf("id = %q, want id:file", result.Result.ID)
}
if result.Result.Rev != "deleted-rev" {
t.Fatalf("rev = %q, want deleted-rev", result.Result.Rev)
}
if result.Result.Size == nil || *result.Result.Size != 456 {
t.Fatalf("size = %v, want 456", result.Result.Size)
}
}

func TestRmJSONFolderOmitsFileFields(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)
setRmFlag(t, cmd, "recursive")
folder := rmFolderMetadata("/Folder")

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return folder, nil
},
deleteV2Fn: func(arg *files.DeleteArg) (*files.DeleteResult, error) {
return files.NewDeleteResult(folder), nil
},
}
stubFilesClient(t, mock)

if err := rm(cmd, []string{"/Folder"}); err != nil {
t.Fatalf("rm error: %v", err)
}

output := stdout.String()
if strings.Contains(output, `"rev"`) || strings.Contains(output, `"size"`) {
t.Fatalf("folder JSON output = %s, want no file-only fields", output)
}
got := decodeRemoveOutputString(t, output)
if len(got.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(got.Results))
}
result := got.Results[0]
if result.Result.Type != "folder" {
t.Fatalf("result type = %q, want folder", result.Result.Type)
}
if !result.Input.Recursive {
t.Fatalf("recursive = false, want true")
}
}

func TestRmJSONPermanentUsesValidatedMetadata(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)
setRmFlag(t, cmd, "permanent")
file := rmFileMetadata("/File.txt")
file.Rev = "validated-rev"
var permanentlyDeleted []string

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return file, nil
},
deleteV2Fn: func(arg *files.DeleteArg) (*files.DeleteResult, error) {
t.Fatalf("DeleteV2 called for permanent delete: %v", arg)
return nil, nil
},
permanentlyDeleteFn: func(arg *files.DeleteArg) error {
permanentlyDeleted = append(permanentlyDeleted, arg.Path)
return nil
},
}
stubFilesClient(t, mock)

if err := rm(cmd, []string{"/File.txt"}); err != nil {
t.Fatalf("rm error: %v", err)
}
if len(permanentlyDeleted) != 1 || permanentlyDeleted[0] != "/File.txt" {
t.Fatalf("permanentlyDeleted = %v, want [/File.txt]", permanentlyDeleted)
}
got := decodeRemoveOutput(t, stdout)
if len(got.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(got.Results))
}
result := got.Results[0]
if !result.Input.Permanent {
t.Fatalf("permanent = false, want true")
}
if result.Result.Rev != "validated-rev" {
t.Fatalf("rev = %q, want validated-rev", result.Result.Rev)
}
}

func TestRmJSONMultipleTargets(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return rmFileMetadata(arg.Path), nil
},
deleteV2Fn: func(arg *files.DeleteArg) (*files.DeleteResult, error) {
return files.NewDeleteResult(rmFileMetadata(arg.Path)), nil
},
}
stubFilesClient(t, mock)

if err := rm(cmd, []string{"/one.txt", "/two.txt"}); err != nil {
t.Fatalf("rm error: %v", err)
}

got := decodeRemoveOutput(t, stdout)
if len(got.Results) != 2 {
t.Fatalf("results len = %d, want 2", len(got.Results))
}
if got.Results[0].Input.Path != "/one.txt" || got.Results[1].Input.Path != "/two.txt" {
t.Fatalf("result paths = %q, %q; want /one.txt, /two.txt", got.Results[0].Input.Path, got.Results[1].Input.Path)
}
}

func TestRmJSONVerboseDoesNotPrintText(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)
setRmFlag(t, cmd, "verbose")
file := rmFileMetadata("/File.txt")

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return file, nil
},
deleteV2Fn: func(arg *files.DeleteArg) (*files.DeleteResult, error) {
return files.NewDeleteResult(file), nil
},
}
stubFilesClient(t, mock)

if err := rm(cmd, []string{"/File.txt"}); err != nil {
t.Fatalf("rm error: %v", err)
}
if strings.Contains(stdout.String(), "Deleted ") {
t.Fatalf("stdout = %q, want JSON only", stdout.String())
}
got := decodeRemoveOutput(t, stdout)
if len(got.Results) != 1 {
t.Fatalf("results len = %d, want 1", len(got.Results))
}
}

func TestRmJSONErrorWritesNoOutput(t *testing.T) {
cmd, stdout := testRmCmd(t)
setRmOutputJSON(t, cmd)

mock := &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
return nil, errors.New("metadata failed")
},
}
stubFilesClient(t, mock)

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

func TestRmCommandSupportsStructuredOutput(t *testing.T) {
if !commandSupportsStructuredOutput(rmCmd) {
t.Fatal("rm command should support structured output")
}
}