From 3b3cced203d2d6c9c9b2a81ddb9f08fd10cafcb0 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 8 Jul 2026 14:18:43 -0700 Subject: [PATCH] Add --dry-run flag to cp Extend dry-run support to cp, reusing the shared helpers from dry_run.go. With --dry-run, cp previews each intended copy without calling CopyV2: it resolves the destination (including into an existing folder), reports a "planned" JSON status, and prints "Would copy to " in text mode. Fold the mv-specific dry-run helpers into shared relocation code so both commands use them: plannedRelocationResult (relocation_output.go) fetches source metadata and builds a planned result, and renderPlannedRelocationResults (dry_run.go) renders the from/to text with a verb parameter ("move"/"copy"). Updates the help manifest, JSON contract, definition schema, and docs to include the dry_run input and planned status for cp. --- cmd/cp.go | 22 +++ cmd/cp_test.go | 160 ++++++++++++++++++ cmd/dry_run.go | 9 + cmd/help_manifest.go | 4 +- cmd/json_contract_test.go | 2 +- cmd/mv.go | 21 +-- cmd/relocation_if_exists.go | 2 +- cmd/relocation_output.go | 8 + .../json_contract/success_schemas.json | 1 + docs/commands/dbxcli_cp.md | 3 +- docs/json-schema/v1/commands.json | 1 + docs/json-schema/v1/commands.schema.json | 1 + 12 files changed, 210 insertions(+), 24 deletions(-) diff --git a/cmd/cp.go b/cmd/cp.go index f6af710..7b107b2 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -16,6 +16,7 @@ package cmd import ( "fmt" + "io" "strings" "github.com/dropbox/dbxcli/v3/internal/output" @@ -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 @@ -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)) @@ -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 } @@ -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") } diff --git a/cmd/cp_test.go b/cmd/cp_test.go index 050faa6..3566741 100644 --- a/cmd/cp_test.go +++ b/cmd/cp_test.go @@ -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) { @@ -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) { diff --git a/cmd/dry_run.go b/cmd/dry_run.go index 1a03d50..ef15a27 100644 --- a/cmd/dry_run.go +++ b/cmd/dry_run.go @@ -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 diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 3af0eef..42189e3 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -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, }, @@ -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"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 755ab84..5bc313f 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -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), diff --git a/cmd/mv.go b/cmd/mv.go index 8de8864..23c2704 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -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)) @@ -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)) } @@ -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] [more sources] ", diff --git a/cmd/relocation_if_exists.go b/cmd/relocation_if_exists.go index ac9ff70..9830b7c 100644 --- a/cmd/relocation_if_exists.go +++ b/cmd/relocation_if_exists.go @@ -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 { diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index babd90f..0117f85 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -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) } diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index f3f62f2..1963c47 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -421,6 +421,7 @@ "statuses": [ "autorenamed", "copied", + "planned", "skipped" ], "kinds": [ diff --git a/docs/commands/dbxcli_cp.md b/docs/commands/dbxcli_cp.md index e31f6c9..dff7e49 100644 --- a/docs/commands/dbxcli_cp.md +++ b/docs/commands/dbxcli_cp.md @@ -11,6 +11,7 @@ dbxcli cp [flags] [more sources] ### 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") ``` @@ -33,7 +34,7 @@ dbxcli cp [flags] [more sources] * 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` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index f3f62f2..1963c47 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -421,6 +421,7 @@ "statuses": [ "autorenamed", "copied", + "planned", "skipped" ], "kinds": [ diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 3980f8c..ac676be 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1972,6 +1972,7 @@ "enum": [ "autorenamed", "copied", + "planned", "skipped" ] }