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
53 changes: 53 additions & 0 deletions cmd/dry_run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright © 2026 Dropbox, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"fmt"
"io"

"github.com/spf13/cobra"
)

const (
dryRunFlagName = "dry-run"
jsonStatusPlanned = "planned"
)

// Dry-run support is limited here to shared flags and output conventions.
// Command implementations should keep their validate/plan/execute flow local
// until at least two commands need the same execution driver.
func addDryRunFlag(cmd *cobra.Command) {
cmd.Flags().Bool(dryRunFlagName, false, "Show what would be done without making changes")
}

func dryRunEnabled(cmd *cobra.Command) (bool, error) {
return cmd.Flags().GetBool(dryRunFlagName)
}

// Dry-run JSON results use status "planned" and include dry_run=true in the
// result input. They do not preserve the real mutation status, because no
// mutation happened.
func plannedStatus(dryRun bool, realStatus string) string {
if dryRun {
return jsonStatusPlanned
}
return realStatus
}

func writeDryRunLine(w io.Writer, verb, path string) error {
_, err := fmt.Fprintf(w, "Would %s %s\n", verb, path)
return err
}
81 changes: 81 additions & 0 deletions cmd/dry_run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright © 2026 Dropbox, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"bytes"
"testing"

"github.com/spf13/cobra"
)

func TestAddDryRunFlag(t *testing.T) {
cmd := &cobra.Command{Use: "test"}

addDryRunFlag(cmd)

flag := cmd.Flags().Lookup(dryRunFlagName)
if flag == nil {
t.Fatalf("%q flag not registered", dryRunFlagName)
}
if flag.Shorthand != "" {
t.Fatalf("dry-run shorthand = %q, want none", flag.Shorthand)
}
enabled, err := dryRunEnabled(cmd)
if err != nil {
t.Fatalf("dryRunEnabled error: %v", err)
}
if enabled {
t.Fatal("dry-run default = true, want false")
}

if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil {
t.Fatal(err)
}
enabled, err = dryRunEnabled(cmd)
if err != nil {
t.Fatalf("dryRunEnabled error after set: %v", err)
}
if !enabled {
t.Fatal("dry-run = false after setting flag")
}
}

func TestPlannedStatus(t *testing.T) {
if got, want := plannedStatus(false, "deleted"), "deleted"; got != want {
t.Fatalf("plannedStatus(false) = %q, want %q", got, want)
}
if got, want := plannedStatus(true, "deleted"), jsonStatusPlanned; got != want {
t.Fatalf("plannedStatus(true) = %q, want %q", got, want)
}
}

func TestWriteDryRunLine(t *testing.T) {
var stdout bytes.Buffer
if err := writeDryRunLine(&stdout, "delete", "/File.txt"); err != nil {
t.Fatalf("writeDryRunLine error: %v", err)
}
if got, want := stdout.String(), "Would delete /File.txt\n"; got != want {
t.Fatalf("writeDryRunLine output = %q, want %q", got, want)
}

stdout.Reset()
if err := writeDryRunLine(&stdout, "permanently delete", "/File.txt"); err != nil {
t.Fatalf("writeDryRunLine permanent error: %v", err)
}
if got, want := stdout.String(), "Would permanently delete /File.txt\n"; got != want {
t.Fatalf("writeDryRunLine permanent output = %q, want %q", got, want)
}
}
9 changes: 5 additions & 4 deletions cmd/help_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,10 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{
Args: []jsonCommandArg{commandArg("file", true, true, "dropbox_path", "Dropbox path to remove")},
Examples: []jsonCommandExample{{Description: "Remove a Dropbox path", Command: "dbxcli rm /old.txt"}},
Flags: map[string]jsonCommandFlagMetadata{
"force": {ValueKind: "boolean"},
"permanent": {ValueKind: "boolean"},
"recursive": {ValueKind: "boolean"},
dryRunFlagName: {ValueKind: "boolean"},
"force": {ValueKind: "boolean"},
"permanent": {ValueKind: "boolean"},
"recursive": {ValueKind: "boolean"},
},
DropboxScopes: []string{"files.content.write", "files.metadata.read"},
Known: true,
Expand Down Expand Up @@ -352,7 +353,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{
"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"}},
"rm": {Statuses: []string{"deleted", "permanently_deleted"}, Kinds: []string{"deleted", "file", "folder"}},
"rm": {Statuses: []string{"deleted", "permanently_deleted", jsonStatusPlanned}, Kinds: []string{"deleted", "file", "folder"}},
"search": {Statuses: []string{"found"}, Kinds: []string{"deleted", "file", "folder"}},
"share list folder": {Statuses: []string{"listed"}, Kinds: []string{"shared_folder"}},
"share list link": {Statuses: []string{"listed"}, Kinds: []string{"file", "folder", "link"}, Warnings: []string{jsonWarningCodeDeprecatedCommand}},
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 @@ -1097,7 +1097,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema {
"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),
"rm": operationSchema("empty", schemaRef("remove_input"), "metadata", []string{removeJSONStatusDeleted, removeJSONStatusPermanentlyDeleted}, metadataKinds(), 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),
"share list folder": operationSchema("empty", schemaRef("empty"), "share_folder", []string{shareFolderJSONStatusListed}, []string{shareFolderJSONKindFolder}, nil),
"share list link": operationSchema("share_link_list_input", schemaRef("empty"), "share_link_metadata", []string{shareLinkJSONStatusListed}, shareLinkKinds(), []string{jsonWarningCodeDeprecatedCommand}),
Expand Down
52 changes: 38 additions & 14 deletions cmd/rm.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type removeOptions struct {
force bool
recursive bool
permanent bool
dryRun bool
verbose bool
}

Expand All @@ -40,6 +41,7 @@ type removeInput struct {
Permanent bool `json:"permanent"`
Recursive bool `json:"recursive"`
Force bool `json:"force"`
DryRun bool `json:"dry_run,omitempty"`
}

type removeResult struct {
Expand Down Expand Up @@ -75,7 +77,7 @@ func rm(cmd *cobra.Command, args []string) error {
}

return commandOutput(cmd).Render(func(w io.Writer) error {
if !opts.verbose {
if !opts.dryRun && !opts.verbose {
return nil
}
return renderRemoveResults(w, results)
Expand All @@ -92,9 +94,9 @@ func removeOperationResults(results []removeResult) []jsonOperationResult {

func removeJSONStatus(result removeResult) string {
if result.Input.Permanent {
return removeJSONStatusPermanentlyDeleted
return plannedStatus(result.Input.DryRun, removeJSONStatusPermanentlyDeleted)
}
return removeJSONStatusDeleted
return plannedStatus(result.Input.DryRun, removeJSONStatusDeleted)
}

func parseRemoveOptions(cmd *cobra.Command) (removeOptions, error) {
Expand All @@ -113,12 +115,18 @@ func parseRemoveOptions(cmd *cobra.Command) (removeOptions, error) {
return removeOptions{}, err
}

dryRun, err := dryRunEnabled(cmd)
if err != nil {
return removeOptions{}, err
}

verbose, _ := cmd.Flags().GetBool("verbose")

return removeOptions{
force: force,
recursive: recursive,
permanent: permanent,
dryRun: dryRun,
verbose: verbose,
}, nil
}
Expand Down Expand Up @@ -161,17 +169,19 @@ func removeTargets(dbx filesClient, targets []removeTarget, opts removeOptions)
arg := files.NewDeleteArg(target.path)
metadata := target.metadata

if opts.permanent {
if err := dbx.PermanentlyDeleteContext(currentContext(), arg); err != nil {
return nil, withJSONErrorDetails(err, operationErrorDetails(removeOperation(opts)), pathErrorDetails(target.path))
}
} else {
res, err := dbx.DeleteV2Context(currentContext(), arg)
if err != nil {
return nil, withJSONErrorDetails(err, operationErrorDetails(removeOperation(opts)), pathErrorDetails(target.path))
}
if res != nil && res.Metadata != nil {
metadata = res.Metadata
if !opts.dryRun {
if opts.permanent {
if err := dbx.PermanentlyDeleteContext(currentContext(), arg); err != nil {
return nil, withJSONErrorDetails(err, operationErrorDetails(removeOperation(opts)), pathErrorDetails(target.path))
}
} else {
res, err := dbx.DeleteV2Context(currentContext(), arg)
if err != nil {
return nil, withJSONErrorDetails(err, operationErrorDetails(removeOperation(opts)), pathErrorDetails(target.path))
}
if res != nil && res.Metadata != nil {
metadata = res.Metadata
}
}
}

Expand All @@ -196,6 +206,7 @@ func newRemoveResult(path string, metadata files.IsMetadata, opts removeOptions)
Permanent: opts.permanent,
Recursive: opts.recursive,
Force: opts.force,
DryRun: opts.dryRun,
},
Result: result,
}, nil
Expand All @@ -212,6 +223,18 @@ func removeMetadataFromDropbox(path string, metadata files.IsMetadata) (jsonMeta

func renderRemoveResults(w io.Writer, results []removeResult) error {
for _, result := range results {
if result.Input.DryRun {
if result.Input.Permanent {
if err := writeDryRunLine(w, "permanently delete", result.displayPath()); err != nil {
return err
}
continue
}
if err := writeDryRunLine(w, "delete", result.displayPath()); err != nil {
return err
}
continue
}
if result.Input.Permanent {
if _, err := fmt.Fprintf(w, "Permanently deleted %s\n", result.displayPath()); err != nil {
return err
Expand Down Expand Up @@ -257,4 +280,5 @@ func init() {
rmCmd.Flags().BoolP("force", "f", false, "Allow removing non-empty folders; same as --recursive")
rmCmd.Flags().BoolP("recursive", "r", false, "Recursively remove folders")
rmCmd.Flags().Bool("permanent", false, "Permanently delete instead of moving to Dropbox trash")
addDryRunFlag(rmCmd)
}
Loading
Loading