diff --git a/README.md b/README.md index c920d7d..2817c38 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Text output is the default. JSON output is available through the global `--outpu ```sh $ dbxcli --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`. diff --git a/cmd/rm.go b/cmd/rm.go index 0ca831f..57cb17b 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -17,6 +17,7 @@ package cmd import ( "errors" "fmt" + "io" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" @@ -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 { @@ -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) { @@ -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 { @@ -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 { @@ -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") diff --git a/cmd/rm_test.go b/cmd/rm_test.go index f960064..b587e46 100644 --- a/cmd/rm_test.go +++ b/cmd/rm_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "encoding/json" + "errors" "fmt" "strings" "testing" @@ -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 } @@ -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", @@ -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")}, @@ -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") + } +}