diff --git a/README.md b/README.md index f309e68..38743d4 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Text output is the default. JSON output is available through the global `--outpu ```sh $ dbxcli --output=json +$ dbxcli mkdir --output=json /new-folder $ dbxcli rm --output=json /old-file.txt $ dbxcli restore --output=json /Reports/old.pdf 015f... ``` diff --git a/cmd/mkdir.go b/cmd/mkdir.go index b347aa3..6492a10 100644 --- a/cmd/mkdir.go +++ b/cmd/mkdir.go @@ -16,12 +16,23 @@ package cmd import ( "errors" + "fmt" "strings" "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" "github.com/spf13/cobra" ) +type mkdirInput struct { + Path string `json:"path"` + Parents bool `json:"parents"` +} + +type mkdirResult struct { + Input mkdirInput `json:"input"` + Result jsonMetadata `json:"result"` +} + func mkdir(cmd *cobra.Command, args []string) (err error) { if len(args) != 1 { return errors.New("`mkdir` requires a `directory` argument") @@ -36,15 +47,98 @@ func mkdir(cmd *cobra.Command, args []string) (err error) { parents, _ := cmd.Flags().GetBool("parents") - dbx := files.New(config) - if _, err = dbx.CreateFolderV2(arg); err != nil { - if parents && isConflictError(err) { - return nil + dbx := filesNewFunc(config) + created, err := dbx.CreateFolderV2(arg) + var metadata *files.FolderMetadata + if err != nil { + if !parents { + return err } - return + + conflictTag, ok := createFolderConflictTag(err) + switch { + case ok && conflictTag == files.WriteConflictErrorFolder: + if commandOutputFormat(cmd) == "text" { + return nil + } + metadata, err = existingFolderMetadata(dbx, dst) + if err != nil { + return err + } + case ok && (conflictTag == files.WriteConflictErrorFile || conflictTag == files.WriteConflictErrorFileAncestor): + return fmt.Errorf("path exists and is not a folder: %s", dst) + case ok: + return err + case isConflictError(err): + if commandOutputFormat(cmd) == "text" { + return nil + } + metadata, err = existingFolderMetadata(dbx, dst) + if err != nil { + return err + } + default: + return err + } + } else { + if created == nil || created.Metadata == nil { + return errors.New("create folder returned no metadata") + } + metadata = created.Metadata } - return + result := newMkdirResult(dst, parents, metadata) + return commandOutput(cmd).Render(nil, result) +} + +func existingFolderMetadata(dbx files.Client, dst string) (*files.FolderMetadata, error) { + metadata, err := dbx.GetMetadata(files.NewGetMetadataArg(dst)) + if err != nil { + return nil, err + } + folder, ok := metadata.(*files.FolderMetadata) + if !ok || folder == nil { + return nil, fmt.Errorf("path exists and is not a folder: %s", dst) + } + return folder, nil +} + +func newMkdirResult(path string, parents bool, metadata *files.FolderMetadata) mkdirResult { + result := jsonMetadataFromDropbox(metadata) + result.PathDisplay = metadataDisplayPath(path, result.PathDisplay) + + return mkdirResult{ + Input: mkdirInput{ + Path: path, + Parents: parents, + }, + Result: result, + } +} + +func createFolderConflictTag(err error) (string, bool) { + var apiErrPtr *files.CreateFolderV2APIError + if errors.As(err, &apiErrPtr) && apiErrPtr != nil { + return createFolderEndpointConflictTag(apiErrPtr.EndpointError) + } + + var apiErr files.CreateFolderV2APIError + if errors.As(err, &apiErr) { + return createFolderEndpointConflictTag(apiErr.EndpointError) + } + + return "", false +} + +func createFolderEndpointConflictTag(endpointErr *files.CreateFolderError) (string, bool) { + if endpointErr == nil || + endpointErr.Tag != files.CreateFolderErrorPath || + endpointErr.Path == nil || + endpointErr.Path.Tag != files.WriteErrorConflict || + endpointErr.Path.Conflict == nil { + return "", false + } + return endpointErr.Path.Conflict.Tag, true } func isConflictError(err error) bool { @@ -61,4 +155,5 @@ var mkdirCmd = &cobra.Command{ func init() { RootCmd.AddCommand(mkdirCmd) mkdirCmd.Flags().BoolP("parents", "p", false, "No error if existing, create parent directories as needed") + enableStructuredOutput(mkdirCmd) } diff --git a/cmd/mkdir_test.go b/cmd/mkdir_test.go index af85050..0b1eba8 100644 --- a/cmd/mkdir_test.go +++ b/cmd/mkdir_test.go @@ -1,8 +1,15 @@ package cmd import ( + "bytes" + "encoding/json" "fmt" + "strings" "testing" + + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox" + "github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files" + "github.com/spf13/cobra" ) func TestMkdirArgValidation(t *testing.T) { @@ -19,6 +26,202 @@ func TestMkdirTooManyArgs(t *testing.T) { } } +func TestMkdirQuietByDefault(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + folder := mkdirFolderMetadata("/Projects") + var createdPath string + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + createdPath = arg.Path + return files.NewCreateFolderResult(folder), nil + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called on create success: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if createdPath != "/Projects" { + t.Fatalf("created path = %q, want /Projects", createdPath) + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want quiet success", got) + } +} + +func TestMkdirJSONOutputsCreatedFolder(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + folder := mkdirFolderMetadata("/Projects") + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + return files.NewCreateFolderResult(folder), 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 { + t.Fatalf("input = %#v, want path /Projects and parents false", got.Input) + } + if got.Result.Type != "folder" { + t.Fatalf("result type = %q, want folder", got.Result.Type) + } + if got.Result.PathDisplay != "/Projects" { + t.Fatalf("path_display = %q, want /Projects", got.Result.PathDisplay) + } + if got.Result.PathLower != "/projects" { + t.Fatalf("path_lower = %q, want /projects", got.Result.PathLower) + } + if got.Result.ID != "id:folder" { + t.Fatalf("id = %q, want id:folder", got.Result.ID) + } + if strings.Contains(stdout.String(), `"rev"`) || strings.Contains(stdout.String(), `"size"`) { + t.Fatalf("folder JSON output = %s, want no file-only fields", stdout.String()) + } +} + +func TestMkdirJSONParentsReturnsExistingFolderMetadata(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + setMkdirParents(t, cmd) + var createPath string + var getPath string + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + createPath = arg.Path + return nil, createFolderConflictError(files.WriteConflictErrorFolder) + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + getPath = arg.Path + return mkdirFolderMetadata("/Existing"), nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Existing"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if createPath != "/Existing" || getPath != "/Existing" { + t.Fatalf("createPath = %q, getPath = %q; want /Existing", createPath, getPath) + } + + got := decodeMkdirOutput(t, stdout) + if got.Input.Path != "/Existing" || !got.Input.Parents { + t.Fatalf("input = %#v, want path /Existing and parents true", got.Input) + } + if got.Result.Type != "folder" || got.Result.PathDisplay != "/Existing" { + t.Fatalf("result = %#v, want existing folder metadata", got.Result) + } +} + +func TestMkdirParentsExistingFolderTextDoesNotFetchMetadata(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirParents(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + return nil, createFolderConflictError(files.WriteConflictErrorFolder) + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called for text existing folder success: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Existing"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want quiet success", got) + } +} + +func TestMkdirParentsExistingFileReturnsError(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirParents(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + return nil, createFolderConflictError(files.WriteConflictErrorFile) + }, + getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) { + t.Fatalf("GetMetadata called for typed existing file conflict: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + err := mkdir(cmd, []string{"/Existing"}) + if err == nil { + t.Fatal("expected error for existing file") + } + if !strings.Contains(err.Error(), "not a folder") { + t.Fatalf("error = %q, want not a folder", err.Error()) + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty output on error", got) + } +} + +func TestMkdirJSONErrorWritesNoOutput(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + return nil, fmt.Errorf("create failed") + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err == nil { + t.Fatal("expected mkdir error") + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty output on error", got) + } +} + +func TestMkdirJSONUsesInputPathWhenMetadataPathDisplayMissing(t *testing.T) { + cmd, stdout := testMkdirCmd(t) + setMkdirOutputJSON(t, cmd) + + mock := &mockFilesClient{ + createFolderV2Fn: func(arg *files.CreateFolderArg) (*files.CreateFolderResult, error) { + return files.NewCreateFolderResult(&files.FolderMetadata{}), nil + }, + } + stubFilesClient(t, mock) + + if err := mkdir(cmd, []string{"/Projects"}); err != nil { + t.Fatalf("mkdir error: %v", err) + } + + got := decodeMkdirOutput(t, stdout) + if got.Result.PathDisplay != "/Projects" { + t.Fatalf("path_display = %q, want fallback input path", got.Result.PathDisplay) + } +} + +func TestMkdirCommandSupportsStructuredOutput(t *testing.T) { + if !commandSupportsStructuredOutput(mkdirCmd) { + t.Fatal("mkdir command should support structured output") + } +} + func TestIsConflictError(t *testing.T) { tests := []struct { msg string @@ -37,3 +240,66 @@ func TestIsConflictError(t *testing.T) { } } } + +func testMkdirCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + + var stdout bytes.Buffer + cmd := &cobra.Command{Use: "mkdir"} + cmd.SetOut(&stdout) + cmd.Flags().BoolP("parents", "p", false, "") + cmd.Flags().String(outputFlag, "text", "") + return cmd, &stdout +} + +func setMkdirOutputJSON(t *testing.T, cmd *cobra.Command) { + t.Helper() + + if err := cmd.Flags().Set(outputFlag, "json"); err != nil { + t.Fatal(err) + } +} + +func setMkdirParents(t *testing.T, cmd *cobra.Command) { + t.Helper() + + if err := cmd.Flags().Set("parents", "true"); err != nil { + t.Fatal(err) + } +} + +func mkdirFolderMetadata(path string) *files.FolderMetadata { + return &files.FolderMetadata{ + Metadata: files.Metadata{ + Name: strings.TrimPrefix(path, "/"), + PathDisplay: path, + PathLower: strings.ToLower(path), + }, + Id: "id:folder", + } +} + +func createFolderConflictError(conflictTag string) files.CreateFolderV2APIError { + return files.CreateFolderV2APIError{ + APIError: dropbox.APIError{ErrorSummary: "path/conflict/" + conflictTag + "/"}, + EndpointError: &files.CreateFolderError{ + Tagged: dropbox.Tagged{Tag: files.CreateFolderErrorPath}, + Path: &files.WriteError{ + Tagged: dropbox.Tagged{Tag: files.WriteErrorConflict}, + Conflict: &files.WriteConflictError{ + Tagged: dropbox.Tagged{Tag: conflictTag}, + }, + }, + }, + } +} + +func decodeMkdirOutput(t *testing.T, stdout *bytes.Buffer) mkdirResult { + t.Helper() + + var got mkdirResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode JSON output: %v\noutput: %s", err, stdout.String()) + } + return got +}