From aca765671b8ecd77f2aaeb1b32ed4c341790346a Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 8 Jul 2026 13:49:33 -0700 Subject: [PATCH] Add --dry-run flag to mv Extend dry-run support to mv, reusing the shared helpers from dry_run.go. With --dry-run, mv previews each intended move without calling MoveV2: it resolves the destination (including into an existing folder), reports a "planned" JSON status, and prints "Would move to " in text mode. Adds a writeDryRunRelocationLine helper for the from/to text form and a newPlannedRelocationResult builder. cp shares the relocation parsing but does not register --dry-run, so parseRelocationOptions guards the flag lookup and cp is unaffected. Updates the help manifest, JSON contract, input/definition schemas, and docs to include the dry_run input and planned status for mv. --- cmd/cp_test.go | 15 ++ cmd/dry_run.go | 5 + cmd/dry_run_test.go | 10 ++ cmd/help_manifest.go | 4 +- cmd/json_contract_test.go | 2 +- cmd/mv.go | 39 +++++ cmd/mv_test.go | 157 ++++++++++++++++++ cmd/relocation_if_exists.go | 11 +- cmd/relocation_output.go | 27 ++- .../json_contract/success_schemas.json | 2 + docs/commands/dbxcli_mv.md | 3 +- docs/json-schema/v1/commands.json | 2 + docs/json-schema/v1/commands.schema.json | 4 + 13 files changed, 274 insertions(+), 7 deletions(-) diff --git a/cmd/cp_test.go b/cmd/cp_test.go index 919dbb9..050faa6 100644 --- a/cmd/cp_test.go +++ b/cmd/cp_test.go @@ -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) } @@ -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"` diff --git a/cmd/dry_run.go b/cmd/dry_run.go index ade2255..1a03d50 100644 --- a/cmd/dry_run.go +++ b/cmd/dry_run.go @@ -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 diff --git a/cmd/dry_run_test.go b/cmd/dry_run_test.go index 16d68fb..6997c46 100644 --- a/cmd/dry_run_test.go +++ b/cmd/dry_run_test.go @@ -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) diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index ccb3688..3af0eef 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -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, }, @@ -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"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index bccbdbf..755ab84 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -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), diff --git a/cmd/mv.go b/cmd/mv.go index e7baaff..8de8864 100644 --- a/cmd/mv.go +++ b/cmd/mv.go @@ -16,6 +16,7 @@ package cmd import ( "fmt" + "io" "strings" "github.com/dropbox/dbxcli/v3/internal/output" @@ -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 @@ -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)) @@ -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] [more sources] ", @@ -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") } diff --git a/cmd/mv_test.go b/cmd/mv_test.go index 422ed2c..c7c1f2f 100644 --- a/cmd/mv_test.go +++ b/cmd/mv_test.go @@ -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) { diff --git a/cmd/relocation_if_exists.go b/cmd/relocation_if_exists.go index 07983cd..ac9ff70 100644 --- a/cmd/relocation_if_exists.go +++ b/cmd/relocation_if_exists.go @@ -15,6 +15,7 @@ const ( type relocationOptions struct { ifExists string + dryRun bool } func parseRelocationOptions(cmd *cobra.Command) (relocationOptions, error) { @@ -22,7 +23,15 @@ func parseRelocationOptions(cmd *cobra.Command) (relocationOptions, error) { 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) { diff --git a/cmd/relocation_output.go b/cmd/relocation_output.go index b97ddf9..babd90f 100644 --- a/cmd/relocation_output.go +++ b/cmd/relocation_output.go @@ -1,6 +1,10 @@ package cmd -import "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +import ( + "strings" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" +) const ( relocationJSONStatusCopied = "copied" @@ -12,6 +16,7 @@ const ( type relocationInput struct { FromPath string `json:"from_path"` ToPath string `json:"to_path"` + DryRun bool `json:"dry_run,omitempty"` } type relocationResult struct { @@ -43,8 +48,26 @@ func newRelocationResultFromMetadata(arg *files.RelocationArg, metadata files.Is }, nil } +func newPlannedRelocationResult(arg *files.RelocationArg, metadata files.IsMetadata) (relocationResult, error) { + result, err := jsonMetadataFromDropbox(metadata) + if err != nil { + return relocationResult{}, err + } + result.PathDisplay = arg.ToPath + result.PathLower = strings.ToLower(arg.ToPath) + + return relocationResult{ + Input: relocationInput{ + FromPath: arg.FromPath, + ToPath: arg.ToPath, + DryRun: true, + }, + Result: result, + }, nil +} + func relocationOperationResult(status string, result relocationResult) jsonOperationResult { - return newJSONOperationResult(status, result.Result.Type, result.Input, result.Result) + return newJSONOperationResult(plannedStatus(result.Input.DryRun, status), result.Result.Type, result.Input, result.Result) } // relocationSuccessStatus returns the JSON status for a successful copy/move. diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index e4e3d68..f3f62f2 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -210,6 +210,7 @@ "target" ], "relocation_input": [ + "dry_run", "from_path", "to_path" ], @@ -532,6 +533,7 @@ "statuses": [ "autorenamed", "moved", + "planned", "skipped" ], "kinds": [ diff --git a/docs/commands/dbxcli_mv.md b/docs/commands/dbxcli_mv.md index 4afdb1c..d2a643b 100644 --- a/docs/commands/dbxcli_mv.md +++ b/docs/commands/dbxcli_mv.md @@ -11,6 +11,7 @@ dbxcli mv [flags] [more sources] ### Options ``` + --dry-run Preview intended writes without making changes -h, --help help for mv --if-exists string What to do when the destination exists: fail, skip, or autorename (default "fail") ``` @@ -33,7 +34,7 @@ dbxcli mv [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`, `moved`, `skipped` +* Result statuses: `autorenamed`, `moved`, `planned`, `skipped` * Result kinds: `deleted`, `file`, `folder` * JSON contract: `docs/json-schema/v1/commands.json#/commands/mv` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_mv` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index e4e3d68..f3f62f2 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -210,6 +210,7 @@ "target" ], "relocation_input": [ + "dry_run", "from_path", "to_path" ], @@ -532,6 +533,7 @@ "statuses": [ "autorenamed", "moved", + "planned", "skipped" ], "kinds": [ diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 25f6964..3980f8c 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1865,6 +1865,9 @@ "relocation_input": { "additionalProperties": false, "properties": { + "dry_run": { + "type": "boolean" + }, "from_path": { "type": "string" }, @@ -2177,6 +2180,7 @@ "enum": [ "autorenamed", "moved", + "planned", "skipped" ] }