Skip to content

Commit 3dd5b60

Browse files
Merge pull request #275 from dropbox/cp-mv-json-output
Add --output=json support to cp and mv commands
2 parents d98f6bd + 74fbd54 commit 3dd5b60

6 files changed

Lines changed: 299 additions & 11 deletions

File tree

README.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,16 @@ $ dbxcli du --output=json
143143
$ dbxcli ls --output=json /
144144
$ dbxcli search --output=json report /Reports
145145
$ dbxcli revs --output=json /Reports/old.pdf
146+
$ dbxcli cp --output=json /Reports/old.pdf /Reports/copy.pdf
147+
$ dbxcli mv --output=json /Reports/copy.pdf /Reports/archive/copy.pdf
146148
$ dbxcli share-link create --output=json /Reports/old.pdf
147149
$ dbxcli share-link list --output=json /Reports/old.pdf
148150
$ dbxcli mkdir --output=json /new-folder
149151
$ dbxcli rm --output=json /old-file.txt
150152
$ dbxcli restore --output=json /Reports/old.pdf 015f...
151153
```
152154

153-
JSON support is rolling out command by command. Currently migrated commands are `account`, `du`, `ls`, `search`, `revs`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`.
155+
Structured success output is rolling out command by command. Currently migrated commands are `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `share-link create`, `share-link list`, `share-link info`, `share-link update`, `share-link revoke`, `share-link download`, `mkdir`, `rm`, and `restore`. Commands that have not been migrated return a JSON error whose `error.message` is `structured output is not supported for this command yet` when used with `--output=json`.
154156

155157
Command results are written to stdout. Status, progress, warnings, diagnostics, and verbose logs are written to stderr.
156158

@@ -171,7 +173,30 @@ Successful JSON responses are command-specific. Commands that operate on one pat
171173
}
172174
```
173175

174-
Commands that operate on multiple paths return a `results` array:
176+
Commands that operate on multiple paths return a `results` array. For `cp` and `mv`, each `input` object uses `from_path` and `to_path`:
177+
178+
```json
179+
{
180+
"results": [
181+
{
182+
"input": {
183+
"from_path": "/Reports/old.pdf",
184+
"to_path": "/Reports/copy.pdf"
185+
},
186+
"result": {
187+
"type": "file",
188+
"path_display": "/Reports/copy.pdf",
189+
"path_lower": "/reports/copy.pdf",
190+
"id": "id:...",
191+
"rev": "...",
192+
"size": 123
193+
}
194+
}
195+
]
196+
}
197+
```
198+
199+
For commands such as `rm`, `input` uses command-specific path and flag fields:
175200

176201
```json
177202
{
@@ -268,7 +293,7 @@ Shared-link commands return command-specific objects built around shared-link me
268293

269294
`share-link download --output=json <url> -` is not supported because stdout is reserved for downloaded file bytes when the target is `-`.
270295

271-
In JSON mode, command errors are also written to stdout. The process still exits with a non-zero status:
296+
In JSON mode, command errors are written to stdout as JSON, including errors from commands that do not yet support structured success output. The process still exits with a non-zero status. Detailed diagnostics may also be written to stderr:
272297

273298
```json
274299
{

cmd/cp.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ package cmd
1717
import (
1818
"errors"
1919
"fmt"
20-
"os"
2120
"strings"
2221

2322
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
@@ -40,6 +39,7 @@ func cp(cmd *cobra.Command, args []string) error {
4039

4140
var cpErrors []error
4241
var relocationArgs []*files.RelocationArg
42+
var results []relocationResult
4343

4444
dbx := filesNewFunc(config)
4545
destIsFolder := len(argsToCopy) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination)
@@ -56,20 +56,23 @@ func cp(cmd *cobra.Command, args []string) error {
5656
}
5757

5858
for _, arg := range relocationArgs {
59-
if _, err := dbx.CopyV2(arg); err != nil {
59+
res, err := dbx.CopyV2(arg)
60+
if err != nil {
6061
copyError := fmt.Errorf("copy %q to %q: %v", arg.FromPath, arg.ToPath, err)
6162
cpErrors = append(cpErrors, copyError)
63+
continue
6264
}
65+
results = append(results, newRelocationResult(arg, res))
6366
}
6467

6568
if len(cpErrors) > 0 {
6669
for _, cpError := range cpErrors {
67-
_, _ = fmt.Fprintf(os.Stderr, "%v\n", cpError)
70+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", cpError)
6871
}
6972
return fmt.Errorf("cp: %d error(s)", len(cpErrors))
7073
}
7174

72-
return nil
75+
return commandOutput(cmd).Render(nil, relocationOutput{Results: results})
7376
}
7477

7578
// cpCmd represents the cp command
@@ -82,4 +85,5 @@ var cpCmd = &cobra.Command{
8285

8386
func init() {
8487
RootCmd.AddCommand(cpCmd)
88+
enableStructuredOutput(cpCmd)
8589
}

cmd/cp_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
package cmd
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57
"io"
68
"os"
9+
"path"
710
"strings"
811
"testing"
12+
"time"
913

14+
"github.com/dropbox/dbxcli/internal/output"
1015
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox"
1116
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
17+
"github.com/spf13/cobra"
1218
)
1319

1420
func stubFilesClient(t *testing.T, client files.Client) {
@@ -194,3 +200,122 @@ func TestCpCopyErrorIncludesAPIError(t *testing.T) {
194200
t.Errorf("stderr still contains formatted relocation arg: %q", stderr)
195201
}
196202
}
203+
204+
func TestCpJSONOutputsRelocationResults(t *testing.T) {
205+
stubFilesClient(t, &mockFilesClient{
206+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
207+
return nil, fmt.Errorf("path/not_found/")
208+
},
209+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
210+
metadata := files.NewFileMetadata("file-copy.txt", "id:file-copy", time.Time{}, time.Time{}, "rev-copy", 42)
211+
metadata.PathDisplay = arg.ToPath
212+
metadata.PathLower = strings.ToLower(arg.ToPath)
213+
return files.NewRelocationResult(metadata), nil
214+
},
215+
})
216+
217+
var stdout bytes.Buffer
218+
cmd := newRelocationTestCommand(&stdout, nil)
219+
220+
if err := cp(cmd, []string{"/src/file.txt", "/dest/file-copy.txt"}); err != nil {
221+
t.Fatalf("cp error: %v", err)
222+
}
223+
224+
got := decodeRelocationOutput(t, stdout.Bytes())
225+
if len(got.Results) != 1 {
226+
t.Fatalf("results = %d, want 1", len(got.Results))
227+
}
228+
result := got.Results[0]
229+
if result.Input.FromPath != "/src/file.txt" || result.Input.ToPath != "/dest/file-copy.txt" {
230+
t.Fatalf("input = %#v, want source and destination", result.Input)
231+
}
232+
if result.Result.Type != "file" || result.Result.PathDisplay != "/dest/file-copy.txt" {
233+
t.Fatalf("result = %#v, want copied file metadata", result.Result)
234+
}
235+
if result.Result.Size == nil || *result.Result.Size != 42 {
236+
t.Fatalf("size = %#v, want 42", result.Result.Size)
237+
}
238+
}
239+
240+
func TestCpJSONMultipleSourcesOutputsMultipleResults(t *testing.T) {
241+
stubFilesClient(t, &mockFilesClient{
242+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
243+
name := path.Base(arg.ToPath)
244+
metadata := files.NewFileMetadata(name, "id:"+name, time.Time{}, time.Time{}, "rev", 1)
245+
metadata.PathDisplay = arg.ToPath
246+
metadata.PathLower = strings.ToLower(arg.ToPath)
247+
return files.NewRelocationResult(metadata), nil
248+
},
249+
})
250+
251+
var stdout bytes.Buffer
252+
cmd := newRelocationTestCommand(&stdout, nil)
253+
254+
if err := cp(cmd, []string{"/src/a.txt", "/src/b.txt", "/dest"}); err != nil {
255+
t.Fatalf("cp error: %v", err)
256+
}
257+
258+
got := decodeRelocationOutput(t, stdout.Bytes())
259+
if len(got.Results) != 2 {
260+
t.Fatalf("results = %d, want 2", len(got.Results))
261+
}
262+
if got.Results[0].Input.ToPath != "/dest/a.txt" || got.Results[1].Input.ToPath != "/dest/b.txt" {
263+
t.Fatalf("results = %#v, want folder destinations", got.Results)
264+
}
265+
}
266+
267+
func TestCpJSONErrorUsesCommandStderr(t *testing.T) {
268+
stubFilesClient(t, &mockFilesClient{
269+
getMetadataFn: func(arg *files.GetMetadataArg) (files.IsMetadata, error) {
270+
return nil, fmt.Errorf("path/not_found/")
271+
},
272+
copyV2Fn: func(arg *files.RelocationArg) (*files.RelocationResult, error) {
273+
return nil, fmt.Errorf("path/malformed_path/")
274+
},
275+
})
276+
277+
var stdout bytes.Buffer
278+
var stderr bytes.Buffer
279+
cmd := newRelocationTestCommand(&stdout, &stderr)
280+
281+
err := cp(cmd, []string{"/src/file.txt", "/dest/file.txt"})
282+
if err == nil {
283+
t.Fatal("expected cp error")
284+
}
285+
if stdout.String() != "" {
286+
t.Fatalf("stdout = %q, want empty", stdout.String())
287+
}
288+
if !strings.Contains(stderr.String(), `copy "/src/file.txt" to "/dest/file.txt": path/malformed_path/`) {
289+
t.Fatalf("stderr = %q, want copy API error", stderr.String())
290+
}
291+
}
292+
293+
func TestCpCommandSupportsStructuredOutput(t *testing.T) {
294+
if !commandSupportsStructuredOutput(cpCmd) {
295+
t.Fatal("cp should support structured output")
296+
}
297+
}
298+
299+
func newRelocationTestCommand(stdout, stderr *bytes.Buffer) *cobra.Command {
300+
cmd := &cobra.Command{}
301+
cmd.Flags().String(outputFlag, string(output.FormatText), "")
302+
if err := cmd.Flags().Set(outputFlag, string(output.FormatJSON)); err != nil {
303+
panic(err)
304+
}
305+
if stdout != nil {
306+
cmd.SetOut(stdout)
307+
}
308+
if stderr != nil {
309+
cmd.SetErr(stderr)
310+
}
311+
return cmd
312+
}
313+
314+
func decodeRelocationOutput(t *testing.T, data []byte) relocationOutput {
315+
t.Helper()
316+
var got relocationOutput
317+
if err := json.Unmarshal(data, &got); err != nil {
318+
t.Fatalf("decode JSON output: %v\noutput: %s", err, string(data))
319+
}
320+
return got
321+
}

cmd/mv.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package cmd
1616

1717
import (
1818
"fmt"
19-
"os"
2019
"strings"
2120

2221
"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
@@ -39,6 +38,7 @@ func mv(cmd *cobra.Command, args []string) error {
3938

4039
var mvErrors []error
4140
var relocationArgs []*files.RelocationArg
41+
var results []relocationResult
4242

4343
dbx := filesNewFunc(config)
4444
destIsFolder := len(argsToMove) > 1 || strings.HasSuffix(destination, "/") || isRemoteFolder(dbx, destination)
@@ -54,20 +54,23 @@ func mv(cmd *cobra.Command, args []string) error {
5454
}
5555

5656
for _, arg := range relocationArgs {
57-
if _, err := dbx.MoveV2(arg); err != nil {
57+
res, err := dbx.MoveV2(arg)
58+
if err != nil {
5859
moveError := fmt.Errorf("move %q to %q: %v", arg.FromPath, arg.ToPath, err)
5960
mvErrors = append(mvErrors, moveError)
61+
continue
6062
}
63+
results = append(results, newRelocationResult(arg, res))
6164
}
6265

6366
if len(mvErrors) > 0 {
6467
for _, mvError := range mvErrors {
65-
_, _ = fmt.Fprintf(os.Stderr, "%v\n", mvError)
68+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%v\n", mvError)
6669
}
6770
return fmt.Errorf("mv: %d error(s)", len(mvErrors))
6871
}
6972

70-
return nil
73+
return commandOutput(cmd).Render(nil, relocationOutput{Results: results})
7174
}
7275

7376
// mvCmd represents the mv command
@@ -79,4 +82,5 @@ var mvCmd = &cobra.Command{
7982

8083
func init() {
8184
RootCmd.AddCommand(mvCmd)
85+
enableStructuredOutput(mvCmd)
8286
}

0 commit comments

Comments
 (0)