diff --git a/CHANGELOG.md b/CHANGELOG.md
index 154a810ad..ed8cec41f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## 0.35.1 - Unreleased
+- Gmail: add opt-in exact-or-lower-bound match counts to thread and message searches without treating Gmail's result estimate as exact. (#983, #984) — thanks @chrischall.
- Gmail: add draft-only reply, reply-all, and forward workflows with shared send-side composition, no-send compatibility, and address-aware recipient validation. (#977) — thanks @malob.
- Safety: allow custom baked profiles to lock boolean CLI flags against command-line, environment, and config overrides without echoing the locked value in override errors. (#976) — thanks @ronny-rentner.
- Auth: offer one-time re-authorization for expired or revoked stored OAuth refresh tokens after interactive confirmation, while preserving non-interactive recovery guidance. (#973) — thanks @inamiy.
diff --git a/docs/commands/gog-gmail-messages-search.md b/docs/commands/gog-gmail-messages-search.md
index 1e8897dd8..8bbdf8851 100644
--- a/docs/commands/gog-gmail-messages-search.md
+++ b/docs/commands/gog-gmail-messages-search.md
@@ -24,6 +24,7 @@ gog gmail (mail,email) messages (message,msg,msgs) search (find,query,ls,list) <
| `--body-format` | `string` | text | Body format preference when --include-body is set: text or html |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
+| `--count` | `bool` | | Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
diff --git a/docs/commands/gog-gmail-search.md b/docs/commands/gog-gmail-search.md
index 9608ac800..815ec873b 100644
--- a/docs/commands/gog-gmail-search.md
+++ b/docs/commands/gog-gmail-search.md
@@ -23,6 +23,7 @@ gog gmail (mail,email) search (find,query,ls,list) ... [flags]
| `--all`
`--all-pages`
`--allpages` | `bool` | | Fetch all pages |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
+| `--count` | `bool` | | Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go
index e5ad5122f..4425670b5 100644
--- a/internal/cmd/gmail_messages.go
+++ b/internal/cmd/gmail_messages.go
@@ -33,6 +33,7 @@ type GmailMessagesSearchCmd struct {
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
+ Count bool `name:"count" help:"Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only"`
Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"`
Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
IncludeBody bool `name:"include-body" help:"Include decoded message body (JSON is full; text output truncates only unusually large bodies)"`
@@ -80,14 +81,33 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
return err
}
+ var matchCount gmailMatchCount
+ countReported := false
+ if c.Count {
+ matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, c.Page, len(messages), func() (gmailMatchCount, error) {
+ return countGmailMessageMatches(ctx, svc, query)
+ })
+ if err != nil {
+ return err
+ }
+ }
+
if len(messages) == 0 {
if outfmt.IsJSON(ctx) {
- return writePagedJSONResult(ctx, map[string]any{
+ payload := map[string]any{
"messages": []messageItem{},
"nextPageToken": nextPageToken,
- }, 0, c.FailEmpty)
+ }
+ if countReported {
+ matchCount.apply(payload)
+ }
+ return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
+ }
+ if countReported {
+ printGmailMatchCount(u, 0, matchCount)
+ } else {
+ u.Err().Println("No results")
}
- u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
}
@@ -107,10 +127,14 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
}
if outfmt.IsJSON(ctx) {
- return writePagedJSONResult(ctx, map[string]any{
+ payload := map[string]any{
"messages": items,
"nextPageToken": nextPageToken,
- }, len(items), c.FailEmpty)
+ }
+ if countReported {
+ matchCount.apply(payload)
+ }
+ return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}
if len(items) == 0 {
@@ -126,6 +150,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro
); err != nil {
return err
}
+ // stderr, so the table on stdout stays parseable.
+ if countReported {
+ printGmailMatchCount(u, len(items), matchCount)
+ }
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go
index 5f19e0697..46a33fd6a 100644
--- a/internal/cmd/gmail_search.go
+++ b/internal/cmd/gmail_search.go
@@ -19,6 +19,7 @@ type GmailSearchCmd struct {
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
+ Count bool `name:"count" help:"Report the whole-query count as totalMatches (exact) or totalMatchesAtLeast (lower bound); free with --all unless --page is set; unavailable with --results-only"`
Oldest bool `name:"oldest" help:"Show first message date instead of last"`
Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: GOG_TIMEZONE, config, then local"`
Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"`
@@ -65,14 +66,33 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
return err
}
+ var matchCount gmailMatchCount
+ countReported := false
+ if c.Count {
+ matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, c.Page, len(threads), func() (gmailMatchCount, error) {
+ return countGmailThreadMatches(ctx, svc, query)
+ })
+ if err != nil {
+ return err
+ }
+ }
+
if len(threads) == 0 {
if outfmt.IsJSON(ctx) {
- return writePagedJSONResult(ctx, map[string]any{
+ payload := map[string]any{
"threads": []threadItem{},
"nextPageToken": nextPageToken,
- }, 0, c.FailEmpty)
+ }
+ if countReported {
+ matchCount.apply(payload)
+ }
+ return writePagedJSONResult(ctx, payload, 0, c.FailEmpty)
+ }
+ if countReported {
+ printGmailMatchCount(u, 0, matchCount)
+ } else {
+ u.Err().Println("No results")
}
- u.Err().Println("No results")
return failEmptyExit(c.FailEmpty)
}
@@ -92,10 +112,14 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
}
if outfmt.IsJSON(ctx) {
- return writePagedJSONResult(ctx, map[string]any{
+ payload := map[string]any{
"threads": items,
"nextPageToken": nextPageToken,
- }, len(items), c.FailEmpty)
+ }
+ if countReported {
+ matchCount.apply(payload)
+ }
+ return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty)
}
if len(items) == 0 {
@@ -106,6 +130,10 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
if err := outfmt.WriteTable(ctx, stdoutWriter(ctx), items, gmailThreadColumns()); err != nil {
return err
}
+ // stderr, so the table on stdout stays parseable.
+ if countReported {
+ printGmailMatchCount(u, len(items), matchCount)
+ }
printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages")
return nil
}
diff --git a/internal/cmd/gmail_search_count.go b/internal/cmd/gmail_search_count.go
new file mode 100644
index 000000000..cb56e95b5
--- /dev/null
+++ b/internal/cmd/gmail_search_count.go
@@ -0,0 +1,125 @@
+package cmd
+
+import (
+ "context"
+ "fmt"
+
+ "google.golang.org/api/gmail/v1"
+
+ "github.com/openclaw/gogcli/internal/ui"
+)
+
+// One maximal page is enough to count all but the broadest result sets, and
+// 500 is the ceiling Gmail's list endpoints accept.
+const gmailCountProbePageSize = 500
+
+// gmailMatchCount is how large a result set really is.
+//
+// Exact means the probe reached the end of the set, so Value is the total.
+// Otherwise the probe filled its page with more behind it and Value is a lower
+// bound — reported as such rather than rounded into a total nobody can trust.
+type gmailMatchCount struct {
+ Value int64
+ Exact bool
+}
+
+// apply writes the count into a JSON payload under the name that matches its
+// certainty, so a consumer never has to guess whether a number is a total.
+func (c gmailMatchCount) apply(payload map[string]any) {
+ if c.Exact {
+ payload["totalMatches"] = c.Value
+ return
+ }
+ payload["totalMatchesAtLeast"] = c.Value
+}
+
+// Deliberately NOT Gmail's resultSizeEstimate, which is the obvious source and
+// saturates: measured against a live mailbox it returned exactly 201 for every
+// non-empty query — from:freshbooks.com (21 real matches), from:housecallpro.com
+// (6), from:thumbtack.com newer_than:30d (3) — and 0 for a query with none. It
+// is a has-results boolean wearing a number's clothes, and it does not vary
+// with maxResults either. Emitting it would let a caller report "3 of ~201"
+// when the truth is 3 of 6. See openclaw/gogcli#983.
+//
+// Counting bare ids costs one extra list call and returns a response of ids
+// alone, and it is exact whenever the set fits a single page — the common case,
+// and always the case for the narrow queries where a wrong count does the most
+// damage.
+func countGmailThreadMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) {
+ opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "")
+ resp, err := applyGmailThreadListOptions(svc.Users.Threads.List("me"), opts).
+ Fields("threads/id,nextPageToken").
+ Context(ctx).
+ Do()
+ if err != nil {
+ return gmailMatchCount{}, err
+ }
+ return gmailMatchCount{Value: int64(len(resp.Threads)), Exact: resp.NextPageToken == ""}, nil
+}
+
+func countGmailMessageMatches(ctx context.Context, svc *gmail.Service, query string) (gmailMatchCount, error) {
+ opts := newGmailSearchRequestOptions(query, gmailCountProbePageSize, "")
+ resp, err := applyGmailMessageListOptions(svc.Users.Messages.List("me"), opts).
+ Fields("messages/id,nextPageToken").
+ Context(ctx).
+ Do()
+ if err != nil {
+ return gmailMatchCount{}, err
+ }
+ return gmailMatchCount{Value: int64(len(resp.Messages)), Exact: resp.NextPageToken == ""}, nil
+}
+
+// The human-facing form of the same fact, on stderr so the table on stdout
+// stays parseable. Says outright when the page is not the whole set: a caller
+// reading only the visible rows is exactly how a partial result gets reported
+// as an absence.
+func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) {
+ if count.Exact {
+ if int64(shown) < count.Value {
+ u.Err().Println(fmt.Sprintf("Showing %d of %d matches.", shown, count.Value))
+ return
+ }
+ u.Err().Println(fmt.Sprintf("%d matches.", count.Value))
+ return
+ }
+ u.Err().Println(fmt.Sprintf("Showing %d of at least %d matches.", shown, count.Value))
+}
+
+// resolveGmailMatchCount decides how --count is answered for one search, and
+// whether it can be answered at all. Three cases, and only one of them is worth
+// an extra request:
+//
+// --results-only The count is an envelope field, and --results-only exists
+// to drop the envelope and emit the bare result array — so
+// the number would be computed and then thrown away. Say so
+// on stderr instead of spending a request in silence.
+// --all When the walk started at the beginning, the items in hand
+// ARE the whole set. A walk started with --page contains only
+// the suffix, so it still needs a whole-query probe.
+// otherwise Probe.
+//
+// The count is always for the WHOLE query, never the remainder after a
+// --page cursor: "how many match this" is the number that stops a caller
+// concluding an absence, and keeping it page-independent means it does not
+// drift while paging through a set.
+func resolveGmailMatchCount(
+ u *ui.UI,
+ resultsOnly, all bool,
+ page string,
+ shown int,
+ probe func() (gmailMatchCount, error),
+) (gmailMatchCount, bool, error) {
+ switch {
+ case resultsOnly:
+ u.Err().Println("--count has no effect with --results-only: the count is an envelope field, and --results-only emits only the result array. Skipping the extra request.")
+ return gmailMatchCount{}, false, nil
+ case all && page == "":
+ return gmailMatchCount{Value: int64(shown), Exact: true}, true, nil
+ default:
+ count, err := probe()
+ if err != nil {
+ return gmailMatchCount{}, false, err
+ }
+ return count, true, nil
+ }
+}
diff --git a/internal/cmd/gmail_search_count_test.go b/internal/cmd/gmail_search_count_test.go
new file mode 100644
index 000000000..0b5ea9b96
--- /dev/null
+++ b/internal/cmd/gmail_search_count_test.go
@@ -0,0 +1,437 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/openclaw/gogcli/internal/ui"
+)
+
+// countProbeCapture records what the count probe actually asked Gmail for, so a
+// test can assert the probe is a cheap ids-only call and not a second full
+// fetch.
+type countProbeCapture struct {
+ fields string
+ maxResults string
+ query string
+ calls int
+}
+
+// gmailCountTestHandler serves a first page of `pageIDs` plus a count probe
+// returning `totalIDs` ids. The probe is told apart from the listing by its
+// `fields` selector, which is the only thing the count path sets.
+func gmailCountTestHandler(t *testing.T, capture *countProbeCapture, resource string, pageIDs, totalIDs []string, probeNextPage string) http.HandlerFunc {
+ t.Helper()
+ return func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ q := r.URL.Query()
+ w.Header().Set("Content-Type", "application/json")
+
+ switch {
+ case strings.Contains(path, "/users/me/labels"):
+ _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}})
+
+ case strings.HasSuffix(path, "/users/me/"+resource) && strings.Contains(q.Get("fields"), "/id"):
+ capture.calls++
+ capture.fields = q.Get("fields")
+ capture.maxResults = q.Get("maxResults")
+ capture.query = q.Get("q")
+ items := make([]map[string]any, 0, len(totalIDs))
+ for _, id := range totalIDs {
+ items = append(items, map[string]any{"id": id})
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ resource: items, "nextPageToken": probeNextPage,
+ })
+
+ case strings.HasSuffix(path, "/users/me/"+resource):
+ items := make([]map[string]any, 0, len(pageIDs))
+ for _, id := range pageIDs {
+ items = append(items, map[string]any{"id": id, "threadId": id})
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{resource: items, "nextPageToken": "PAGE2"})
+
+ case strings.Contains(path, "/users/me/threads/"), strings.Contains(path, "/users/me/messages/"):
+ id := path[strings.LastIndex(path, "/")+1:]
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": id, "messages": []map[string]any{{
+ "id": id, "threadId": id, "internalDate": "1760000000000",
+ "payload": map[string]any{"headers": []map[string]any{
+ {"name": "Subject", "value": "s"}, {"name": "From", "value": "a@b.com"},
+ }},
+ }},
+ })
+
+ default:
+ http.NotFound(w, r)
+ }
+ }
+}
+
+// gmailAllCountTestHandler serves a single complete page (no nextPageToken) and
+// records any count probe, which under --all must never fire.
+func gmailAllCountTestHandler(t *testing.T, capture *countProbeCapture, ids []string) http.HandlerFunc {
+ t.Helper()
+ return func(w http.ResponseWriter, r *http.Request) {
+ path := r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.Contains(path, "/users/me/labels"):
+ _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}})
+ case strings.HasSuffix(path, "/users/me/threads") && strings.Contains(r.URL.Query().Get("fields"), "/id"):
+ capture.calls++
+ _ = json.NewEncoder(w).Encode(map[string]any{"threads": make([]map[string]any, 99)})
+ case strings.HasSuffix(path, "/users/me/threads"):
+ items := make([]map[string]any, 0, len(ids))
+ for _, id := range ids {
+ items = append(items, map[string]any{"id": id, "threadId": id})
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"threads": items})
+ case strings.Contains(path, "/users/me/threads/"):
+ id := path[strings.LastIndex(path, "/")+1:]
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": id, "messages": []map[string]any{{
+ "id": id, "threadId": id, "internalDate": "1760000000000",
+ "payload": map[string]any{"headers": []map[string]any{{"name": "Subject", "value": "s"}}},
+ }},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }
+}
+
+func TestGmailSearch_Count_ExactWhenSetFitsOnePage(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1", "t2", "t3"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ TotalMatches *int64 `json:"totalMatches"`
+ TotalMatchesAtLeast *int64 `json:"totalMatchesAtLeast"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 3 {
+ t.Fatalf("totalMatches = %v, want 3", parsed.TotalMatches)
+ }
+ if parsed.TotalMatchesAtLeast != nil {
+ t.Fatalf("an exact count must not also report a lower bound: %v", *parsed.TotalMatchesAtLeast)
+ }
+}
+
+func TestGmailSearch_Count_LowerBoundWhenProbePageFills(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1", "t2"}, "MORE"))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ TotalMatches *int64 `json:"totalMatches"`
+ TotalMatchesAtLeast *int64 `json:"totalMatchesAtLeast"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatchesAtLeast == nil || *parsed.TotalMatchesAtLeast != 2 {
+ t.Fatalf("totalMatchesAtLeast = %v, want 2", parsed.TotalMatchesAtLeast)
+ }
+ if parsed.TotalMatches != nil {
+ t.Fatalf("a saturated probe must NOT be reported as an exact total: %v", *parsed.TotalMatches)
+ }
+}
+
+func TestGmailSearch_Count_ProbeIsIdsOnlyAndUsesTheSameQuery(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "from:x@y.com", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+ if capture.calls != 1 {
+ t.Fatalf("count probe ran %d times, want exactly 1", capture.calls)
+ }
+ if capture.fields != "threads/id,nextPageToken" {
+ t.Fatalf("probe fields = %q, want ids only", capture.fields)
+ }
+ if capture.maxResults != "500" {
+ t.Fatalf("probe maxResults = %q, want 500", capture.maxResults)
+ }
+ if capture.query != "from:x@y.com" {
+ t.Fatalf("probe query = %q, want the search's own query", capture.query)
+ }
+}
+
+func TestGmailSearch_Count_AbsentWithoutTheFlag(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1", "t2"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+ if strings.Contains(result.stdout, "totalMatches") {
+ t.Fatalf("count must be opt-in, got: %s", result.stdout)
+ }
+ if capture.calls != 0 {
+ t.Fatalf("count probe must not run without --count, ran %d times", capture.calls)
+ }
+}
+
+func TestGmailMessagesSearch_Count_Exact(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "messages",
+ []string{"m1"}, []string{"m1", "m2", "m3", "m4"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "invoice", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ TotalMatches *int64 `json:"totalMatches"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 4 {
+ t.Fatalf("totalMatches = %v, want 4", parsed.TotalMatches)
+ }
+ if capture.fields != "messages/id,nextPageToken" {
+ t.Fatalf("probe fields = %q, want message ids only", capture.fields)
+ }
+}
+
+func TestGmailSearch_Count_ReportsZeroForNoMatches(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if strings.Contains(r.URL.Path, "/users/me/labels") {
+ _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"threads": []map[string]any{}})
+ }))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "zzznope", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ TotalMatches *int64 `json:"totalMatches"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 0 {
+ t.Fatalf("totalMatches = %v, want 0", parsed.TotalMatches)
+ }
+}
+
+func TestGmailSearch_Count_EmptyPageStillCountsWholeQuery(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ resource string
+ args []string
+ }{
+ {
+ name: "threads",
+ resource: "threads",
+ args: []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--page", "PAGE2", "--count"},
+ },
+ {
+ name: "messages",
+ resource: "messages",
+ args: []string{"--json", "--account", "a@b.com", "gmail", "messages", "search", "invoice", "--page", "PAGE2", "--count"},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, tc.resource,
+ nil, []string{"m1", "m2", "m3"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t, tc.args, newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ TotalMatches *int64 `json:"totalMatches"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 3 {
+ t.Fatalf("totalMatches = %v, want whole-query count 3", parsed.TotalMatches)
+ }
+ if capture.calls != 1 {
+ t.Fatalf("empty displayed page still needs one whole-query probe; got %d", capture.calls)
+ }
+ })
+ }
+}
+
+func TestGmailSearch_Count_TextOutputNamesTheWholeSetOnStderr(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1", "t2", "t3"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--plain", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+ if !strings.Contains(result.stderr, "Showing 1 of 3 matches.") {
+ t.Fatalf("stderr should say the page is partial, got: %q", result.stderr)
+ }
+ // stdout must stay parseable.
+ if strings.Contains(result.stdout, "matches.") {
+ t.Fatalf("count hint leaked onto stdout: %q", result.stdout)
+ }
+}
+
+func TestPrintGmailMatchCount_Wording(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ shown int
+ count gmailMatchCount
+ want string
+ }{
+ {"partial exact", 3, gmailMatchCount{Value: 21, Exact: true}, "Showing 3 of 21 matches."},
+ {"complete exact", 6, gmailMatchCount{Value: 6, Exact: true}, "6 matches."},
+ {"lower bound", 3, gmailMatchCount{Value: 500}, "Showing 3 of at least 500 matches."},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ u, err := ui.New(ui.Options{Stdout: &stdout, Stderr: &stderr, Color: "never"})
+ if err != nil {
+ t.Fatalf("ui.New: %v", err)
+ }
+ printGmailMatchCount(u, tc.shown, tc.count)
+ if !strings.Contains(stderr.String(), tc.want) {
+ t.Fatalf("got %q, want it to contain %q", stderr.String(), tc.want)
+ }
+ })
+ }
+}
+
+func TestGmailSearch_Count_SkipsProbeWithAll(t *testing.T) {
+ capture := &countProbeCapture{}
+ // The probe would report 99 if it ran; --all must instead report what the
+ // walk actually collected, so a probe result cannot masquerade as the total.
+ srv := httptest.NewServer(gmailAllCountTestHandler(t, capture, []string{"t1", "t2"}))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--all", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ Threads []struct{} `json:"threads"`
+ TotalMatches *int64 `json:"totalMatches"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 2 {
+ t.Fatalf("totalMatches = %v, want 2 (what --all collected)", parsed.TotalMatches)
+ }
+ if capture.calls != 0 {
+ t.Fatalf("--all already has the whole set; probe must not run, ran %d times", capture.calls)
+ }
+}
+
+func TestGmailSearch_Count_AllFromPageStillProbesWholeQuery(t *testing.T) {
+ capture := &countProbeCapture{}
+ // The --all walk starts at PAGE2 and returns only two remaining threads.
+ // The separate no-cursor probe returns 99, which is the whole-query count.
+ srv := httptest.NewServer(gmailAllCountTestHandler(t, capture, []string{"t1", "t2"}))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--account", "a@b.com", "gmail", "search", "invoice", "--all", "--page", "PAGE2", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+
+ var parsed struct {
+ Threads []struct{} `json:"threads"`
+ TotalMatches *int64 `json:"totalMatches"`
+ }
+ if err := json.Unmarshal([]byte(result.stdout), &parsed); err != nil {
+ t.Fatalf("decode: %v (%s)", err, result.stdout)
+ }
+ if len(parsed.Threads) != 2 {
+ t.Fatalf("threads = %d, want remaining page suffix of 2", len(parsed.Threads))
+ }
+ if parsed.TotalMatches == nil || *parsed.TotalMatches != 99 {
+ t.Fatalf("totalMatches = %v, want whole-query count 99", parsed.TotalMatches)
+ }
+ if capture.calls != 1 {
+ t.Fatalf("--all from a cursor still needs one whole-query probe; got %d", capture.calls)
+ }
+}
+
+func TestGmailSearch_Count_SkipsProbeWithResultsOnly(t *testing.T) {
+ capture := &countProbeCapture{}
+ srv := httptest.NewServer(gmailCountTestHandler(t, capture, "threads",
+ []string{"t1"}, []string{"t1", "t2", "t3"}, ""))
+ defer srv.Close()
+
+ result := executeWithGmailTestService(t,
+ []string{"--json", "--results-only", "--account", "a@b.com", "gmail", "search", "invoice", "--max", "1", "--count"},
+ newGmailServiceFromServer(t, srv))
+ if result.err != nil {
+ t.Fatalf("Execute: %v\nstderr=%q", result.err, result.stderr)
+ }
+ // --results-only unwraps to the bare array, so the count could never be
+ // reported. Spending the request anyway is pure waste.
+ if capture.calls != 0 {
+ t.Fatalf("probe must not run under --results-only, ran %d times", capture.calls)
+ }
+ if !strings.Contains(result.stderr, "--count has no effect with --results-only") {
+ t.Fatalf("the caller must be told, not silently ignored; stderr=%q", result.stderr)
+ }
+}