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
15 changes: 15 additions & 0 deletions cmd/cp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().String(outputFlag, string(output.FormatText), "")
cmd.Flags().String("if-exists", relocationIfExistsFail, "")
addDryRunFlag(cmd)
if err := cmd.Flags().Set(outputFlag, string(output.FormatJSON)); err != nil {
panic(err)
}
Expand All @@ -640,6 +641,20 @@ func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
return cmd
}

func newRelocationTextTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().String(outputFlag, string(output.FormatText), "")
cmd.Flags().String("if-exists", relocationIfExistsFail, "")
addDryRunFlag(cmd)
if stdout != nil {
cmd.SetOut(stdout)
}
if stderr != nil {
cmd.SetErr(stderr)
}
return cmd
}

type relocationOutput struct {
Input map[string]any `json:"input"`
Results []relocationJSONResult `json:"results"`
Expand Down
5 changes: 5 additions & 0 deletions cmd/dry_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func writeDryRunLine(w io.Writer, verb, path string) error {
return err
}

func writeDryRunRelocationLine(w io.Writer, verb, fromPath, toPath string) error {
_, err := fmt.Fprintf(w, "Would %s %s to %s\n", verb, fromPath, toPath)
return err
}

func dryRunDisplayPath(metadata jsonMetadata, fallback string) string {
if metadata.PathDisplay != "" {
return metadata.PathDisplay
Expand Down
10 changes: 10 additions & 0 deletions cmd/dry_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ func TestWriteDryRunLine(t *testing.T) {
}
}

func TestWriteDryRunRelocationLine(t *testing.T) {
var stdout bytes.Buffer
if err := writeDryRunRelocationLine(&stdout, "move", "/from.txt", "/to.txt"); err != nil {
t.Fatalf("writeDryRunRelocationLine error: %v", err)
}
if got, want := stdout.String(), "Would move /from.txt to /to.txt\n"; got != want {
t.Fatalf("writeDryRunRelocationLine output = %q, want %q", got, want)
}
}

func TestDryRunDisplayPath(t *testing.T) {
if got, want := dryRunDisplayPath(jsonMetadata{PathDisplay: "/Display.txt"}, "/fallback.txt"), "/Display.txt"; got != want {
t.Fatalf("dryRunDisplayPath with display path = %q, want %q", got, want)
Expand Down
4 changes: 2 additions & 2 deletions cmd/help_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{
commandArg("target", true, false, "dropbox_path", "Dropbox destination path"),
},
Examples: []jsonCommandExample{{Description: "Move a Dropbox file", Command: "dbxcli mv /from.txt /to.txt"}},
Flags: map[string]jsonCommandFlagMetadata{"if-exists": {EnumValues: []string{"fail", "skip", "autorename"}, ValueKind: "enum"}},
Flags: map[string]jsonCommandFlagMetadata{dryRunFlagName: {ValueKind: "boolean"}, "if-exists": {EnumValues: []string{"fail", "skip", "autorename"}, ValueKind: "enum"}},
DropboxScopes: []string{"files.content.write", "files.metadata.read"},
Known: true,
},
Expand Down Expand Up @@ -349,7 +349,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{
"logout": {Statuses: []string{"already_logged_out", "logged_out"}, Kinds: []string{"auth"}, Warnings: []string{jsonWarningCodeTokenRevokeFailed}},
"ls": {Statuses: []string{"listed"}, Kinds: []string{"deleted", "file", "folder"}},
"mkdir": {Statuses: []string{"created", "existing", jsonStatusPlanned}, Kinds: []string{"folder"}},
"mv": {Statuses: []string{"autorenamed", "moved", "skipped"}, Kinds: []string{"deleted", "file", "folder"}},
"mv": {Statuses: []string{"autorenamed", "moved", "skipped", jsonStatusPlanned}, Kinds: []string{"deleted", "file", "folder"}},
"put": {Statuses: []string{"autorenamed", "created", "existing", "skipped", "uploaded"}, Kinds: []string{"file", "folder"}, Warnings: []string{jsonWarningCodeSkippedSymlink}},
"restore": {Statuses: []string{"restored"}, Kinds: []string{"file"}},
"revs": {Statuses: []string{"revision"}, Kinds: []string{"file"}},
Expand Down
2 changes: 1 addition & 1 deletion cmd/json_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema {
"ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil),
"logout": operationSchema("empty", schemaRef("empty"), "logout_result", []string{logoutStatusAlreadyLoggedOut, logoutStatusLoggedOut}, []string{logoutKindAuth}, []string{jsonWarningCodeTokenRevokeFailed}),
"mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting, jsonStatusPlanned}, []string{mkdirKindFolder}, nil),
"mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusAutorenamed, relocationJSONStatusMoved, relocationJSONStatusSkipped}, metadataKinds(), nil),
"mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusAutorenamed, relocationJSONStatusMoved, relocationJSONStatusSkipped, jsonStatusPlanned}, metadataKinds(), nil),
"put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusAutorenamed, putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}),
"restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored}, []string{restoreKindFile}, nil),
"revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file"}, nil),
Expand Down
39 changes: 39 additions & 0 deletions cmd/mv.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package cmd

import (
"fmt"
"io"
"strings"

"github.com/dropbox/dbxcli/v3/internal/output"
Expand Down Expand Up @@ -45,6 +46,7 @@ func mv(cmd *cobra.Command, args []string) error {
var mvErrors []error
var mvErrorDetails []map[string]any
var relocationArgs []*files.RelocationArg
var plannedResults []relocationResult
var results []jsonOperationResult
collectResults := commandOutputFormat(cmd) == output.FormatJSON

Expand All @@ -59,6 +61,19 @@ func mv(cmd *cobra.Command, args []string) error {
mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(argument, dst))
} else {
arg.Autorename = opts.ifExists == relocationIfExistsAutorename
if opts.dryRun {
result, err := plannedMoveResult(dbx, arg)
if err != nil {
mvErrors = append(mvErrors, fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err))
mvErrorDetails = append(mvErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath))
continue
}
plannedResults = append(plannedResults, result)
if collectResults {
results = append(results, relocationOperationResult(relocationJSONStatusMoved, result))
}
continue
}
result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts)
if err != nil {
mvErrors = append(mvErrors, fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err))
Expand Down Expand Up @@ -108,12 +123,35 @@ func mv(cmd *cobra.Command, args []string) error {
return relocationAggregateError("mv", "move", len(mvErrors), mvErrorDetails)
}

if opts.dryRun {
return commandOutput(cmd).Render(func(w io.Writer) error {
return renderPlannedMoveResults(w, plannedResults)
}, newJSONCommandOperationOutput(cmd, nil, results, nil))
}

if !collectResults {
return nil
}
return renderJSONOperationOutput(cmd, nil, results)
}

func plannedMoveResult(dbx filesClient, arg *files.RelocationArg) (relocationResult, error) {
metadata, err := dbx.GetMetadataContext(currentContext(), files.NewGetMetadataArg(arg.FromPath))
if err != nil {
return relocationResult{}, err
}
return newPlannedRelocationResult(arg, metadata)
}

func renderPlannedMoveResults(w io.Writer, results []relocationResult) error {
for _, result := range results {
if err := writeDryRunRelocationLine(w, "move", result.Input.FromPath, result.Input.ToPath); err != nil {
return err
}
}
return nil
}

// mvCmd represents the mv command
var mvCmd = &cobra.Command{
Use: "mv [flags] <source> [more sources] <target>",
Expand All @@ -124,5 +162,6 @@ var mvCmd = &cobra.Command{
func init() {
RootCmd.AddCommand(mvCmd)
enableStructuredOutput(mvCmd)
addDryRunFlag(mvCmd)
mvCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail, skip, or autorename")
}
157 changes: 157 additions & 0 deletions cmd/mv_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,163 @@ func TestMvJSONOutputsRelocationResults(t *testing.T) {
}
}

func TestMvDryRunTextOutputSnapshot(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/dest/file-moved.txt":
return nil, relocationTestGetMetadataNotFoundError()
case "/src/file.txt":
return relocationTestFileMetadata("/src/file.txt", 64), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("MoveV2 called during dry-run: %v", arg)
return nil, nil
},
})

var stdout bytes.Buffer
cmd := newRelocationTextTestCommand(&stdout, nil)
if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil {
t.Fatal(err)
}

if err := mv(cmd, []string{"/src/file.txt", "/dest/file-moved.txt"}); err != nil {
t.Fatalf("mv error: %v", err)
}

const want = "Would move /src/file.txt to /dest/file-moved.txt\n"
if got := stdout.String(); got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}

func TestMvJSONDryRunOutputsPlannedResult(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/dest/file-moved.txt":
return nil, relocationTestGetMetadataNotFoundError()
case "/src/file.txt":
return relocationTestFileMetadata("/src/file.txt", 64), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("MoveV2 called during dry-run: %v", arg)
return nil, nil
},
})

var stdout bytes.Buffer
cmd := newRelocationTestCommand(&stdout, nil)
if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil {
t.Fatal(err)
}

if err := mv(cmd, []string{"/src/file.txt", "/dest/file-moved.txt"}); err != nil {
t.Fatalf("mv error: %v", err)
}

got := decodeRelocationOutput(t, stdout.Bytes())
if len(got.Results) != 1 {
t.Fatalf("results = %d, want 1", len(got.Results))
}
result := got.Results[0]
if result.Status != jsonStatusPlanned || result.Kind != "file" {
t.Fatalf("status/kind = %s/%s, want planned/file", result.Status, result.Kind)
}
if result.Input.FromPath != "/src/file.txt" || result.Input.ToPath != "/dest/file-moved.txt" || !result.Input.DryRun {
t.Fatalf("input = %#v, want source, destination, dry_run true", result.Input)
}
if result.Result.Type != "file" || result.Result.PathDisplay != "/dest/file-moved.txt" || result.Result.PathLower != "/dest/file-moved.txt" {
t.Fatalf("result = %#v, want planned destination file metadata", result.Result)
}
if result.Result.Size == nil || *result.Result.Size != 64 {
t.Fatalf("size = %#v, want 64", result.Result.Size)
}
}

func TestMvDryRunMultipleSourcesOutputsPlansWithoutMoving(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/src/a.txt", "/src/b.txt":
return relocationTestFileMetadata(arg.Path, 1), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("MoveV2 called during dry-run: %v", arg)
return nil, nil
},
})

var stdout bytes.Buffer
cmd := newRelocationTextTestCommand(&stdout, nil)
if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil {
t.Fatal(err)
}

if err := mv(cmd, []string{"/src/a.txt", "/src/b.txt", "/dest"}); err != nil {
t.Fatalf("mv error: %v", err)
}

const want = "Would move /src/a.txt to /dest/a.txt\nWould move /src/b.txt to /dest/b.txt\n"
if got := stdout.String(); got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
}

func TestMvDryRunSingleSourceExistingDestinationFolder(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/dest":
return mkdirFolderMetadata("/dest"), nil
case "/src/file.txt":
return relocationTestFileMetadata("/src/file.txt", 64), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("MoveV2 called during dry-run: %v", arg)
return nil, nil
},
})

var stdout bytes.Buffer
cmd := newRelocationTestCommand(&stdout, nil)
if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil {
t.Fatal(err)
}

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

got := decodeRelocationOutput(t, stdout.Bytes())
if len(got.Results) != 1 {
t.Fatalf("results = %d, want 1", len(got.Results))
}
if got.Results[0].Input.ToPath != "/dest/file.txt" {
t.Fatalf("to_path = %q, want /dest/file.txt", got.Results[0].Input.ToPath)
}
if got.Results[0].Result.PathDisplay != "/dest/file.txt" {
t.Fatalf("path_display = %q, want /dest/file.txt", got.Results[0].Result.PathDisplay)
}
}

func TestMvJSONMultipleSourcesOutputsMultipleResults(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
Expand Down
11 changes: 10 additions & 1 deletion cmd/relocation_if_exists.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,23 @@ const (

type relocationOptions struct {
ifExists string
dryRun bool
}

func parseRelocationOptions(cmd *cobra.Command) (relocationOptions, error) {
ifExists, err := parseRelocationIfExists(cmd)
if err != nil {
return relocationOptions{}, err
}
return relocationOptions{ifExists: ifExists}, nil
dryRun := false
// cp shares relocation parsing but does not register --dry-run yet.
if cmd != nil && cmd.Flags().Lookup(dryRunFlagName) != nil {
dryRun, err = dryRunEnabled(cmd)
if err != nil {
return relocationOptions{}, err
}
}
return relocationOptions{ifExists: ifExists, dryRun: dryRun}, nil
}

func parseRelocationIfExists(cmd *cobra.Command) (string, error) {
Expand Down
Loading
Loading