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
22 changes: 22 additions & 0 deletions cmd/cp.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 cp(cmd *cobra.Command, args []string) error {
var cpErrors []error
var cpErrorDetails []map[string]any
var relocationArgs []*files.RelocationArg
var plannedResults []relocationResult
var results []jsonOperationResult
collectResults := commandOutputFormat(cmd) == output.FormatJSON

Expand All @@ -60,6 +62,19 @@ func cp(cmd *cobra.Command, args []string) error {
cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(argument, dst))
} else {
arg.Autorename = opts.ifExists == relocationIfExistsAutorename
if opts.dryRun {
result, err := plannedRelocationResult(dbx, arg)
if err != nil {
cpErrors = append(cpErrors, fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err))
cpErrorDetails = append(cpErrorDetails, relocationFailureDetails(arg.FromPath, arg.ToPath))
continue
}
plannedResults = append(plannedResults, result)
if collectResults {
results = append(results, relocationOperationResult(relocationJSONStatusCopied, result))
}
continue
}
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))
Expand Down Expand Up @@ -109,6 +124,12 @@ func cp(cmd *cobra.Command, args []string) error {
return relocationAggregateError("cp", "copy", len(cpErrors), cpErrorDetails)
}

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

if !collectResults {
return nil
}
Expand All @@ -126,5 +147,6 @@ var cpCmd = &cobra.Command{
func init() {
RootCmd.AddCommand(cpCmd)
enableStructuredOutput(cpCmd)
addDryRunFlag(cpCmd)
cpCmd.Flags().String("if-exists", relocationIfExistsFail, "What to do when the destination exists: fail, skip, or autorename")
}
160 changes: 160 additions & 0 deletions cmd/cp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,163 @@ func TestCpJSONOutputsRelocationResults(t *testing.T) {
}
}

func TestCpDryRunTextOutputSnapshot(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/dest/file-copy.txt":
return nil, relocationTestGetMetadataNotFoundError()
case "/src/file.txt":
return relocationTestFileMetadata("/src/file.txt", 42), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("CopyV2 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 := cp(cmd, []string{"/src/file.txt", "/dest/file-copy.txt"}); err != nil {
t.Fatalf("cp error: %v", err)
}

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

func TestCpJSONDryRunOutputsPlannedResult(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
switch arg.Path {
case "/dest/file-copy.txt":
return nil, relocationTestGetMetadataNotFoundError()
case "/src/file.txt":
return relocationTestFileMetadata("/src/file.txt", 42), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("CopyV2 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 := cp(cmd, []string{"/src/file.txt", "/dest/file-copy.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))
}
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-copy.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-copy.txt" || result.Result.PathLower != "/dest/file-copy.txt" {
t.Fatalf("result = %#v, want planned destination file metadata", result.Result)
}
if result.Result.Size == nil || *result.Result.Size != 42 {
t.Fatalf("size = %#v, want 42", result.Result.Size)
}
}

func TestCpDryRunMultipleSourcesOutputsPlansWithoutCopying(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
}
},
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("CopyV2 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 := cp(cmd, []string{"/src/a.txt", "/src/b.txt", "/dest"}); err != nil {
t.Fatalf("cp error: %v", err)
}

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

func TestCpDryRunSingleSourceExistingDestinationFolder(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", 42), nil
default:
t.Fatalf("unexpected GetMetadata path during dry-run: %q", arg.Path)
return nil, nil
}
},
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
t.Fatalf("CopyV2 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 := cp(cmd, []string{"/src/file.txt", "/dest"}); 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].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 TestCpJSONMultipleSourcesOutputsMultipleResults(t *testing.T) {
stubFilesClient(t, &mockFilesClient{
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
Expand Down Expand Up @@ -302,6 +459,9 @@ func TestCpCommandDefinesIfExistsFlag(t *testing.T) {
if flag.DefValue != relocationIfExistsFail {
t.Fatalf("--if-exists default = %q, want %q", flag.DefValue, relocationIfExistsFail)
}
if cpCmd.Flags().Lookup(dryRunFlagName) == nil {
t.Fatalf("cp should define --%s", dryRunFlagName)
}
}

func TestCpInvalidIfExistsReturnsInvalidArguments(t *testing.T) {
Expand Down
9 changes: 9 additions & 0 deletions cmd/dry_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ func writeDryRunRelocationLine(w io.Writer, verb, fromPath, toPath string) error
return err
}

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

func dryRunDisplayPath(metadata jsonMetadata, fallback string) string {
if metadata.PathDisplay != "" {
return metadata.PathDisplay
Expand Down
4 changes: 2 additions & 2 deletions cmd/help_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{
commandArg("target", true, false, "dropbox_path", "Dropbox destination path"),
},
Examples: []jsonCommandExample{{Description: "Copy a Dropbox file", Command: "dbxcli cp /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 @@ -342,7 +342,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{

var commandContractRegistry = map[string]jsonCommandContractMetadata{
"account": {Statuses: []string{"found"}, Kinds: []string{"account"}},
"cp": {Statuses: []string{"autorenamed", "copied", "skipped"}, Kinds: []string{"deleted", "file", "folder"}},
"cp": {Statuses: []string{"autorenamed", "copied", "skipped", jsonStatusPlanned}, Kinds: []string{"deleted", "file", "folder"}},
"du": {Statuses: []string{"reported"}, Kinds: []string{"space_usage"}},
"get": {Statuses: []string{"created", "downloaded", "existing"}, Kinds: []string{"file", "folder"}},
"help": {Statuses: []string{"described"}, Kinds: []string{"command"}},
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 @@ -1086,7 +1086,7 @@ 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{relocationJSONStatusAutorenamed, relocationJSONStatusCopied, relocationJSONStatusSkipped}, metadataKinds(), nil),
"cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusAutorenamed, relocationJSONStatusCopied, relocationJSONStatusSkipped, jsonStatusPlanned}, 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),
Expand Down
21 changes: 2 additions & 19 deletions cmd/mv.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func mv(cmd *cobra.Command, args []string) error {
} else {
arg.Autorename = opts.ifExists == relocationIfExistsAutorename
if opts.dryRun {
result, err := plannedMoveResult(dbx, arg)
result, err := plannedRelocationResult(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))
Expand Down Expand Up @@ -125,7 +125,7 @@ func mv(cmd *cobra.Command, args []string) error {

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

Expand All @@ -135,23 +135,6 @@ func mv(cmd *cobra.Command, args []string) error {
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 Down
2 changes: 1 addition & 1 deletion cmd/relocation_if_exists.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func parseRelocationOptions(cmd *cobra.Command) (relocationOptions, error) {
return relocationOptions{}, err
}
dryRun := false
// cp shares relocation parsing but does not register --dry-run yet.
// Some tests and callers use relocation parsing without registering --dry-run.
if cmd != nil && cmd.Flags().Lookup(dryRunFlagName) != nil {
dryRun, err = dryRunEnabled(cmd)
if err != nil {
Expand Down
8 changes: 8 additions & 0 deletions cmd/relocation_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ func newPlannedRelocationResult(arg *files.RelocationArg, metadata files.IsMetad
}, nil
}

func plannedRelocationResult(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 relocationOperationResult(status string, result relocationResult) jsonOperationResult {
return newJSONOperationResult(plannedStatus(result.Input.DryRun, status), result.Result.Type, result.Input, result.Result)
}
Expand Down
1 change: 1 addition & 0 deletions cmd/testdata/json_contract/success_schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@
"statuses": [
"autorenamed",
"copied",
"planned",
"skipped"
],
"kinds": [
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/dbxcli_cp.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dbxcli cp [flags] <source> [more sources] <target>
### Options

```
--dry-run Preview intended writes without making changes
-h, --help help for cp
--if-exists string What to do when the destination exists: fail, skip, or autorename (default "fail")
```
Expand All @@ -33,7 +34,7 @@ dbxcli cp [flags] <source> [more sources] <target>
* Dropbox scopes: `files.content.write`, `files.metadata.read`
* Arguments: `source` (required, dropbox_path, variadic), `target` (required, dropbox_path)
* Flag metadata: `--if-exists` (values: `autorename`, `fail`, `skip`), `--output` (values: `json`, `text`)
* Result statuses: `autorenamed`, `copied`, `skipped`
* Result statuses: `autorenamed`, `copied`, `planned`, `skipped`
* Result kinds: `deleted`, `file`, `folder`
* JSON contract: `docs/json-schema/v1/commands.json#/commands/cp`
* JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_cp`
Expand Down
1 change: 1 addition & 0 deletions docs/json-schema/v1/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@
"statuses": [
"autorenamed",
"copied",
"planned",
"skipped"
],
"kinds": [
Expand Down
1 change: 1 addition & 0 deletions docs/json-schema/v1/commands.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1972,6 +1972,7 @@
"enum": [
"autorenamed",
"copied",
"planned",
"skipped"
]
}
Expand Down
Loading