diff --git a/README.md b/README.md index 27fb3ff..dcbf2db 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ folder or a team folder. * File operations: `ls`, `cp`, `mkdir`, `mv`, `rm`, `put`, and `get` * Recursive upload and download with `put -r` and `get -r` * Pipe-friendly transfers with stdin upload and stdout download -* Upload conflict control with `put --if-exists overwrite|skip|fail` +* Conflict control with `put --if-exists overwrite|skip|fail` and `cp`/`mv --if-exists fail|skip` * Shared-link creation, listing, inspection, update, revoke, and download * Search, file revisions, restore, flexible sorting, and time formatting * Chunked uploads for large files and paginated listing for large directories diff --git a/cmd/cp.go b/cmd/cp.go index e90a42b..b29cbe9 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -37,9 +37,14 @@ func cp(cmd *cobra.Command, args []string) error { return invalidArgumentsErrorWithDetails("cp requires a source and a destination", argumentsErrorDetails("source", "destination")) } + opts, err := parseRelocationOptions(cmd) + if err != nil { + return err + } + var cpErrors []error var relocationArgs []*files.RelocationArg - var results []relocationResult + var results []jsonOperationResult collectResults := commandOutputFormat(cmd) == output.FormatJSON dbx := filesNewFunc(config) @@ -52,6 +57,17 @@ func cp(cmd *cobra.Command, args []string) error { relocationError := fmt.Errorf("Error validating copy for %s to %s: %v", argument, dst, err) cpErrors = append(cpErrors, relocationError) } else { + result, skipped, err := relocationSkipIfDestinationExists(dbx, arg, opts) + if err != nil { + cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)) + continue + } + if skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } relocationArgs = append(relocationArgs, arg) } } @@ -59,6 +75,12 @@ func cp(cmd *cobra.Command, args []string) error { for _, arg := range relocationArgs { res, err := dbx.CopyV2(arg) if err != nil { + if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err) cpErrors = append(cpErrors, copyError) continue @@ -70,7 +92,7 @@ func cp(cmd *cobra.Command, args []string) error { cpErrors = append(cpErrors, copyError) continue } - results = append(results, result) + results = append(results, relocationOperationResult(relocationJSONStatusCopied, result)) } } @@ -84,7 +106,7 @@ func cp(cmd *cobra.Command, args []string) error { if !collectResults { return nil } - return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusCopied, results)) + return renderJSONOperationOutput(cmd, nil, results) } // cpCmd represents the cp command @@ -98,4 +120,5 @@ var cpCmd = &cobra.Command{ func init() { RootCmd.AddCommand(cpCmd) enableStructuredOutput(cpCmd) + cpCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail or skip") } diff --git a/cmd/cp_test.go b/cmd/cp_test.go index 43e59e7..2c43b4f 100644 --- a/cmd/cp_test.go +++ b/cmd/cp_test.go @@ -290,6 +290,240 @@ func TestCpJSONErrorUsesCommandStderr(t *testing.T) { } } +func TestCpCommandDefinesIfExistsFlag(t *testing.T) { + flag := cpCmd.Flags().Lookup("if-exists") + if flag == nil { + t.Fatal("cp should define --if-exists") + } + if flag.DefValue != relocationIfExistsFail { + t.Fatalf("--if-exists default = %q, want %q", flag.DefValue, relocationIfExistsFail) + } +} + +func TestCpInvalidIfExistsReturnsInvalidArguments(t *testing.T) { + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", "replace"); err != nil { + t.Fatal(err) + } + + err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}) + if err == nil { + t.Fatal("expected cp error") + } + if code := jsonErrorCode(err); code != jsonErrorCodeInvalidArguments { + t.Fatalf("json error code = %q, want %q", code, jsonErrorCodeInvalidArguments) + } + details := jsonErrorDetails(err) + if details["flag"] != "if-exists" || details["value"] != "replace" { + t.Fatalf("details = %#v, want if-exists flag value", details) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestCpIfExistsFailCallsCopy(t *testing.T) { + var copied []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copied = append(copied, arg) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsFail); err != nil { + t.Fatal(err) + } + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if len(copied) != 1 { + t.Fatalf("copied = %d, want 1", len(copied)) + } +} + +func TestCpIfExistsSkipExistingDestinationDoesNotCopy(t *testing.T) { + var copied bool + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + if arg.Path != "/dest/file.txt" { + t.Fatalf("metadata path = %q, want /dest/file.txt", arg.Path) + } + return relocationTestFileMetadata(arg.Path, 8), nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copied = true + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if copied { + t.Fatal("CopyV2 called for skipped destination") + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusSkipped { + t.Fatalf("status = %q, want skipped", got.Results[0].Status) + } + if got.Results[0].Input.FromPath != "/src/file.txt" || got.Results[0].Input.ToPath != "/dest/file.txt" { + t.Fatalf("input = %#v, want source and destination", got.Results[0].Input) + } +} + +func TestCpIfExistsSkipMissingDestinationCopies(t *testing.T) { + var copied []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copied = append(copied, arg) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 3)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if len(copied) != 1 { + t.Fatalf("copied = %d, want 1", len(copied)) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if got.Results[0].Status != relocationJSONStatusCopied { + t.Fatalf("status = %q, want copied", got.Results[0].Status) + } +} + +func TestCpIfExistsSkipConvertsDestinationConflict(t *testing.T) { + getMetadataCalls := 0 + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + getMetadataCalls++ + if getMetadataCalls < 3 { + return nil, relocationTestGetMetadataNotFoundError() + } + return relocationTestFileMetadata(arg.Path, 13), nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return nil, relocationTestCopyDestinationConflictError() + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp 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].Status != relocationJSONStatusSkipped { + t.Fatalf("status = %q, want skipped", got.Results[0].Status) + } +} + +func TestCpIfExistsSkipMultipleSourcesAppliesPerTarget(t *testing.T) { + var copied []string + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + switch arg.Path { + case "/dest/a.txt": + return relocationTestFileMetadata(arg.Path, 1), nil + case "/dest/b.txt": + return nil, relocationTestGetMetadataNotFoundError() + default: + t.Fatalf("unexpected metadata path %q", arg.Path) + return nil, nil + } + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + copied = append(copied, arg.ToPath) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 2)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := cp(cmd, []string{"/src/a.txt", "/src/b.txt", "/dest"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if len(copied) != 1 || copied[0] != "/dest/b.txt" { + t.Fatalf("copied = %#v, want only /dest/b.txt", copied) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 2 { + t.Fatalf("results = %d, want 2", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusSkipped || got.Results[1].Status != relocationJSONStatusCopied { + t.Fatalf("statuses = %q, %q; want skipped, copied", got.Results[0].Status, got.Results[1].Status) + } +} + +func TestCpIfExistsSkipTextModeQuiet(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return relocationTestFileMetadata(arg.Path, 8), nil + }, + copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + t.Fatal("CopyV2 called for skipped destination") + return nil, nil + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().String(outputFlag, string(output.FormatText), "") + cmd.Flags().String("if-exists", relocationIfExistsFail, "") + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("cp error: %v", err) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + func TestCpCommandSupportsStructuredOutput(t *testing.T) { if !commandSupportsStructuredOutput(cpCmd) { t.Fatal("cp should support structured output") @@ -299,6 +533,7 @@ func TestCpCommandSupportsStructuredOutput(t *testing.T) { func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { cmd := &cobra.Command{} cmd.Flags().String(outputFlag, string(output.FormatText), "") + cmd.Flags().String("if-exists", relocationIfExistsFail, "") if err := cmd.Flags().Set(outputFlag, string(output.FormatJSON)); err != nil { panic(err) } @@ -312,9 +547,16 @@ func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command { } type relocationOutput struct { - Input map[string]any `json:"input"` - Results []relocationResult `json:"results"` - Warnings []jsonWarning `json:"warnings"` + Input map[string]any `json:"input"` + Results []relocationJSONResult `json:"results"` + Warnings []jsonWarning `json:"warnings"` +} + +type relocationJSONResult struct { + Status string `json:"status"` + Kind string `json:"kind"` + Input relocationInput `json:"input"` + Result jsonMetadata `json:"result"` } func decodeRelocationOutput(t *testing.T, data []byte) relocationOutput { @@ -337,3 +579,41 @@ func decodeRelocationOutput(t *testing.T, data []byte) relocationOutput { } return got } + +func relocationTestFileMetadata(pathDisplay string, size uint64) *files.FileMetadata { + metadata := files.NewFileMetadata(path.Base(pathDisplay), "id:"+path.Base(pathDisplay), time.Time{}, time.Time{}, "rev", size) + metadata.PathDisplay = pathDisplay + metadata.PathLower = strings.ToLower(pathDisplay) + return metadata +} + +func relocationTestGetMetadataNotFoundError() error { + return files.GetMetadataAPIError{ + EndpointError: &files.GetMetadataError{ + Tagged: dropbox.Tagged{Tag: files.GetMetadataErrorPath}, + Path: &files.LookupError{Tagged: dropbox.Tagged{Tag: files.LookupErrorNotFound}}, + }, + } +} + +func relocationTestCopyDestinationConflictError() error { + return files.CopyV2APIError{ + EndpointError: relocationTestDestinationConflictError(), + } +} + +func relocationTestMoveDestinationConflictError() error { + return files.MoveV2APIError{ + EndpointError: relocationTestDestinationConflictError(), + } +} + +func relocationTestDestinationConflictError() *files.RelocationError { + return &files.RelocationError{ + Tagged: dropbox.Tagged{Tag: files.RelocationErrorTo}, + To: &files.WriteError{ + Tagged: dropbox.Tagged{Tag: files.WriteErrorConflict}, + Conflict: &files.WriteConflictError{Tagged: dropbox.Tagged{Tag: files.WriteConflictErrorFile}}, + }, + } +} diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 87b68dc..7e1dbd5 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -885,14 +885,14 @@ func jsonContractDefinitions() map[string][]string { func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { return map[string]jsonGoldenCommandSchema{ "account": operationSchema("account_input", schemaRef("account_input"), "account", []string{accountJSONStatusFound}, []string{accountKindAccount}, nil), - "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusCopied}, metadataKinds(), nil), + "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusCopied, relocationJSONStatusSkipped}, metadataKinds(), nil), "du": operationSchema("empty", schemaRef("empty"), "du_output", []string{duJSONStatusReported}, []string{duKindSpaceUsage}, nil), "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), "help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil), "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}, []string{mkdirKindFolder}, nil), - "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved}, metadataKinds(), nil), + "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved, relocationJSONStatusSkipped}, metadataKinds(), nil), "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{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), diff --git a/cmd/mv.go b/cmd/mv.go index cc1389d..ca1aba6 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -37,9 +37,14 @@ func mv(cmd *cobra.Command, args []string) error { return invalidArgumentsErrorWithDetails("mv command requires a source and a destination", argumentsErrorDetails("source", "destination")) } + opts, err := parseRelocationOptions(cmd) + if err != nil { + return err + } + var mvErrors []error var relocationArgs []*files.RelocationArg - var results []relocationResult + var results []jsonOperationResult collectResults := commandOutputFormat(cmd) == output.FormatJSON dbx := filesNewFunc(config) @@ -51,6 +56,17 @@ func mv(cmd *cobra.Command, args []string) error { if err != nil { mvErrors = append(mvErrors, fmt.Errorf("Error validating move for %s to %s: %v", argument, dst, err)) } else { + 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)) + continue + } + if skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } relocationArgs = append(relocationArgs, arg) } } @@ -58,6 +74,12 @@ func mv(cmd *cobra.Command, args []string) error { for _, arg := range relocationArgs { res, err := dbx.MoveV2(arg) if err != nil { + if result, skipped := relocationSkipAfterDestinationConflict(dbx, arg, err, opts); skipped { + if collectResults { + results = append(results, relocationOperationResult(relocationJSONStatusSkipped, result)) + } + continue + } moveError := fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err) mvErrors = append(mvErrors, moveError) continue @@ -69,7 +91,7 @@ func mv(cmd *cobra.Command, args []string) error { mvErrors = append(mvErrors, moveError) continue } - results = append(results, result) + results = append(results, relocationOperationResult(relocationJSONStatusMoved, result)) } } @@ -83,12 +105,12 @@ func mv(cmd *cobra.Command, args []string) error { if !collectResults { return nil } - return renderJSONOperationOutput(cmd, nil, relocationOperationResults(relocationJSONStatusMoved, results)) + return renderJSONOperationOutput(cmd, nil, results) } // mvCmd represents the mv command var mvCmd = &cobra.Command{ - Use: "mv [flags] ", + Use: "mv [flags] [more sources] ", Short: "Move files", RunE: mv, } @@ -96,4 +118,5 @@ var mvCmd = &cobra.Command{ func init() { RootCmd.AddCommand(mvCmd) enableStructuredOutput(mvCmd) + mvCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail or skip") } diff --git a/cmd/mv_test.go b/cmd/mv_test.go index 68ba9d9..d215ccb 100644 --- a/cmd/mv_test.go +++ b/cmd/mv_test.go @@ -8,7 +8,9 @@ import ( "testing" "time" + "github.com/dropbox/dbxcli/v3/internal/output" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" ) func TestMvArgValidation(t *testing.T) { @@ -167,6 +169,237 @@ func TestMvJSONErrorUsesCommandStderr(t *testing.T) { } } +func TestMvCommandDefinesIfExistsFlag(t *testing.T) { + flag := mvCmd.Flags().Lookup("if-exists") + if flag == nil { + t.Fatal("mv should define --if-exists") + } + if flag.DefValue != relocationIfExistsFail { + t.Fatalf("--if-exists default = %q, want %q", flag.DefValue, relocationIfExistsFail) + } +} + +func TestMvInvalidIfExistsReturnsInvalidArguments(t *testing.T) { + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", "replace"); err != nil { + t.Fatal(err) + } + + err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}) + if err == nil { + t.Fatal("expected mv error") + } + if code := jsonErrorCode(err); code != jsonErrorCodeInvalidArguments { + t.Fatalf("json error code = %q, want %q", code, jsonErrorCodeInvalidArguments) + } + details := jsonErrorDetails(err) + if details["flag"] != "if-exists" || details["value"] != "replace" { + t.Fatalf("details = %#v, want if-exists flag value", details) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } +} + +func TestMvIfExistsFailCallsMove(t *testing.T) { + var moved []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moved = append(moved, arg) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 1)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsFail); err != nil { + t.Fatal(err) + } + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + if len(moved) != 1 { + t.Fatalf("moved = %d, want 1", len(moved)) + } +} + +func TestMvIfExistsSkipExistingDestinationDoesNotMove(t *testing.T) { + var moved bool + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + if arg.Path != "/dest/file.txt" { + t.Fatalf("metadata path = %q, want /dest/file.txt", arg.Path) + } + return relocationTestFileMetadata(arg.Path, 8), nil + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moved = true + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + if moved { + t.Fatal("MoveV2 called for skipped destination") + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 1 { + t.Fatalf("results = %d, want 1", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusSkipped { + t.Fatalf("status = %q, want skipped", got.Results[0].Status) + } +} + +func TestMvIfExistsSkipMissingDestinationMoves(t *testing.T) { + var moved []*files.RelocationArg + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return nil, relocationTestGetMetadataNotFoundError() + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moved = append(moved, arg) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 3)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + if len(moved) != 1 { + t.Fatalf("moved = %d, want 1", len(moved)) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if got.Results[0].Status != relocationJSONStatusMoved { + t.Fatalf("status = %q, want moved", got.Results[0].Status) + } +} + +func TestMvIfExistsSkipConvertsDestinationConflict(t *testing.T) { + getMetadataCalls := 0 + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + getMetadataCalls++ + if getMetadataCalls < 3 { + return nil, relocationTestGetMetadataNotFoundError() + } + return relocationTestFileMetadata(arg.Path, 13), nil + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + return nil, relocationTestMoveDestinationConflictError() + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.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)) + } + if got.Results[0].Status != relocationJSONStatusSkipped { + t.Fatalf("status = %q, want skipped", got.Results[0].Status) + } +} + +func TestMvIfExistsSkipMultipleSourcesAppliesPerTarget(t *testing.T) { + var moved []string + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + switch arg.Path { + case "/dest/a.txt": + return relocationTestFileMetadata(arg.Path, 1), nil + case "/dest/b.txt": + return nil, relocationTestGetMetadataNotFoundError() + default: + t.Fatalf("unexpected metadata path %q", arg.Path) + return nil, nil + } + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + moved = append(moved, arg.ToPath) + return files.NewRelocationResult(relocationTestFileMetadata(arg.ToPath, 2)), nil + }, + }) + + var stdout bytes.Buffer + cmd := newRelocationTestCommand(&stdout, nil) + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); 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) + } + if len(moved) != 1 || moved[0] != "/dest/b.txt" { + t.Fatalf("moved = %#v, want only /dest/b.txt", moved) + } + got := decodeRelocationOutput(t, stdout.Bytes()) + if len(got.Results) != 2 { + t.Fatalf("results = %d, want 2", len(got.Results)) + } + if got.Results[0].Status != relocationJSONStatusSkipped || got.Results[1].Status != relocationJSONStatusMoved { + t.Fatalf("statuses = %q, %q; want skipped, moved", got.Results[0].Status, got.Results[1].Status) + } +} + +func TestMvIfExistsSkipTextModeQuiet(t *testing.T) { + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + return relocationTestFileMetadata(arg.Path, 8), nil + }, + moveV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) { + t.Fatal("MoveV2 called for skipped destination") + return nil, nil + }, + }) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd := &cobra.Command{} + cmd.Flags().String(outputFlag, string(output.FormatText), "") + cmd.Flags().String("if-exists", relocationIfExistsFail, "") + if err := cmd.Flags().Set("if-exists", relocationIfExistsSkip); err != nil { + t.Fatal(err) + } + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + if err := mv(cmd, []string{"/src/file.txt", "/dest/file.txt"}); err != nil { + t.Fatalf("mv error: %v", err) + } + if stdout.String() != "" { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } +} + func TestMvCommandSupportsStructuredOutput(t *testing.T) { if !commandSupportsStructuredOutput(mvCmd) { t.Fatal("mv should support structured output") diff --git a/cmd/relocation_if_exists.go b/cmd/relocation_if_exists.go new file mode 100644 index 0000000..e0e8a57 --- /dev/null +++ b/cmd/relocation_if_exists.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "errors" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" +) + +const ( + relocationIfExistsFail = "fail" + relocationIfExistsSkip = "skip" +) + +type relocationOptions struct { + ifExists string +} + +func parseRelocationOptions(cmd *cobra.Command) (relocationOptions, error) { + ifExists, err := parseRelocationIfExists(cmd) + if err != nil { + return relocationOptions{}, err + } + return relocationOptions{ifExists: ifExists}, nil +} + +func parseRelocationIfExists(cmd *cobra.Command) (string, error) { + if cmd == nil { + return relocationIfExistsFail, nil + } + ifExists, err := cmd.Flags().GetString("if-exists") + if err != nil { + return relocationIfExistsFail, nil + } + return normalizeRelocationIfExists(ifExists) +} + +func normalizeRelocationIfExists(ifExists string) (string, error) { + switch ifExists { + case "", relocationIfExistsFail: + return relocationIfExistsFail, nil + case relocationIfExistsSkip: + return relocationIfExistsSkip, nil + default: + return "", invalidArgumentsErrorfWithDetails("invalid --if-exists %q (use fail or skip)", flagValueErrorDetails("if-exists", ifExists), ifExists) + } +} + +func relocationSkipIfDestinationExists(dbx files.Client, arg *files.RelocationArg, opts relocationOptions) (relocationResult, bool, error) { + if opts.ifExists != relocationIfExistsSkip { + return relocationResult{}, false, nil + } + return relocationSkippedResult(dbx, arg) +} + +func relocationSkipAfterDestinationConflict(dbx files.Client, arg *files.RelocationArg, err error, opts relocationOptions) (relocationResult, bool) { + if opts.ifExists != relocationIfExistsSkip || !isRelocationDestinationConflict(err) { + return relocationResult{}, false + } + + result, skipped, skipErr := relocationSkippedResult(dbx, arg) + if skipErr != nil || !skipped { + return relocationResult{}, false + } + return result, true +} + +func relocationSkippedResult(dbx files.Client, arg *files.RelocationArg) (relocationResult, bool, error) { + metadata, exists, err := getDestinationMetadata(dbx, arg.ToPath) + if err != nil || !exists { + return relocationResult{}, false, err + } + result, err := newRelocationResultFromMetadata(arg, metadata) + if err != nil { + return relocationResult{}, false, err + } + return result, true, nil +} + +func isRelocationDestinationConflict(err error) bool { + var copyErr files.CopyV2APIError + if errors.As(err, ©Err) && relocationErrorHasDestinationConflict(copyErr.EndpointError) { + return true + } + + var copyErrPtr *files.CopyV2APIError + if errors.As(err, ©ErrPtr) && copyErrPtr != nil && relocationErrorHasDestinationConflict(copyErrPtr.EndpointError) { + return true + } + + var moveErr files.MoveV2APIError + if errors.As(err, &moveErr) && relocationErrorHasDestinationConflict(moveErr.EndpointError) { + return true + } + + var moveErrPtr *files.MoveV2APIError + if errors.As(err, &moveErrPtr) && moveErrPtr != nil && relocationErrorHasDestinationConflict(moveErrPtr.EndpointError) { + return true + } + + return false +} + +func relocationErrorHasDestinationConflict(err *files.RelocationError) bool { + return err != nil && + err.Tag == files.RelocationErrorTo && + isRelocationWriteConflict(err.To) +} + +func isRelocationWriteConflict(err *files.WriteError) bool { + return err != nil && + err.Tag == files.WriteErrorConflict && + err.Conflict != nil && + (err.Conflict.Tag == files.WriteConflictErrorFile || + err.Conflict.Tag == files.WriteConflictErrorFolder) +} diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index 9bee1e8..a09a9b7 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -3,8 +3,9 @@ package cmd import "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" const ( - relocationJSONStatusCopied = "copied" - relocationJSONStatusMoved = "moved" + relocationJSONStatusCopied = "copied" + relocationJSONStatusMoved = "moved" + relocationJSONStatusSkipped = "skipped" ) type relocationInput struct { @@ -22,10 +23,15 @@ func newRelocationResult(arg *files.RelocationArg, res *files.RelocationResult) if res != nil { metadata = res.Metadata } + return newRelocationResultFromMetadata(arg, metadata) +} + +func newRelocationResultFromMetadata(arg *files.RelocationArg, metadata files.IsMetadata) (relocationResult, error) { result, err := jsonMetadataFromDropbox(metadata) if err != nil { return relocationResult{}, err } + result.PathDisplay = metadataDisplayPath(arg.ToPath, result.PathDisplay) return relocationResult{ Input: relocationInput{ @@ -36,10 +42,6 @@ func newRelocationResult(arg *files.RelocationArg, res *files.RelocationResult) }, nil } -func relocationOperationResults(status string, results []relocationResult) []jsonOperationResult { - operationResults := make([]jsonOperationResult, 0, len(results)) - for _, result := range results { - operationResults = append(operationResults, newJSONOperationResult(status, result.Result.Type, result.Input, result.Result)) - } - return operationResults +func relocationOperationResult(status string, result relocationResult) jsonOperationResult { + return newJSONOperationResult(status, result.Result.Type, result.Input, result.Result) } diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index ffe2f1c..9544f0c 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -344,7 +344,8 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ - "copied" + "copied", + "skipped" ], "kinds": [ "deleted", @@ -453,7 +454,8 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ - "moved" + "moved", + "skipped" ], "kinds": [ "deleted", diff --git a/docs/commands/dbxcli_cp.md b/docs/commands/dbxcli_cp.md index 9267a49..0561034 100644 --- a/docs/commands/dbxcli_cp.md +++ b/docs/commands/dbxcli_cp.md @@ -11,7 +11,8 @@ dbxcli cp [flags] [more sources] ### Options ``` - -h, --help help for cp + -h, --help help for cp + --if-exists string What to do when the destination exists: fail or skip (default "fail") ``` ### Options inherited from parent commands diff --git a/docs/commands/dbxcli_mv.md b/docs/commands/dbxcli_mv.md index 0213bb2..f3154ac 100644 --- a/docs/commands/dbxcli_mv.md +++ b/docs/commands/dbxcli_mv.md @@ -5,13 +5,14 @@ Move files ``` -dbxcli mv [flags] +dbxcli mv [flags] [more sources] ``` ### Options ``` - -h, --help help for mv + -h, --help help for mv + --if-exists string What to do when the destination exists: fail or skip (default "fail") ``` ### Options inherited from parent commands diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index ffe2f1c..9544f0c 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -344,7 +344,8 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ - "copied" + "copied", + "skipped" ], "kinds": [ "deleted", @@ -453,7 +454,8 @@ "result_input": "relocation_input", "result": "metadata", "statuses": [ - "moved" + "moved", + "skipped" ], "kinds": [ "deleted",