From 786c1e67b87cc17af4e6cf6b483094ba7647843c Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Wed, 8 Jul 2026 19:58:01 -0700 Subject: [PATCH] Add --dry-run flag to restore Preview the intended restore without calling the Dropbox API: text output prints "Would restore to revision " and JSON reports status "planned" with dry_run=true in the result input. Extract plannedMetadata() into dry_run.go to synthesize a jsonMetadata from a path/kind, and route mkdir's planned result through it. --- cmd/dry_run.go | 9 +++ cmd/dry_run_test.go | 10 +++ cmd/help_manifest.go | 3 +- cmd/json_contract_test.go | 2 +- cmd/mkdir.go | 21 +++--- cmd/restore.go | 52 +++++++++++++- cmd/restore_test.go | 70 ++++++++++++++++++- .../json_contract/success_schemas.json | 2 + docs/commands/dbxcli_restore.md | 5 +- docs/json-schema/v1/commands.json | 2 + docs/json-schema/v1/commands.schema.json | 4 ++ 11 files changed, 162 insertions(+), 18 deletions(-) diff --git a/cmd/dry_run.go b/cmd/dry_run.go index ef15a27c..f1d4f3dc 100644 --- a/cmd/dry_run.go +++ b/cmd/dry_run.go @@ -17,6 +17,7 @@ package cmd import ( "fmt" "io" + "strings" "github.com/spf13/cobra" ) @@ -66,6 +67,14 @@ func renderPlannedRelocationResults(w io.Writer, verb string, results []relocati return nil } +func plannedMetadata(kind, path string) jsonMetadata { + return jsonMetadata{ + Type: kind, + PathDisplay: path, + PathLower: strings.ToLower(path), + } +} + 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 6997c460..eb8a7d13 100644 --- a/cmd/dry_run_test.go +++ b/cmd/dry_run_test.go @@ -98,3 +98,13 @@ func TestDryRunDisplayPath(t *testing.T) { t.Fatalf("dryRunDisplayPath fallback = %q, want %q", got, want) } } + +func TestPlannedMetadata(t *testing.T) { + got := plannedMetadata("file", "/Reports/Old.PDF") + if got.Type != "file" || got.PathDisplay != "/Reports/Old.PDF" || got.PathLower != "/reports/old.pdf" { + t.Fatalf("plannedMetadata = %#v, want file metadata with display and lower paths", got) + } + if got.Size != nil || got.ServerModified != nil || got.ClientModified != nil { + t.Fatalf("plannedMetadata = %#v, want unknown metadata omitted", got) + } +} diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 42189e38..65555468 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -204,6 +204,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ commandArg("revision", true, false, "revision", "Dropbox file revision to restore"), }, Examples: []jsonCommandExample{{Description: "Restore a file revision", Command: "dbxcli restore /Reports/old.pdf 015f"}}, + Flags: map[string]jsonCommandFlagMetadata{dryRunFlagName: {ValueKind: "boolean"}}, DropboxScopes: []string{"files.content.write", "files.metadata.read"}, Known: true, }, @@ -351,7 +352,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "mkdir": {Statuses: []string{"created", "existing", jsonStatusPlanned}, Kinds: []string{"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"}}, + "restore": {Statuses: []string{"restored", jsonStatusPlanned}, Kinds: []string{"file"}}, "revs": {Statuses: []string{"revision"}, Kinds: []string{"file"}}, "rm": {Statuses: []string{"deleted", "permanently_deleted", jsonStatusPlanned}, Kinds: []string{"deleted", "file", "folder"}}, "search": {Statuses: []string{"found"}, Kinds: []string{"deleted", "file", "folder"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 5bc313fc..71606201 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -1095,7 +1095,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "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, 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), + "restore": operationSchema("restore_input", schemaRef("restore_input"), "metadata", []string{restoreStatusRestored, jsonStatusPlanned}, []string{restoreKindFile}, nil), "revs": operationSchema("revs_input", schemaRef("empty"), "metadata", []string{revsJSONStatusRevision}, []string{"file"}, nil), "rm": operationSchema("empty", schemaRef("remove_input"), "metadata", []string{removeJSONStatusDeleted, removeJSONStatusPermanentlyDeleted, jsonStatusPlanned}, metadataKinds(), nil), "search": operationSchema("search_input", schemaRef("empty"), "metadata", []string{searchJSONStatusFound}, metadataKinds(), nil), diff --git a/cmd/mkdir.go b/cmd/mkdir.go index 95f3d1db..e7e859c9 100644 --- a/cmd/mkdir.go +++ b/cmd/mkdir.go @@ -64,10 +64,7 @@ func mkdir(cmd *cobra.Command, args []string) (err error) { } if opts.dryRun { - result, err := newPlannedMkdirResult(dst, opts) - if err != nil { - return err - } + result := newPlannedMkdirResult(dst, opts) return commandOutput(cmd).Render(func(w io.Writer) error { return writeDryRunLine(w, "create directory", result.displayPath()) }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{mkdirOperationResult(result)}, nil)) @@ -172,13 +169,17 @@ func newMkdirResult(status, path string, opts mkdirOptions, metadata *files.Fold }, nil } -func newPlannedMkdirResult(path string, opts mkdirOptions) (mkdirResult, error) { - return newMkdirResult(mkdirStatusCreated, path, opts, &files.FolderMetadata{ - Metadata: files.Metadata{ - PathDisplay: path, - PathLower: strings.ToLower(path), +func newPlannedMkdirResult(path string, opts mkdirOptions) mkdirResult { + return mkdirResult{ + Status: mkdirStatusCreated, + Kind: mkdirKindFolder, + Input: mkdirInput{ + Path: path, + Parents: opts.parents, + DryRun: opts.dryRun, }, - }) + Result: plannedMetadata(mkdirKindFolder, path), + } } func mkdirOperationResult(result mkdirResult) jsonOperationResult { diff --git a/cmd/restore.go b/cmd/restore.go index 09a34127..389b806b 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -26,6 +26,7 @@ import ( type restoreInput struct { Path string `json:"path"` Revision string `json:"revision"` + DryRun bool `json:"dry_run,omitempty"` } const ( @@ -40,6 +41,10 @@ type restoreResult struct { Result jsonMetadata `json:"result"` } +type restoreOptions struct { + dryRun bool +} + func restore(cmd *cobra.Command, args []string) (err error) { if len(args) != 2 { return invalidArgumentsErrorWithDetails("`restore` requires `target-path` and `revision` arguments", argumentsErrorDetails("target-path", "revision")) @@ -52,6 +57,17 @@ func restore(cmd *cobra.Command, args []string) (err error) { rev := args[1] + opts, err := parseRestoreOptions(cmd) + if err != nil { + return err + } + if opts.dryRun { + result := newPlannedRestoreResult(path, rev, opts) + return commandOutput(cmd).Render(func(w io.Writer) error { + return renderPlannedRestoreResult(w, result) + }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil)) + } + arg := files.NewRestoreArg(path, rev) dbx := filesNewFunc(config) @@ -61,7 +77,7 @@ func restore(cmd *cobra.Command, args []string) (err error) { } verbose, _ := cmd.Flags().GetBool("verbose") - result, err := newRestoreResult(path, rev, metadata) + result, err := newRestoreResult(path, rev, opts, metadata) if err != nil { return withJSONErrorDetails(err, restoreErrorDetails(path, rev)) } @@ -74,11 +90,19 @@ func restore(cmd *cobra.Command, args []string) (err error) { }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{restoreOperationResult(result)}, nil)) } +func parseRestoreOptions(cmd *cobra.Command) (restoreOptions, error) { + dryRun, err := dryRunEnabled(cmd) + if err != nil { + return restoreOptions{}, err + } + return restoreOptions{dryRun: dryRun}, nil +} + func restoreErrorDetails(path, revision string) map[string]any { return mergeJSONErrorDetails(operationErrorDetails("restore"), pathErrorDetails(path), revisionErrorDetails(revision)) } -func newRestoreResult(path, revision string, metadata *files.FileMetadata) (restoreResult, error) { +func newRestoreResult(path, revision string, opts restoreOptions, metadata *files.FileMetadata) (restoreResult, error) { result, err := restoreMetadataFromDropbox(path, metadata) if err != nil { return restoreResult{}, err @@ -89,13 +113,29 @@ func newRestoreResult(path, revision string, metadata *files.FileMetadata) (rest Input: restoreInput{ Path: path, Revision: revision, + DryRun: opts.dryRun, }, Result: result, }, nil } +func newPlannedRestoreResult(path, revision string, opts restoreOptions) restoreResult { + result := plannedMetadata(restoreKindFile, path) + result.Rev = revision + return restoreResult{ + Status: restoreStatusRestored, + Kind: restoreKindFile, + Input: restoreInput{ + Path: path, + Revision: revision, + DryRun: opts.dryRun, + }, + Result: result, + } +} + func restoreOperationResult(result restoreResult) jsonOperationResult { - return newJSONOperationResult(result.Status, result.Kind, result.Input, result.Result) + return newJSONOperationResult(plannedStatus(result.Input.DryRun, result.Status), result.Kind, result.Input, result.Result) } func restoreMetadataFromDropbox(path string, metadata *files.FileMetadata) (jsonMetadata, error) { @@ -131,6 +171,11 @@ func renderRestoreResult(w io.Writer, result restoreResult) error { return err } +func renderPlannedRestoreResult(w io.Writer, result restoreResult) error { + path := dryRunDisplayPath(result.Result, result.Input.Path) + return writeDryRunLine(w, "restore", fmt.Sprintf("%s to revision %s", path, result.Input.Revision)) +} + func restoreResultServerModified(result restoreResult) string { if result.Result.ServerModified != nil { return *result.Result.ServerModified @@ -154,4 +199,5 @@ Use "dbxcli revs " to list available revisions.`, func init() { RootCmd.AddCommand(restoreCmd) enableStructuredOutput(restoreCmd) + addDryRunFlag(restoreCmd) } diff --git a/cmd/restore_test.go b/cmd/restore_test.go index cc610b1e..911c0746 100644 --- a/cmd/restore_test.go +++ b/cmd/restore_test.go @@ -101,7 +101,7 @@ func TestRestoreVerbosePrintsRevisionAndServerModifiedTime(t *testing.T) { func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) { clientModified := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) serverModified := time.Date(2026, 6, 17, 12, 30, 0, 0, time.UTC) - result, err := newRestoreResult("/Reports/old.pdf", "target-rev", &files.FileMetadata{ + result, err := newRestoreResult("/Reports/old.pdf", "target-rev", restoreOptions{}, &files.FileMetadata{ Metadata: files.Metadata{ PathDisplay: "/Reports/old.pdf", PathLower: "/reports/old.pdf", @@ -137,6 +137,67 @@ func TestNewRestoreResultKeepsInputAndMetadata(t *testing.T) { } } +func TestRestoreDryRunTextOutputSnapshot(t *testing.T) { + cmd, stdout := testRestoreCmd() + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + stubFilesClient(t, &mockFilesClient{ + restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) { + t.Fatalf("Restore called during dry-run: %v", arg) + return nil, nil + }, + }) + + if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil { + t.Fatalf("restore error: %v", err) + } + + const want = "Would restore /Reports/old.pdf to revision target-rev\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestRestoreJSONDryRunOutputsPlannedResult(t *testing.T) { + cmd, stdout := testRestoreCmd() + setRestoreOutputJSON(t, cmd) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + stubFilesClient(t, &mockFilesClient{ + restoreFn: func(arg *files.RestoreArg) (*files.FileMetadata, error) { + t.Fatalf("Restore called during dry-run: %v", arg) + return nil, nil + }, + }) + + if err := restore(cmd, []string{"/Reports/old.pdf", "target-rev"}); err != nil { + t.Fatalf("restore error: %v", err) + } + + got := decodeRestoreOutput(t, stdout) + if got.Input.Path != "/Reports/old.pdf" || got.Input.Revision != "target-rev" || !got.Input.DryRun { + t.Fatalf("input = %#v, want path, revision, dry_run true", got.Input) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Kind != restoreKindFile { + t.Fatalf("status/kind = %s/%s, want planned/file", result.Status, result.Kind) + } + if result.Input.Path != "/Reports/old.pdf" || result.Input.Revision != "target-rev" || !result.Input.DryRun { + t.Fatalf("result input = %#v, want path, revision, dry_run true", result.Input) + } + if result.Result.Type != "file" || result.Result.PathDisplay != "/Reports/old.pdf" || result.Result.PathLower != "/reports/old.pdf" { + t.Fatalf("metadata = %#v, want planned target file metadata", result.Result) + } + if result.Result.Rev != "target-rev" { + t.Fatalf("rev = %q, want target-rev", result.Result.Rev) + } + if result.Result.Size != nil { + t.Fatalf("size = %v, want omitted planned size", *result.Result.Size) + } +} + func TestRestoreJSONOutputsInputAndMetadata(t *testing.T) { cmd, stdout := testRestoreCmd() setRestoreOutputJSON(t, cmd) @@ -275,12 +336,19 @@ func TestRestoreCommandSupportsStructuredOutput(t *testing.T) { } } +func TestRestoreCommandDefinesDryRunFlag(t *testing.T) { + if restoreCmd.Flags().Lookup(dryRunFlagName) == nil { + t.Fatalf("restore should define --%s", dryRunFlagName) + } +} + func testRestoreCmd() (*cobra.Command, *bytes.Buffer) { var stdout bytes.Buffer cmd := &cobra.Command{Use: "restore"} cmd.SetOut(&stdout) cmd.Flags().BoolP("verbose", "v", false, "") cmd.Flags().String(outputFlag, "text", "") + addDryRunFlag(cmd) return cmd, &stdout } diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 1963c477..eb253927 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -222,6 +222,7 @@ "recursive" ], "restore_input": [ + "dry_run", "path", "revision" ], @@ -572,6 +573,7 @@ "result_input": "restore_input", "result": "metadata", "statuses": [ + "planned", "restored" ], "kinds": [ diff --git a/docs/commands/dbxcli_restore.md b/docs/commands/dbxcli_restore.md index 2def1180..072a4477 100644 --- a/docs/commands/dbxcli_restore.md +++ b/docs/commands/dbxcli_restore.md @@ -25,7 +25,8 @@ dbxcli restore [flags] ### Options ``` - -h, --help help for restore + --dry-run Preview intended writes without making changes + -h, --help help for restore ``` ### Options inherited from parent commands @@ -46,7 +47,7 @@ dbxcli restore [flags] * Dropbox scopes: `files.content.write`, `files.metadata.read` * Arguments: `target-path` (required, dropbox_path), `revision` (required, revision) * Flag metadata: `--output` (values: `json`, `text`) -* Result statuses: `restored` +* Result statuses: `planned`, `restored` * Result kinds: `file` * JSON contract: `docs/json-schema/v1/commands.json#/commands/restore` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_restore` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 1963c477..eb253927 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -222,6 +222,7 @@ "recursive" ], "restore_input": [ + "dry_run", "path", "revision" ], @@ -572,6 +573,7 @@ "result_input": "restore_input", "result": "metadata", "statuses": [ + "planned", "restored" ], "kinds": [ diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index ac676be9..aebc8425 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1911,6 +1911,9 @@ "restore_input": { "additionalProperties": false, "properties": { + "dry_run": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -2243,6 +2246,7 @@ }, "status": { "enum": [ + "planned", "restored" ] }