diff --git a/cmd/format.go b/cmd/format.go index 217f39d..2cc775c 100644 --- a/cmd/format.go +++ b/cmd/format.go @@ -29,6 +29,7 @@ type listOptions struct { timeFormat string sortBy string reverse bool + limit uint64 } func formatTime(t time.Time, opts listOptions) string { diff --git a/cmd/ls.go b/cmd/ls.go index cb859f9..59a3f2a 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -28,12 +28,14 @@ import ( ) const deletedItemFormatString = "<<%s>>" +const maxListFolderLimit = uint64(^uint32(0)) type lsInput struct { Path string `json:"path"` Recursive bool `json:"recursive"` IncludeDeleted bool `json:"include_deleted"` OnlyDeleted bool `json:"only_deleted"` + Limit uint64 `json:"limit,omitempty"` Long bool `json:"long"` Sort string `json:"sort,omitempty"` Reverse bool `json:"reverse"` @@ -108,12 +110,14 @@ func parseLsOptions(cmd *cobra.Command) listOptions { timeFormat, _ := cmd.Flags().GetString("time-format") sortBy, _ := cmd.Flags().GetString("sort") reverse, _ := cmd.Flags().GetBool("reverse") + limit, _ := cmd.Flags().GetUint64("limit") return listOptions{ long: long, timeField: timeField, timeFormat: timeFormat, sortBy: sortBy, reverse: reverse, + limit: limit, } } @@ -132,6 +136,12 @@ func ls(cmd *cobra.Command, args []string) (err error) { onlyDeleted, _ := cmd.Flags().GetBool("only-deleted") arg.IncludeDeleted = arg.IncludeDeleted || onlyDeleted opts := parseLsOptions(cmd) + if opts.limit > 0 { + if opts.limit > maxListFolderLimit { + return invalidArgumentsErrorWithDetails("`ls --limit` is too large", flagErrorDetails("limit")) + } + arg.Limit = uint32(opts.limit) + } dbx := filesNewFunc(config) @@ -164,9 +174,9 @@ func ls(cmd *cobra.Command, args []string) (err error) { metaRes, _ = getFileMetadata(dbx, path) entries = []files.IsMetadata{metaRes} } else { - entries = res.Entries + entries = appendMetadataWithLimit(entries, res.Entries, opts.limit) - for res.HasMore { + for res.HasMore && !metadataLimitReached(entries, opts.limit) { arg := files.NewListFolderContinueArg(res.Cursor) res, err = dbx.ListFolderContinue(arg) @@ -174,7 +184,7 @@ func ls(cmd *cobra.Command, args []string) (err error) { return err } - entries = append(entries, res.Entries...) + entries = appendMetadataWithLimit(entries, res.Entries, opts.limit) } } @@ -187,6 +197,20 @@ func ls(cmd *cobra.Command, args []string) (err error) { return renderLsOutput(cmd, path, arg, onlyDeleted, opts, entries) } +func appendMetadataWithLimit(entries []files.IsMetadata, next []files.IsMetadata, limit uint64) []files.IsMetadata { + for _, entry := range next { + if metadataLimitReached(entries, limit) { + break + } + entries = append(entries, entry) + } + return entries +} + +func metadataLimitReached(entries []files.IsMetadata, limit uint64) bool { + return limit > 0 && uint64(len(entries)) >= limit +} + func prepareLsEntries(dbx files.Client, entries []files.IsMetadata, onlyDeleted bool) ([]files.IsMetadata, error) { var filtered []files.IsMetadata for _, entry := range entries { @@ -251,6 +275,7 @@ func newLsInput(path string, arg *files.ListFolderArg, onlyDeleted bool, opts li Recursive: arg.Recursive, IncludeDeleted: arg.IncludeDeleted, OnlyDeleted: onlyDeleted, + Limit: opts.limit, Long: opts.long, Sort: opts.sortBy, Reverse: opts.reverse, @@ -361,6 +386,7 @@ func init() { lsCmd.Flags().BoolP("recurse", "R", false, "Recursively list all subfolders") lsCmd.Flags().BoolP("include-deleted", "d", false, "Include deleted files") lsCmd.Flags().BoolP("only-deleted", "D", false, "Only show deleted files") + lsCmd.Flags().Uint64("limit", 0, "Maximum number of entries to return") lsCmd.Flags().String("sort", "", "Sort by: name, size, time, type") lsCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order") lsCmd.Flags().String("time", "server", "Time field: server, client") diff --git a/cmd/ls_test.go b/cmd/ls_test.go index 91122e0..7303c0f 100644 --- a/cmd/ls_test.go +++ b/cmd/ls_test.go @@ -183,6 +183,7 @@ func TestLsJSONListsResultsAndInput(t *testing.T) { setLsOutputJSON(t, cmd) setLsFlag(t, cmd, "recurse", "true") setLsFlag(t, cmd, "include-deleted", "true") + setLsFlag(t, cmd, "limit", "50") setLsFlag(t, cmd, "long", "true") setLsFlag(t, cmd, "sort", "name") setLsFlag(t, cmd, "reverse", "true") @@ -232,6 +233,9 @@ func TestLsJSONListsResultsAndInput(t *testing.T) { if !listArg.Recursive || !listArg.IncludeDeleted { t.Fatalf("ListFolder flags = recursive:%v include_deleted:%v, want true/true", listArg.Recursive, listArg.IncludeDeleted) } + if listArg.Limit != 50 { + t.Fatalf("ListFolder limit = %d, want 50", listArg.Limit) + } got := decodeLsOutput(t, stdout) if got.Input.Path != "/" { @@ -243,6 +247,9 @@ func TestLsJSONListsResultsAndInput(t *testing.T) { if got.Input.Sort != "name" || !got.Input.Reverse || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" { t.Fatalf("input options = %+v, want sort/name reverse/client/rfc3339", got.Input) } + if got.Input.Limit != 50 { + t.Fatalf("input limit = %d, want 50", got.Input.Limit) + } if len(got.Results) != 2 { t.Fatalf("results = %d, want 2", len(got.Results)) } @@ -258,6 +265,68 @@ func TestLsJSONListsResultsAndInput(t *testing.T) { } } +func TestLsLimitStopsPaginationAndTruncatesOutput(t *testing.T) { + cmd, stdout := testLsCmd(t) + setLsOutputJSON(t, cmd) + setLsFlag(t, cmd, "limit", "2") + + var listArg *files.ListFolderArg + mock := &mockFilesClient{ + listFolderFn: func(arg *files.ListFolderArg) (*files.ListFolderResult, error) { + listArg = arg + return &files.ListFolderResult{ + Entries: []files.IsMetadata{ + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/one.txt", PathLower: "/one.txt"}, Id: "id:one", Rev: "rev-one"}, + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/two.txt", PathLower: "/two.txt"}, Id: "id:two", Rev: "rev-two"}, + &files.FileMetadata{Metadata: files.Metadata{PathDisplay: "/three.txt", PathLower: "/three.txt"}, Id: "id:three", Rev: "rev-three"}, + }, + HasMore: true, + Cursor: "cursor", + }, nil + }, + listFolderContinueFn: func(arg *files.ListFolderContinueArg) (*files.ListFolderResult, error) { + t.Fatalf("ListFolderContinue should not be called after reaching limit: %v", arg) + return nil, nil + }, + } + stubFilesClient(t, mock) + + if err := ls(cmd, nil); err != nil { + t.Fatalf("ls error: %v", err) + } + if listArg == nil { + t.Fatal("ListFolder was not called") + } + if listArg.Limit != 2 { + t.Fatalf("ListFolder limit = %d, want 2", listArg.Limit) + } + + got := decodeLsOutput(t, stdout) + if got.Input.Limit != 2 { + t.Fatalf("input limit = %d, want 2", got.Input.Limit) + } + if len(got.Results) != 2 { + t.Fatalf("results = %d, want 2", len(got.Results)) + } + for _, result := range got.Results { + if result.Result.PathDisplay == "/three.txt" { + t.Fatalf("results include truncated entry: %#v", got.Results) + } + } +} + +func TestLsLimitRejectsUint32Overflow(t *testing.T) { + cmd, stdout := testLsCmd(t) + setLsFlag(t, cmd, "limit", "4294967296") + + if err := ls(cmd, nil); err == nil { + t.Fatal("expected ls limit overflow error") + } + if got := stdout.String(); got != "" { + t.Fatalf("stdout = %q, want empty output on validation error", got) + } +} + func TestLsJSONFilePathUsesMetadata(t *testing.T) { cmd, stdout := testLsCmd(t) setLsOutputJSON(t, cmd) @@ -467,6 +536,7 @@ func testLsCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { cmd.Flags().BoolP("recurse", "R", false, "") cmd.Flags().BoolP("include-deleted", "d", false, "") cmd.Flags().BoolP("only-deleted", "D", false, "") + cmd.Flags().Uint64("limit", 0, "") cmd.Flags().String("sort", "", "") cmd.Flags().BoolP("reverse", "r", false, "") cmd.Flags().String("time", "server", "") diff --git a/cmd/revs.go b/cmd/revs.go index cecb6f1..ebfe8a7 100644 --- a/cmd/revs.go +++ b/cmd/revs.go @@ -26,6 +26,7 @@ import ( type revsInput struct { Path string `json:"path"` + Limit uint64 `json:"limit,omitempty"` Long bool `json:"long"` Time string `json:"time,omitempty"` TimeFormat string `json:"time_format,omitempty"` @@ -44,6 +45,10 @@ func revs(cmd *cobra.Command, args []string) (err error) { } arg := files.NewListRevisionsArg(path) + limit, _ := cmd.Flags().GetUint64("limit") + if limit > 0 { + arg.Limit = limit + } dbx := filesNewFunc(config) res, err := dbx.ListRevisions(arg) @@ -53,10 +58,10 @@ func revs(cmd *cobra.Command, args []string) (err error) { opts := parseLsOptions(cmd) - return renderRevisionsOutput(cmd, path, res.Entries, opts) + return renderRevisionsOutput(cmd, path, limit, res.Entries, opts) } -func renderRevisionsOutput(cmd *cobra.Command, path string, entries []*files.FileMetadata, opts listOptions) error { +func renderRevisionsOutput(cmd *cobra.Command, path string, limit uint64, entries []*files.FileMetadata, opts listOptions) error { out := commandOutput(cmd) if commandOutputFormat(cmd) != output.FormatJSON { return out.RenderText(func(w io.Writer) error { @@ -64,7 +69,7 @@ func renderRevisionsOutput(cmd *cobra.Command, path string, entries []*files.Fil }) } - input := newRevsInput(path, opts) + input := newRevsInput(path, limit, opts) metadata, err := jsonMetadataListFromRevisions(entries) if err != nil { return err @@ -73,9 +78,10 @@ func renderRevisionsOutput(cmd *cobra.Command, path string, entries []*files.Fil return renderJSONOperationOutput(cmd, input, results) } -func newRevsInput(path string, opts listOptions) revsInput { +func newRevsInput(path string, limit uint64, opts listOptions) revsInput { return revsInput{ Path: path, + Limit: limit, Long: opts.long, Time: opts.timeField, TimeFormat: opts.timeFormat, @@ -125,6 +131,7 @@ func init() { RootCmd.AddCommand(revsCmd) revsCmd.Flags().BoolP("long", "l", false, "Long listing") + revsCmd.Flags().Uint64("limit", 0, "Maximum number of revisions to return") revsCmd.Flags().String("time", "server", "Time field: server, client") revsCmd.Flags().String("time-format", "", "Time format: short (2006-01-02 15:04), rfc3339") enableStructuredOutput(revsCmd) diff --git a/cmd/revs_test.go b/cmd/revs_test.go index 725b37d..c56c865 100644 --- a/cmd/revs_test.go +++ b/cmd/revs_test.go @@ -73,10 +73,12 @@ func TestRenderRevisionResultsLongModeUsesTimeOptions(t *testing.T) { func TestRevsUsesListRevisionsAndCommandOutput(t *testing.T) { cmd, stdout := testRevsCmd() var gotPath string + var gotLimit uint64 stubFilesClient(t, &mockFilesClient{ listRevisionsFn: func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) { gotPath = arg.Path + gotLimit = arg.Limit return files.NewListRevisionsResult(false, []*files.FileMetadata{ {Rev: "rev-c"}, }), nil @@ -90,6 +92,9 @@ func TestRevsUsesListRevisionsAndCommandOutput(t *testing.T) { if gotPath != "/report.pdf" { t.Fatalf("ListRevisions path = %q, want %q", gotPath, "/report.pdf") } + if gotLimit != 10 { + t.Fatalf("ListRevisions limit = %d, want SDK default 10", gotLimit) + } if got, want := stdout.String(), "rev-c\n"; got != want { t.Fatalf("stdout = %q, want %q", got, want) } @@ -99,14 +104,17 @@ func TestRevsJSONOutputsInputAndResults(t *testing.T) { cmd, stdout := testRevsCmd() setRevsOutputJSON(t, cmd) setRevsFlag(t, cmd, "long", "true") + setRevsFlag(t, cmd, "limit", "25") setRevsFlag(t, cmd, "time", "client") setRevsFlag(t, cmd, "time-format", "rfc3339") var gotPath string + var gotLimit uint64 clientModified := time.Date(2026, 6, 22, 10, 0, 0, 0, time.UTC) stubFilesClient(t, &mockFilesClient{ listRevisionsFn: func(arg *files.ListRevisionsArg) (*files.ListRevisionsResult, error) { gotPath = arg.Path + gotLimit = arg.Limit return files.NewListRevisionsResult(false, []*files.FileMetadata{ { Metadata: files.Metadata{ @@ -128,9 +136,12 @@ func TestRevsJSONOutputsInputAndResults(t *testing.T) { if gotPath != "/report.pdf" { t.Fatalf("ListRevisions path = %q, want /report.pdf", gotPath) } + if gotLimit != 25 { + t.Fatalf("ListRevisions limit = %d, want 25", gotLimit) + } got := decodeRevsOutput(t, stdout) - if got.Input.Path != "/report.pdf" || !got.Input.Long || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" { + if got.Input.Path != "/report.pdf" || got.Input.Limit != 25 || !got.Input.Long || got.Input.Time != "client" || got.Input.TimeFormat != "rfc3339" { t.Fatalf("input = %#v, want path/long/time/time_format", got.Input) } if len(got.Results) != 1 { @@ -180,6 +191,7 @@ func testRevsCmd() (*cobra.Command, *bytes.Buffer) { cmd := &cobra.Command{Use: "revs"} cmd.SetOut(&stdout) cmd.Flags().BoolP("long", "l", false, "") + cmd.Flags().Uint64("limit", 0, "") cmd.Flags().String("time", "server", "") cmd.Flags().String("time-format", "", "") cmd.Flags().String(outputFlag, "text", "") diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 9544f0c..ae43cce 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -88,6 +88,7 @@ ], "ls_input": [ "include_deleted", + "limit", "long", "only_deleted", "path", @@ -152,6 +153,7 @@ "revision" ], "revs_input": [ + "limit", "long", "path", "time", diff --git a/docs/commands/dbxcli_ls.md b/docs/commands/dbxcli_ls.md index 14bd496..fed52f1 100644 --- a/docs/commands/dbxcli_ls.md +++ b/docs/commands/dbxcli_ls.md @@ -22,6 +22,7 @@ dbxcli ls [flags] [] ``` -h, --help help for ls -d, --include-deleted Include deleted files + --limit uint Maximum number of entries to return -l, --long Long listing -D, --only-deleted Only show deleted files -R, --recurse Recursively list all subfolders diff --git a/docs/commands/dbxcli_revs.md b/docs/commands/dbxcli_revs.md index 9a2e108..f5b1cf2 100644 --- a/docs/commands/dbxcli_revs.md +++ b/docs/commands/dbxcli_revs.md @@ -12,6 +12,7 @@ dbxcli revs [flags] ``` -h, --help help for revs + --limit uint Maximum number of revisions to return -l, --long Long listing --time string Time field: server, client (default "server") --time-format string Time format: short (2006-01-02 15:04), rfc3339 diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 9544f0c..ae43cce 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -88,6 +88,7 @@ ], "ls_input": [ "include_deleted", + "limit", "long", "only_deleted", "path", @@ -152,6 +153,7 @@ "revision" ], "revs_input": [ + "limit", "long", "path", "time",