diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go index 0c87890..a9eb7a8 100644 --- a/cmd/help_json_test.go +++ b/cmd/help_json_test.go @@ -277,12 +277,15 @@ func TestJSONHelpManifestV1MachineFields(t *testing.T) { } assertJSONHelpArg(t, put.Args, "source", true, "local_path", true) assertJSONHelpArg(t, put.Args, "target", false, "dropbox_path", false) + if jsonHelpFlagByName(t, put.Flags, dryRunFlagName).ValueKind != "boolean" { + t.Fatalf("put --%s value_kind = %q, want boolean", dryRunFlagName, jsonHelpFlagByName(t, put.Flags, dryRunFlagName).ValueKind) + } assertStringSliceEqual(t, "put --if-exists enum", jsonHelpFlagByName(t, put.Flags, "if-exists").EnumValues, []string{"overwrite", "skip", "fail", "autorename"}) if !put.StdinStdout.ReadsStdin || put.StdinStdout.WritesBinaryStdout { t.Fatalf("put stdin_stdout = %+v, want stdin only", put.StdinStdout) } assertStringSliceEqual(t, "put warning codes", put.WarningCodes, []string{jsonWarningCodeSkippedSymlink}) - assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"autorenamed", "created", "existing", "skipped", "uploaded"}) + assertStringSliceEqual(t, "put result statuses", put.ResultStatuses, []string{"autorenamed", "created", "existing", "skipped", "uploaded", jsonStatusPlanned}) assertStringSliceEqual(t, "put result kinds", put.ResultKinds, []string{"file", "folder"}) assertStringSliceEqual(t, "put scopes", put.DropboxScopes, []string{"files.content.write", "files.metadata.read"}) if put.ScopeAccuracy != commandManifestScopeAccuracyBestEffort { diff --git a/cmd/help_manifest.go b/cmd/help_manifest.go index 6555546..a7dd2c7 100644 --- a/cmd/help_manifest.go +++ b/cmd/help_manifest.go @@ -188,11 +188,12 @@ var commandManifestRegistry = map[string]jsonCommandManifestMetadata{ {Description: "Upload from stdin", Command: "printf 'hello' | dbxcli put - /hello.txt"}, }, Flags: map[string]jsonCommandFlagMetadata{ - "chunksize": {ValueKind: "bytes"}, - "debug": {ValueKind: "boolean"}, - "if-exists": {EnumValues: []string{"overwrite", "skip", "fail", "autorename"}, ValueKind: "enum"}, - "recursive": {ValueKind: "boolean"}, - "workers": {ValueKind: "integer"}, + "chunksize": {ValueKind: "bytes"}, + "debug": {ValueKind: "boolean"}, + dryRunFlagName: {ValueKind: "boolean"}, + "if-exists": {EnumValues: []string{"overwrite", "skip", "fail", "autorename"}, ValueKind: "enum"}, + "recursive": {ValueKind: "boolean"}, + "workers": {ValueKind: "integer"}, }, DropboxScopes: []string{"files.content.write", "files.metadata.read"}, StdinStdout: jsonCommandStdinStdout{ReadsStdin: true}, @@ -351,7 +352,7 @@ var commandContractRegistry = map[string]jsonCommandContractMetadata{ "ls": {Statuses: []string{"listed"}, Kinds: []string{"deleted", "file", "folder"}}, "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}}, + "put": {Statuses: []string{"autorenamed", "created", "existing", "skipped", "uploaded", jsonStatusPlanned}, Kinds: []string{"file", "folder"}, Warnings: []string{jsonWarningCodeSkippedSymlink}}, "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"}}, diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 7160620..4a29e0a 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -739,8 +739,8 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "mv": newJSONOperationOutput(nil, []jsonOperationResult{ newJSONOperationResult(relocationJSONStatusMoved, "file", relocationInput{FromPath: "/Reports/copy.pdf", ToPath: "/Reports/moved.pdf"}, sampleJSONFileMetadata("/Reports/moved.pdf")), }, nil), - "put": newJSONOperationOutput(putCommandInput{Source: "README.md", Target: "/README.md", Recursive: true, IfExists: putIfExistsOverwrite, Stdin: false}, []jsonOperationResult{ - newJSONOperationResult(putStatusUploaded, putKindFile, putResultInput{Source: "README.md", Target: "/README.md"}, sampleJSONFileMetadata("/README.md")), + "put": newJSONOperationOutput(putCommandInput{Source: "README.md", Target: "/README.md", Recursive: true, IfExists: putIfExistsOverwrite, Stdin: false, DryRun: false}, []jsonOperationResult{ + newJSONOperationResult(putStatusUploaded, putKindFile, putResultInput{Source: "README.md", Target: "/README.md", DryRun: false}, sampleJSONFileMetadata("/README.md")), }, []jsonWarning{{Code: jsonWarningCodeSkippedSymlink, Message: "skipped symlink", Path: "docs/link"}}), "restore": newJSONOperationOutput(restoreInput{Path: "/Reports/old.pdf", Revision: "015f"}, []jsonOperationResult{ newJSONOperationResult(restoreStatusRestored, restoreKindFile, restoreInput{Path: "/Reports/old.pdf", Revision: "015f"}, file), @@ -1094,7 +1094,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "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, jsonStatusPlanned}, metadataKinds(), nil), - "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusAutorenamed, putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), + "put": operationSchema("put_input", schemaRef("put_result_input"), "metadata", []string{putStatusAutorenamed, putStatusCreated, putStatusExisting, putStatusSkipped, putStatusUploaded, jsonStatusPlanned}, []string{putKindFile, putKindFolder}, []string{jsonWarningCodeSkippedSymlink}), "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), diff --git a/cmd/pipe_test.go b/cmd/pipe_test.go index 9941880..0548ae4 100644 --- a/cmd/pipe_test.go +++ b/cmd/pipe_test.go @@ -183,6 +183,71 @@ func TestPutStdinIfExistsSkipDoesNotReadStdin(t *testing.T) { } } +func TestPutStdinDryRunDoesNotReadStdin(t *testing.T) { + cmd := testPutCmdWithStdin(failReadReader{t: t}) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + cmd.SetOut(&stdout) + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called during stdin dry-run: %v", arg) + return nil, nil + }, + uploadFn: func(arg *files.UploadArg, r io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during stdin dry-run: %v", arg) + return nil, nil + }, + }) + + if err := put(cmd, []string{"-", "/stdin.txt"}); err != nil { + t.Fatalf("put stdin dry-run error: %v", err) + } + + const want = "Would upload - to /stdin.txt\n" + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestPutStdinJSONDryRunOutputsPlannedResult(t *testing.T) { + cmd := testPutCmdWithStdin(failReadReader{t: t}) + cmd.Flags().String(outputFlag, "text", "") + if err := cmd.Flags().Set(outputFlag, "json"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + cmd.SetOut(&stdout) + stubFilesClient(t, &mockFilesClient{ + uploadFn: func(arg *files.UploadArg, r io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during stdin dry-run: %v", arg) + return nil, nil + }, + }) + + if err := put(cmd, []string{"-", "/stdin.txt"}); err != nil { + t.Fatalf("put stdin dry-run error: %v", err) + } + + got := decodePutOutput(t, &stdout) + if got.Input.Source != "-" || got.Input.Target != "/stdin.txt" || !got.Input.Stdin || !got.Input.DryRun { + t.Fatalf("input = %+v, want stdin dry-run", got.Input) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Kind != putKindFile || !result.Input.DryRun { + t.Fatalf("result = %+v, want planned file dry-run", result) + } + if result.Result == nil || result.Result.PathDisplay != "/stdin.txt" { + t.Fatalf("metadata = %+v, want planned stdin target metadata", result.Result) + } +} + func TestPutStdin_UploadsToDashPath(t *testing.T) { content := "dash path" cmd := testPutCmdWithStdin(strings.NewReader(content)) diff --git a/cmd/put.go b/cmd/put.go index 904637e..06a9926 100644 --- a/cmd/put.go +++ b/cmd/put.go @@ -204,6 +204,7 @@ type putOptions struct { workers int debug bool ifExists string + dryRun bool output *output.Renderer errOut io.Writer } @@ -225,11 +226,13 @@ type putCommandInput struct { Recursive bool `json:"recursive"` IfExists string `json:"if_exists"` Stdin bool `json:"stdin"` + DryRun bool `json:"dry_run,omitempty"` } type putResultInput struct { Source string `json:"source"` Target string `json:"target"` + DryRun bool `json:"dry_run,omitempty"` } type putResult struct { @@ -280,7 +283,7 @@ func putOperationResults(results []putResult) []jsonOperationResult { if result.Result != nil { metadata = result.Result } - operationResults = append(operationResults, newJSONOperationResult(result.Status, result.Kind, result.Input, metadata)) + operationResults = append(operationResults, newJSONOperationResult(plannedStatus(result.Input.DryRun, result.Status), result.Kind, result.Input, metadata)) } return operationResults } @@ -328,6 +331,20 @@ func put(cmd *cobra.Command, args []string) (err error) { } if srcInfo.IsDir() { + if opts.dryRun { + results, warnings, err := plannedPutRecursiveResults(src, dst) + if err != nil { + return withJSONErrorDetails(err, operationErrorDetails("upload"), relocationErrorDetails(src, dst)) + } + return renderPlannedPutResults(cmd, putCommandInput{ + Source: src, + Target: dst, + Recursive: true, + IfExists: opts.ifExists, + Stdin: false, + DryRun: true, + }, results, warnings) + } if commandOutputFormat(cmd) == output.FormatText { return withJSONErrorDetails(putRecursive(src, dst, opts), operationErrorDetails("upload"), relocationErrorDetails(src, dst)) } @@ -341,9 +358,22 @@ func put(cmd *cobra.Command, args []string) (err error) { Recursive: true, IfExists: opts.ifExists, Stdin: false, + DryRun: false, }, results, warnings) } + if opts.dryRun { + result := plannedPutFileResult(src, dst) + return renderPlannedPutResults(cmd, putCommandInput{ + Source: src, + Target: dst, + Recursive: false, + IfExists: opts.ifExists, + Stdin: false, + DryRun: true, + }, []putResult{result}, nil) + } + result, err := putFileWithResult(src, dst, opts) if err != nil { return withJSONErrorDetails(err, operationErrorDetails("upload"), relocationErrorDetails(src, dst)) @@ -354,6 +384,7 @@ func put(cmd *cobra.Command, args []string) (err error) { Recursive: false, IfExists: opts.ifExists, Stdin: false, + DryRun: false, }, []putResult{result}) } @@ -375,6 +406,18 @@ func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool return err } + if opts.dryRun { + result := plannedPutFileResult("-", dstPath) + return renderPlannedPutResults(cmd, putCommandInput{ + Source: "-", + Target: dstPath, + Recursive: false, + IfExists: opts.ifExists, + Stdin: true, + DryRun: true, + }, []putResult{result}, nil) + } + dbx := filesNewFunc(config) action, existingMetadata, err := checkPutStdinDestination(dbx, dstPath, opts.ifExists) if err != nil { @@ -392,6 +435,7 @@ func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool Recursive: false, IfExists: opts.ifExists, Stdin: true, + DryRun: false, }, []putResult{result}) } @@ -422,6 +466,7 @@ func putStdin(cmd *cobra.Command, args []string, opts putOptions, recursive bool Recursive: false, IfExists: opts.ifExists, Stdin: true, + DryRun: false, }, []putResult{result}) } @@ -469,11 +514,16 @@ func parsePutOptions(cmd *cobra.Command) (putOptions, error) { if err != nil { return putOptions{}, err } + dryRun, err := dryRunEnabled(cmd) + if err != nil { + return putOptions{}, err + } return putOptions{ chunkSize: chunkSize, workers: workers, debug: debug, ifExists: ifExists, + dryRun: dryRun, output: commandOutput(cmd), errOut: cmd.ErrOrStderr(), }, nil @@ -744,6 +794,122 @@ func putRecursiveWithResults(src, dst string, opts putOptions) ([]putResult, []j return putRecursiveInternal(src, dst, opts, true) } +func plannedPutFileResult(src, dst string) putResult { + metadata := plannedMetadata(putKindFile, dst) + return putResult{ + Status: putStatusUploaded, + Kind: putKindFile, + Input: putResultInput{ + Source: src, + Target: dst, + DryRun: true, + }, + Result: &metadata, + } +} + +func plannedPutFolderResult(src, dst string) putResult { + metadata := plannedMetadata(putKindFolder, dst) + return putResult{ + Status: putStatusCreated, + Kind: putKindFolder, + Input: putResultInput{ + Source: src, + Target: dst, + DryRun: true, + }, + Result: &metadata, + } +} + +func renderPlannedPutResults(cmd *cobra.Command, input putCommandInput, results []putResult, warnings []jsonWarning) error { + return commandOutput(cmd).Render(func(w io.Writer) error { + for _, result := range results { + if result.Kind == putKindFolder { + if err := writeDryRunLine(w, "create directory", result.Input.Target); err != nil { + return err + } + continue + } + if err := writeDryRunRelocationLine(w, "upload", result.Input.Source, result.Input.Target); err != nil { + return err + } + } + return nil + }, newJSONCommandOperationOutput(cmd, input, putOperationResults(results), warnings)) +} + +// Keep traversal semantics aligned with putRecursiveInternal. Dry-run walks the +// same local tree but plans results instead of creating Dropbox writes. +func plannedPutRecursiveResults(src, dst string) ([]putResult, []jsonWarning, error) { + src = filepath.Clean(src) + var results []putResult + var warnings []jsonWarning + dirsWithFiles := make(map[string]bool) + + err := filepath.WalkDir(src, func(filePath string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !d.Type().IsRegular() { + if d.Type()&os.ModeSymlink != 0 { + warnings = append(warnings, jsonWarning{ + Code: jsonWarningCodeSkippedSymlink, + Message: "skipped symlink during recursive upload", + Path: filePath, + }) + } + return nil + } + + relPath, err := filepath.Rel(src, filePath) + if err != nil { + return err + } + dirsWithFiles[filepath.Dir(filePath)] = true + remotePath := path.Join(dst, filepath.ToSlash(relPath)) + results = append(results, plannedPutFileResult(filePath, remotePath)) + return nil + }) + if err != nil { + return nil, nil, withJSONErrorDetails(err, operationErrorDetails("upload"), pathErrorDetails(src)) + } + + results = append(results, plannedPutFolderResult(src, dst)) + + err = filepath.WalkDir(src, func(dirPath string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + if dirsWithFiles[dirPath] { + return nil + } + + relPath, err := filepath.Rel(src, dirPath) + if err != nil { + return err + } + if relPath == "." { + return nil + } + + remotePath := path.Join(dst, filepath.ToSlash(relPath)) + results = append(results, plannedPutFolderResult(dirPath, remotePath)) + return nil + }) + if err != nil { + return nil, nil, withJSONErrorDetails(err, operationErrorDetails("upload"), pathErrorDetails(src)) + } + + return results, warnings, nil +} + func putRecursiveInternal(src, dst string, opts putOptions, collectResults bool) ([]putResult, []jsonWarning, error) { src = filepath.Clean(src) var results []putResult @@ -928,6 +1094,7 @@ var putCmd = &cobra.Command{ func init() { RootCmd.AddCommand(putCmd) enableStructuredOutput(putCmd) + addDryRunFlag(putCmd) putCmd.Flags().BoolP("recursive", "r", false, "Recursively upload directories") putCmd.Flags().IntP("workers", "w", 4, "Number of concurrent upload workers for chunked large-file uploads") putCmd.Flags().Int64P("chunksize", "c", 1<<24, "Chunk size in bytes for chunked large-file uploads; must be a multiple of 4MiB and no more than 128MiB") diff --git a/cmd/put_test.go b/cmd/put_test.go index a7624d2..01c7078 100644 --- a/cmd/put_test.go +++ b/cmd/put_test.go @@ -241,6 +241,7 @@ func testConfig() dropbox.Config { func testPutCmd() *cobra.Command { cmd := &cobra.Command{} + addDryRunFlag(cmd) cmd.Flags().BoolP("recursive", "r", false, "Recursively upload directories") cmd.Flags().IntP("workers", "w", 4, "Number of concurrent upload workers to use") cmd.Flags().Int64P("chunksize", "c", 1<<24, "Chunk size to use (should be multiple of 4MiB)") @@ -767,6 +768,135 @@ func TestPutJSONSingleFileOutputsUploadedResult(t *testing.T) { } } +func TestPutDryRunTextOutputSnapshot(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + if arg.Path != "/uploaded.txt" { + t.Fatalf("GetMetadata path during dry-run = %q, want /uploaded.txt", arg.Path) + } + return nil, getMetadataNotFoundError() + }, + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := testPutCmd() + cmd.SetOut(&stdout) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + if err := put(cmd, []string{tmpFile, "/uploaded.txt"}); err != nil { + t.Fatalf("put error: %v", err) + } + + want := fmt.Sprintf("Would upload %s to /uploaded.txt\n", tmpFile) + if got := stdout.String(); got != want { + t.Fatalf("stdout = %q, want %q", got, want) + } +} + +func TestPutJSONDryRunOutputsPlannedResult(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + if arg.Path != "/uploaded.txt" { + t.Fatalf("GetMetadata path during dry-run = %q, want /uploaded.txt", arg.Path) + } + return nil, getMetadataNotFoundError() + }, + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := testPutJSONCmd(&stdout, nil) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + if err := put(cmd, []string{tmpFile, "/uploaded.txt"}); err != nil { + t.Fatalf("put error: %v", err) + } + + got := decodePutOutput(t, &stdout) + if got.Input.Source != tmpFile || got.Input.Target != "/uploaded.txt" || !got.Input.DryRun { + t.Fatalf("input = %+v, want source, target, dry_run true", got.Input) + } + if len(got.Results) != 1 { + t.Fatalf("results len = %d, want 1", len(got.Results)) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Kind != putKindFile { + t.Fatalf("result status/kind = %s/%s, want planned/file", result.Status, result.Kind) + } + if result.Input.Source != tmpFile || result.Input.Target != "/uploaded.txt" || !result.Input.DryRun { + t.Fatalf("result input = %+v, want source, target, dry_run true", result.Input) + } + if result.Result == nil || result.Result.Type != "file" || result.Result.PathDisplay != "/uploaded.txt" || result.Result.PathLower != "/uploaded.txt" { + t.Fatalf("metadata = %+v, want planned file metadata", result.Result) + } + if result.Result.Size != nil { + t.Fatalf("size = %v, want omitted planned size", *result.Result.Size) + } +} + +func TestPutJSONDryRunExistingDestinationFolderUsesResolvedPath(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + stubFilesClient(t, &mockFilesClient{ + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + if arg.Path != "/folder" { + t.Fatalf("GetMetadata path during dry-run = %q, want /folder", arg.Path) + } + return putFolderMetadata("/folder"), nil + }, + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := testPutJSONCmd(&stdout, nil) + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + + if err := put(cmd, []string{tmpFile, "/folder"}); err != nil { + t.Fatalf("put error: %v", err) + } + + got := decodePutOutput(t, &stdout) + if got.Input.Target != "/folder/test.txt" { + t.Fatalf("target = %q, want /folder/test.txt", got.Input.Target) + } + result := got.Results[0] + if result.Status != jsonStatusPlanned || result.Input.Target != "/folder/test.txt" { + t.Fatalf("result = %+v, want planned upload to /folder/test.txt", result) + } + if result.Result == nil || result.Result.PathDisplay != "/folder/test.txt" { + t.Fatalf("metadata = %+v, want planned /folder/test.txt metadata", result.Result) + } +} + func TestPutJSONDefaultTargetUsesComputedPath(t *testing.T) { tmpFile := filepath.Join(t.TempDir(), "local.txt") if err := os.WriteFile(tmpFile, []byte("test"), 0644); err != nil { @@ -979,6 +1109,64 @@ func TestPutJSONRecursiveOutputsDirectoryAndFileResults(t *testing.T) { } } +func TestPutJSONDryRunRecursiveOutputsPlannedResults(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "empty"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("file"), 0644); err != nil { + t.Fatal(err) + } + + stubFilesClient(t, &mockFilesClient{ + uploadFn: func(arg *files.UploadArg, content io.Reader) (*files.FileMetadata, error) { + t.Fatalf("Upload called during dry-run: %v", arg) + return nil, nil + }, + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + t.Fatalf("CreateFolderV2 called during dry-run: %v", arg) + return nil, nil + }, + }) + + var stdout bytes.Buffer + cmd := testPutJSONCmd(&stdout, nil) + if err := cmd.Flags().Set("recursive", "true"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set(dryRunFlagName, "true"); err != nil { + t.Fatal(err) + } + if err := put(cmd, []string{dir, "/remote"}); err != nil { + t.Fatalf("put error: %v", err) + } + + got := decodePutOutput(t, &stdout) + if !got.Input.Recursive || !got.Input.DryRun || got.Input.Source != dir || got.Input.Target != "/remote" { + t.Fatalf("input = %+v, want recursive dry-run", got.Input) + } + want := map[string]string{ + "/remote/file.txt": putKindFile, + "/remote": putKindFolder, + "/remote/empty": putKindFolder, + } + if len(got.Results) != len(want) { + t.Fatalf("results len = %d, want %d: %+v", len(got.Results), len(want), got.Results) + } + for _, result := range got.Results { + wantKind, ok := want[result.Input.Target] + if !ok { + t.Fatalf("unexpected result target %q", result.Input.Target) + } + if result.Status != jsonStatusPlanned || result.Kind != wantKind || !result.Input.DryRun { + t.Fatalf("result = %+v, want planned %s dry-run", result, wantKind) + } + if result.Result == nil || result.Result.PathDisplay != result.Input.Target { + t.Fatalf("metadata = %+v, want planned target metadata", result.Result) + } + } +} + func TestPutJSONRecursiveWarnsForSkippedSymlink(t *testing.T) { dir := t.TempDir() realPath := filepath.Join(dir, "real.txt") diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index eb25392..96311ab 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -199,6 +199,7 @@ "status" ], "put_input": [ + "dry_run", "if_exists", "recursive", "source", @@ -206,6 +207,7 @@ "target" ], "put_result_input": [ + "dry_run", "source", "target" ], @@ -555,6 +557,7 @@ "autorenamed", "created", "existing", + "planned", "skipped", "uploaded" ], diff --git a/docs/commands/dbxcli_put.md b/docs/commands/dbxcli_put.md index 69233d4..c4af72a 100644 --- a/docs/commands/dbxcli_put.md +++ b/docs/commands/dbxcli_put.md @@ -36,6 +36,7 @@ dbxcli put [flags] [] ``` -c, --chunksize int Chunk size in bytes for chunked large-file uploads; must be a multiple of 4MiB and no more than 128MiB (default 16777216) -d, --debug Print debug timing + --dry-run Preview intended writes without making changes -h, --help help for put --if-exists string What to do when the destination file exists: overwrite, skip, autorename, or fail (default "overwrite") -r, --recursive Recursively upload directories @@ -61,7 +62,7 @@ dbxcli put [flags] [] * Arguments: `source` (required, local_path, `-` stream operand), `target` (optional, dropbox_path) * Flag metadata: `--if-exists` (values: `autorename`, `fail`, `overwrite`, `skip`), `--output` (values: `json`, `text`) * Stdin/stdout behavior: Use `-` as the local source to upload from stdin; stdin is spooled to a temporary file before upload. -* Result statuses: `autorenamed`, `created`, `existing`, `skipped`, `uploaded` +* Result statuses: `autorenamed`, `created`, `existing`, `planned`, `skipped`, `uploaded` * Result kinds: `file`, `folder` * Warning codes: `skipped_symlink` * JSON contract: `docs/json-schema/v1/commands.json#/commands/put` diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index eb25392..96311ab 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -199,6 +199,7 @@ "status" ], "put_input": [ + "dry_run", "if_exists", "recursive", "source", @@ -206,6 +207,7 @@ "target" ], "put_result_input": [ + "dry_run", "source", "target" ], @@ -555,6 +557,7 @@ "autorenamed", "created", "existing", + "planned", "skipped", "uploaded" ], diff --git a/docs/json-schema/v1/commands.schema.json b/docs/json-schema/v1/commands.schema.json index aebc842..691b953 100644 --- a/docs/json-schema/v1/commands.schema.json +++ b/docs/json-schema/v1/commands.schema.json @@ -1815,6 +1815,9 @@ "put_input": { "additionalProperties": false, "properties": { + "dry_run": { + "type": "boolean" + }, "if_exists": { "enum": [ "autorename", @@ -1849,6 +1852,9 @@ "put_result_input": { "additionalProperties": false, "properties": { + "dry_run": { + "type": "boolean" + }, "source": { "type": "string" }, @@ -2217,6 +2223,7 @@ "autorenamed", "created", "existing", + "planned", "skipped", "uploaded" ]