diff --git a/cmd/dry_run.go b/cmd/dry_run.go index 15fe28b..ade2255 100644 --- a/cmd/dry_run.go +++ b/cmd/dry_run.go @@ -30,7 +30,7 @@ const ( // 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") + cmd.Flags().Bool(dryRunFlagName, false, "Preview intended writes without making changes") } func dryRunEnabled(cmd *cobra.Command) (bool, error) { @@ -51,3 +51,10 @@ func writeDryRunLine(w io.Writer, verb, path string) error { _, err := fmt.Fprintf(w, "Would %s %s\n", verb, path) return err } + +func dryRunDisplayPath(metadata jsonMetadata, fallback string) string { + if metadata.PathDisplay != "" { + return metadata.PathDisplay + } + return fallback +} diff --git a/cmd/dry_run_test.go b/cmd/dry_run_test.go index f75d3a8..16d68fb 100644 --- a/cmd/dry_run_test.go +++ b/cmd/dry_run_test.go @@ -79,3 +79,12 @@ func TestWriteDryRunLine(t *testing.T) { t.Fatalf("writeDryRunLine permanent 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) + } + if got, want := dryRunDisplayPath(jsonMetadata{}, "/fallback.txt"), "/fallback.txt"; got != want { + t.Fatalf("dryRunDisplayPath fallback = %q, want %q", got, want) + } +} diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index fee2f7e..ccb3688 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -164,7 +164,7 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ "mkdir": { Args: []jsonCommandArg{commandArg("directory", true, false, "dropbox_path", "Dropbox directory path to create")}, Examples: []jsonCommandExample{{Description: "Create a Dropbox folder", Command: "dbxcli mkdir /Reports"}}, - Flags: map[string]jsonCommandFlagMetadata{"parents": {ValueKind: "boolean"}}, + Flags: map[string]jsonCommandFlagMetadata{dryRunFlagName: {ValueKind: "boolean"}, "parents": {ValueKind: "boolean"}}, DropboxScopes: []string{"files.content.write"}, Known: true, }, @@ -348,7 +348,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "help": {Statuses: []string{"described"}, Kinds: []string{"command"}}, "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"}, Kinds: []string{"folder"}}, + "mkdir": {Statuses: []string{"created", "existing", jsonStatusPlanned}, Kinds: []string{"folder"}}, "mv": {Statuses: []string{"autorenamed", "moved", "skipped"}, 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"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 95b3e06..bccbdbf 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -1092,7 +1092,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil), "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}, []string{mkdirKindFolder}, nil), + "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), "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), diff --git a/cmd/mkdir.go b/cmd/mkdir.go index 9077308..95f3d1d 100644 --- a/cmd/mkdir.go +++ b/cmd/mkdir.go @@ -16,6 +16,7 @@ package cmd import ( "errors" + "io" "strings" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" @@ -25,6 +26,12 @@ import ( type mkdirInput struct { Path string `json:"path"` Parents bool `json:"parents"` + DryRun bool `json:"dry_run,omitempty"` +} + +type mkdirOptions struct { + parents bool + dryRun bool } const ( @@ -51,16 +58,28 @@ func mkdir(cmd *cobra.Command, args []string) (err error) { return } - arg := files.NewCreateFolderArg(dst) + opts, err := parseMkdirOptions(cmd) + if err != nil { + return err + } - parents, _ := cmd.Flags().GetBool("parents") + if opts.dryRun { + result, err := newPlannedMkdirResult(dst, opts) + if err != nil { + return err + } + return commandOutput(cmd).Render(func(w io.Writer) error { + return writeDryRunLine(w, "create directory", result.displayPath()) + }, newJSONCommandOperationOutput(cmd, result.Input, []jsonOperationResult{mkdirOperationResult(result)}, nil)) + } + arg := files.NewCreateFolderArg(dst) dbx := filesNewFunc(config) created, err := dbx.CreateFolderV2Context(currentContext(), arg) var metadata *files.FolderMetadata status := mkdirStatusCreated if err != nil { - if !parents { + if !opts.parents { return err } @@ -98,13 +117,30 @@ func mkdir(cmd *cobra.Command, args []string) (err error) { metadata = created.Metadata } - result, err := newMkdirResult(status, dst, parents, metadata) + result, err := newMkdirResult(status, dst, opts, metadata) if err != nil { return err } return renderJSONOperationOutput(cmd, result.Input, []jsonOperationResult{mkdirOperationResult(result)}) } +func parseMkdirOptions(cmd *cobra.Command) (mkdirOptions, error) { + parents, err := cmd.Flags().GetBool("parents") + if err != nil { + return mkdirOptions{}, err + } + + dryRun, err := dryRunEnabled(cmd) + if err != nil { + return mkdirOptions{}, err + } + + return mkdirOptions{ + parents: parents, + dryRun: dryRun, + }, nil +} + func existingFolderMetadata(dbx filesClient, dst string) (*files.FolderMetadata, error) { metadata, err := dbx.GetMetadataContext(currentContext(), files.NewGetMetadataArg(dst)) if err != nil { @@ -117,7 +153,7 @@ func existingFolderMetadata(dbx filesClient, dst string) (*files.FolderMetadata, return folder, nil } -func newMkdirResult(status, path string, parents bool, metadata *files.FolderMetadata) (mkdirResult, error) { +func newMkdirResult(status, path string, opts mkdirOptions, metadata *files.FolderMetadata) (mkdirResult, error) { result, err := jsonMetadataFromDropbox(metadata) if err != nil { return mkdirResult{}, err @@ -129,14 +165,28 @@ func newMkdirResult(status, path string, parents bool, metadata *files.FolderMet Kind: mkdirKindFolder, Input: mkdirInput{ Path: path, - Parents: parents, + Parents: opts.parents, + DryRun: opts.dryRun, }, Result: result, }, 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 mkdirOperationResult(result mkdirResult) 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 (r mkdirResult) displayPath() string { + return dryRunDisplayPath(r.Result, r.Input.Path) } func createFolderConflictTag(err error) (string, bool) { @@ -178,5 +228,6 @@ var mkdirCmd = &cobra.Command{ func init() { RootCmd.AddCommand(mkdirCmd) mkdirCmd.Flags().BoolP("parents", "p", false, "No error if existing, create parent directories as needed") + addDryRunFlag(mkdirCmd) enableStructuredOutput(mkdirCmd) } diff --git a/cmd/mkdir_test.go b/cmd/mkdir_test.go index 8b7a177..9da012d 100644 --- a/cmd/mkdir_test.go +++ b/cmd/mkdir_test.go @@ -54,6 +54,32 @@ func TestMkdirQuietByDefault(t *testing.T) { } } +func TestMkdirDryRunTextOutputSnapshot(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirDryRun(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + t.Fatalf("CreateFolderV2 called during dry-run: %v", arg) + return nil, nil + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called during dry-run: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + + const want = "Would create directory /Projects\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + func TestMkdirJSONOutputsCreatedFolder(t *testing.T) { cmd, stdout := testMkdirCmd(t) setMkdirOutputJSON(t, cmd) @@ -98,6 +124,76 @@ func TestMkdirJSONOutputsCreatedFolder(t *testing.T) { } } +func TestMkdirJSONDryRunOutputsPlannedResult(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + setMkdirDryRun(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + t.Fatalf("CreateFolderV2 called during dry-run: %v", arg) + return nil, nil + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called during dry-run: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + + got := decodeMkdirOutput(t, stdout) + if got.Input.Path != "/Projects" || got.Input.Parents || !got.Input.DryRun { + t.Fatalf("input = %#v, want path /Projects, parents false, dry_run true", got.Input) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Kind != mkdirKindFolder { + t.Fatalf("status/kind = %s/%s, want planned/folder", result.Status, result.Kind) + } + if result.Input.Path != "/Projects" || result.Input.Parents || !result.Input.DryRun { + t.Fatalf("result input = %#v, want path /Projects, parents false, dry_run true", result.Input) + } + if result.Result.Type != "folder" { + t.Fatalf("result type = %q, want folder", result.Result.Type) + } + if result.Result.PathDisplay != "/Projects" || result.Result.PathLower != "/projects" { + t.Fatalf("path_display/path_lower = %q/%q, want /Projects//projects", result.Result.PathDisplay, result.Result.PathLower) + } + if result.Result.ID != "" { + t.Fatalf("id = %q, want empty for planned metadata", result.Result.ID) + } +} + +func TestMkdirJSONDryRunOutputSnapshot(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + setMkdirDryRun(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + t.Fatalf("CreateFolderV2 called during dry-run: %v", arg) + return nil, nil + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called during dry-run: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + + const want = "{\"ok\":true,\"schema_version\":\"1\",\"command\":\"mkdir\",\"input\":{\"path\":\"/Projects\",\"parents\":false,\"dry_run\":true},\"results\":[{\"status\":\"planned\",\"kind\":\"folder\",\"input\":{\"path\":\"/Projects\",\"parents\":false,\"dry_run\":true},\"result\":{\"type\":\"folder\",\"path_display\":\"/Projects\",\"path_lower\":\"/projects\"}}],\"warnings\":[]}\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %s, want %s", got, want) + } +} + func TestMkdirJSONParentsReturnsExistingFolderMetadata(t *testing.T) { cmd, stdout := testMkdirCmd(t) setMkdirOutputJSON(t, cmd) @@ -259,6 +355,7 @@ func testMkdirCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { cmd := &cobra.Command{Use: "mkdir"} cmd.SetOut(&stdout) cmd.Flags().BoolP("parents", "p", false, "") + addDryRunFlag(cmd) cmd.Flags().String(outputFlag, "text", "") return cmd, &stdout } @@ -279,6 +376,14 @@ func setMkdirParents(t *testing.T, cmd *cobra.Command) { } } +func setMkdirDryRun(t *testing.T, cmd *cobra.Command) { + t.Helper() + + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } +} + func mkdirFolderMetadata(path string) *files.FolderMetadata { return &files.FolderMetadata{ Metadata: files.Metadata{ diff --git a/cmd/rm.go b/cmd/rm.go index d7172f3..d465255 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -249,10 +249,7 @@ func renderRemoveResults(w io.Writer, results []removeResult) error { } func (r removeResult) displayPath() string { - if r.Result.PathDisplay != "" { - return r.Result.PathDisplay - } - return r.Input.Path + return dryRunDisplayPath(r.Result, r.Input.Path) } func (o removeOptions) allowNonEmptyFolder() bool { diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 0e873fd..e4e3d68 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -180,6 +180,7 @@ "type" ], "mkdir_input": [ + "dry_run", "parents", "path" ], @@ -514,7 +515,8 @@ "result": "metadata", "statuses": [ "created", - "existing" + "existing", + "planned" ], "kinds": [ "folder" diff --git a/docs/commands/dbxcli_mkdir.md b/docs/commands/dbxcli_mkdir.md index e18998e..91b6dca 100644 --- a/docs/commands/dbxcli_mkdir.md +++ b/docs/commands/dbxcli_mkdir.md @@ -11,6 +11,7 @@ dbxcli mkdir [flags] ### Options ``` + --dry-run Preview intended writes without making changes -h, --help help for mkdir -p, --parents No error if existing, create parent directories as needed ``` @@ -33,7 +34,7 @@ dbxcli mkdir [flags] * Dropbox scopes: `files.content.write` * Arguments: `directory` (required, dropbox_path) * Flag metadata: `--output` (values: `json`, `text`) -* Result statuses: `created`, `existing` +* Result statuses: `created`, `existing`, `planned` * Result kinds: `folder` * JSON contract: `docs/json-schema/v1/commands.json#/commands/mkdir` * JSON success schema: `docs/json-schema/v1/commands.schema.json#/$defs/command_mkdir` diff --git a/docs/commands/dbxcli_rm.md b/docs/commands/dbxcli_rm.md index acdf2b1..6c694ad 100644 --- a/docs/commands/dbxcli_rm.md +++ b/docs/commands/dbxcli_rm.md @@ -11,7 +11,7 @@ dbxcli rm [flags] ### Options ``` - --dry-run Show what would be done without making changes + --dry-run Preview intended writes without making changes -f, --force Allow removing non-empty folders; same as --recursive -h, --help help for rm --permanent Permanently delete instead of moving to Dropbox trash diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 0e873fd..e4e3d68 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -180,6 +180,7 @@ "type" ], "mkdir_input": [ + "dry_run", "parents", "path" ], @@ -514,7 +515,8 @@ "result": "metadata", "statuses": [ "created", - "existing" + "existing", + "planned" ], "kinds": [ "folder" diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index 4cd8429..25f6964 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1736,6 +1736,9 @@ "mkdir_input": { "additionalProperties": false, "properties": { + "dry_run": { + "type": "boolean" + }, "parents": { "type": "boolean" }, @@ -2141,7 +2144,8 @@ "status": { "enum": [ "created", - "existing" + "existing", + "planned" ] } },