From 23cf42f197989bcfa28b7a92faf6beaf017a85d2 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Wed, 12 Aug 2026 23:31:36 -0400 Subject: [PATCH 1/2] feat(gmail): add --count to search and messages search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report how many results a query really has. `gmail search` and `gmail messages search` currently emit items plus nextPageToken, so a caller can tell that more results exist but not how many — and a capped page then gets read as the whole answer. Deliberately NOT Gmail's resultSizeEstimate, which is the obvious source and saturates. Measured on a live account (v0.35.0, 2026-08-12) 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), from:honeybook.com (9) — and 0 for a query with no matches. It does not vary with maxResults either (identical at 1, 10 and 100). Surfacing it would let a caller report "3 of ~201" when the truth is 3 of 6, which is worse than reporting nothing. Instead --count asks for one maximal page of bare ids (maxResults=500, fields=/id,nextPageToken) and counts them. Exact when the set fits a page, reported as totalMatches; a lower bound when the page fills with more behind it, reported as totalMatchesAtLeast, so a saturated probe can never be mistaken for a total. Exactness holds for the narrow queries where a wrong count does the most damage. Opt-in, so no caller pays the extra round-trip without asking. The text path prints to stderr, keeping stdout parseable. Refs #983 --- internal/cmd/gmail_messages.go | 29 ++- internal/cmd/gmail_search.go | 29 ++- internal/cmd/gmail_search_count.go | 86 ++++++++ internal/cmd/gmail_search_count_test.go | 277 ++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/gmail_search_count.go create mode 100644 internal/cmd/gmail_search_count_test.go diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index e5ad5122f..accac6836 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:"Also report how many messages match in total (exact when the set fits one page, otherwise a lower bound)"` 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)"` @@ -82,10 +83,14 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro 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 c.Count { + payload["totalMatches"] = int64(0) + } + return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -106,11 +111,23 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } + var matchCount gmailMatchCount + if c.Count { + matchCount, err = countGmailMessageMatches(ctx, svc, query) + if err != nil { + return err + } + } + if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "messages": items, "nextPageToken": nextPageToken, - }, len(items), c.FailEmpty) + } + if c.Count { + matchCount.apply(payload) + } + return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) } if len(items) == 0 { @@ -126,6 +143,10 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro ); err != nil { return err } + // stderr, so the table on stdout stays parseable. + if c.Count { + 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..59fbb2757 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:"Also report how many threads match in total (exact when the set fits one page, otherwise a lower bound)"` 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)"` @@ -67,10 +68,14 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { 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 c.Count { + payload["totalMatches"] = int64(0) + } + return writePagedJSONResult(ctx, payload, 0, c.FailEmpty) } u.Err().Println("No results") return failEmptyExit(c.FailEmpty) @@ -91,11 +96,23 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + var matchCount gmailMatchCount + if c.Count { + matchCount, err = countGmailThreadMatches(ctx, svc, query) + if err != nil { + return err + } + } + if outfmt.IsJSON(ctx) { - return writePagedJSONResult(ctx, map[string]any{ + payload := map[string]any{ "threads": items, "nextPageToken": nextPageToken, - }, len(items), c.FailEmpty) + } + if c.Count { + matchCount.apply(payload) + } + return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) } if len(items) == 0 { @@ -106,6 +123,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 c.Count { + 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..3adcd63bb --- /dev/null +++ b/internal/cmd/gmail_search_count.go @@ -0,0 +1,86 @@ +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)) +} diff --git a/internal/cmd/gmail_search_count_test.go b/internal/cmd/gmail_search_count_test.go new file mode 100644 index 000000000..394b0bc5d --- /dev/null +++ b/internal/cmd/gmail_search_count_test.go @@ -0,0 +1,277 @@ +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) + } + } +} + +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_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) + } + }) + } +} From ca114661c6b903214bbd65521281e93b900a45d8 Mon Sep 17 00:00:00 2001 From: Chris Hall Date: Wed, 12 Aug 2026 23:48:01 -0400 Subject: [PATCH 2/2] feat(gmail): don't spend a count probe that is wasted or already answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the --count review on #983, which asked for an explicit contract around --page, --all and --results-only. Two of the three were real defects: --results-only unwraps the envelope to the bare result array, so a count field cannot survive it. The probe ran anyway and its result was silently discarded — a Gmail request spent for nothing, with no way for the caller to tell. It is now skipped, and the caller is told on stderr rather than left guessing. --all has already walked every page, so the items in hand ARE the whole set. The probe was asking Google a question the walk had just finished answering. The total now comes from the walk, which also makes it exact by construction. --page needed no code change but did need saying: the count is always for the WHOLE query, never the remainder after a cursor. That is the number that stops a caller concluding an absence, and keeping it page-independent means it does not drift while paging. Now stated in the flag help. Refs #983 --- internal/cmd/gmail_messages.go | 11 ++-- internal/cmd/gmail_search.go | 11 ++-- internal/cmd/gmail_search_count.go | 38 +++++++++++ internal/cmd/gmail_search_count_test.go | 84 +++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index accac6836..ccc8c5eed 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -33,7 +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:"Also report how many messages match in total (exact when the set fits one page, otherwise a lower bound)"` + Count bool `name:"count" help:"Also report how many messages match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect 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)"` @@ -112,8 +112,11 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro } var matchCount gmailMatchCount + countReported := false if c.Count { - matchCount, err = countGmailMessageMatches(ctx, svc, query) + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { + return countGmailMessageMatches(ctx, svc, query) + }) if err != nil { return err } @@ -124,7 +127,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro "messages": items, "nextPageToken": nextPageToken, } - if c.Count { + if countReported { matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) @@ -144,7 +147,7 @@ func (c *GmailMessagesSearchCmd) Run(ctx context.Context, flags *RootFlags) erro return err } // stderr, so the table on stdout stays parseable. - if c.Count { + if countReported { printGmailMatchCount(u, len(items), matchCount) } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") diff --git a/internal/cmd/gmail_search.go b/internal/cmd/gmail_search.go index 59fbb2757..535a55009 100644 --- a/internal/cmd/gmail_search.go +++ b/internal/cmd/gmail_search.go @@ -19,7 +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:"Also report how many threads match in total (exact when the set fits one page, otherwise a lower bound)"` + Count bool `name:"count" help:"Also report how many threads match the query in total, as totalMatches (exact) or totalMatchesAtLeast (lower bound). Always counts the WHOLE query, not the remainder after --page. Free with --all; no effect 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)"` @@ -97,8 +97,11 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } var matchCount gmailMatchCount + countReported := false if c.Count { - matchCount, err = countGmailThreadMatches(ctx, svc, query) + matchCount, countReported, err = resolveGmailMatchCount(u, flags.ResultsOnly, c.All, len(items), func() (gmailMatchCount, error) { + return countGmailThreadMatches(ctx, svc, query) + }) if err != nil { return err } @@ -109,7 +112,7 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { "threads": items, "nextPageToken": nextPageToken, } - if c.Count { + if countReported { matchCount.apply(payload) } return writePagedJSONResult(ctx, payload, len(items), c.FailEmpty) @@ -124,7 +127,7 @@ func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return err } // stderr, so the table on stdout stays parseable. - if c.Count { + if countReported { printGmailMatchCount(u, len(items), matchCount) } printNextPageHintWithAll(u, nextPageToken, "--all/--all-pages") diff --git a/internal/cmd/gmail_search_count.go b/internal/cmd/gmail_search_count.go index 3adcd63bb..a9aa5a92f 100644 --- a/internal/cmd/gmail_search_count.go +++ b/internal/cmd/gmail_search_count.go @@ -84,3 +84,41 @@ func printGmailMatchCount(u *ui.UI, shown int, count gmailMatchCount) { } 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 The walk already exhausted every page, so the items in hand +// ARE the whole set. Probing would ask Google a question we +// just finished answering. +// 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, + 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: + 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 index 394b0bc5d..4702542de 100644 --- a/internal/cmd/gmail_search_count_test.go +++ b/internal/cmd/gmail_search_count_test.go @@ -72,6 +72,39 @@ func gmailCountTestHandler(t *testing.T, capture *countProbeCapture, resource st } } +// 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", @@ -275,3 +308,54 @@ func TestPrintGmailMatchCount_Wording(t *testing.T) { }) } } + +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_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) + } +}