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
9 changes: 9 additions & 0 deletions cmd/dry_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package cmd
import (
"fmt"
"io"
"strings"

"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cmd/dry_run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 2 additions & 1 deletion cmd/help_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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"}},
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 @@ -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),
Expand Down
21 changes: 11 additions & 10 deletions cmd/mkdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 49 additions & 3 deletions cmd/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
type restoreInput struct {
Path string `json:"path"`
Revision string `json:"revision"`
DryRun bool `json:"dry_run,omitempty"`
}

const (
Expand All @@ -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"))
Expand All @@ -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)
Expand All @@ -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))
}
Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -154,4 +199,5 @@ Use "dbxcli revs <target-path>" to list available revisions.`,
func init() {
RootCmd.AddCommand(restoreCmd)
enableStructuredOutput(restoreCmd)
addDryRunFlag(restoreCmd)
}
70 changes: 69 additions & 1 deletion cmd/restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/testdata/json_contract/success_schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"recursive"
],
"restore_input": [
"dry_run",
"path",
"revision"
],
Expand Down Expand Up @@ -572,6 +573,7 @@
"result_input": "restore_input",
"result": "metadata",
"statuses": [
"planned",
"restored"
],
"kinds": [
Expand Down
5 changes: 3 additions & 2 deletions docs/commands/dbxcli_restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ dbxcli restore [flags] <target-path> <revision>
### Options

```
-h, --help help for restore
--dry-run Preview intended writes without making changes
-h, --help help for restore
```

### Options inherited from parent commands
Expand All @@ -46,7 +47,7 @@ dbxcli restore [flags] <target-path> <revision>
* 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`
Expand Down
2 changes: 2 additions & 0 deletions docs/json-schema/v1/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"recursive"
],
"restore_input": [
"dry_run",
"path",
"revision"
],
Expand Down Expand Up @@ -572,6 +573,7 @@
"result_input": "restore_input",
"result": "metadata",
"statuses": [
"planned",
"restored"
],
"kinds": [
Expand Down
4 changes: 4 additions & 0 deletions docs/json-schema/v1/commands.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,9 @@
"restore_input": {
"additionalProperties": false,
"properties": {
"dry_run": {
"type": "boolean"
},
"path": {
"type": "string"
},
Expand Down Expand Up @@ -2243,6 +2246,7 @@
},
"status": {
"enum": [
"planned",
"restored"
]
}
Expand Down
Loading