Skip to content

Commit 455caec

Browse files
authored
fix(outfmt): preserve Gmail attachment result envelopes (#943)
Preserve the complete single-object Gmail result envelope when attachment metadata is present, and keep the exact output-shape regression assertion aligned with the seven legitimate fields. Proof: - focused Gmail draft regression test passes - full `make ci` passes - built `gog` binary reports the exact review head and renders Gmail help successfully - exact-head GitHub CI, Docker, worker, Windows, and Darwin checks pass Co-authored-by: hashtag1974 <258647037+hashtag1974@users.noreply.github.com>
1 parent a1637ea commit 455caec

7 files changed

Lines changed: 264 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 0.34.2 - 2026-07-27
44

5+
- Gmail: preserve single-object result envelopes for attachment-bearing `--results-only` output. (#943) — thanks @hashtag1974.
56
- Sheets: keep positional updates within the requested range, preserving comma-bearing single-cell values and accurate named-range dry runs. (#941) — thanks @cathrynlavery.
67
- Gmail: add source-specific `internalDateIso` timestamps to message and thread listings while preserving the legacy sender-header `date`. (#945, #946) — thanks @chrischall.
78
- Gmail: prevent standalone draft updates from acquiring self-referential reply headers, with explicit recovery for affected drafts. (#942, #944) — thanks @chrischall.

internal/cmd/gmail_drafts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,7 @@ func writeDraftResult(ctx context.Context, u *ui.UI, draft *gmail.Draft, threadi
536536
if len(attachments) > 0 {
537537
result["attachments"] = attachments
538538
}
539-
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), result)
539+
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), outfmt.PrimaryResult(result))
540540
}
541541
u.Out().Linef("draft_id\t%s", draft.Id)
542542
if draft.Message != nil && draft.Message.Id != "" {

internal/cmd/gmail_drafts_cmd_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import (
99
"net/http/httptest"
1010
"os"
1111
"path/filepath"
12+
"reflect"
1213
"strings"
1314
"testing"
1415

1516
"google.golang.org/api/gmail/v1"
1617

1718
"github.com/steipete/gogcli/internal/mailmime"
19+
"github.com/steipete/gogcli/internal/outfmt"
1820
)
1921

2022
type gmailQuoteSource struct {
@@ -324,6 +326,85 @@ func TestGmailDraftsCreateCmd_JSON(t *testing.T) {
324326
}
325327
}
326328

329+
func TestGmailDraftsCreateCmd_JSON_ResultsOnlyPreservesCompleteAttachmentResult(t *testing.T) {
330+
attachmentPath := filepath.Join(t.TempDir(), "fixture.txt")
331+
if err := os.WriteFile(attachmentPath, []byte("fixture"), 0o600); err != nil {
332+
t.Fatalf("write attachment: %v", err)
333+
}
334+
335+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
336+
if strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost {
337+
w.Header().Set("Content-Type", "application/json")
338+
_ = json.NewEncoder(w).Encode(map[string]any{
339+
"id": "d1",
340+
"message": map[string]any{
341+
"id": "m1",
342+
"threadId": "t1",
343+
},
344+
})
345+
return
346+
}
347+
http.NotFound(w, r)
348+
}))
349+
defer srv.Close()
350+
351+
run := func(resultsOnly bool) []byte {
352+
t.Helper()
353+
svc := newGmailServiceFromServer(t, srv)
354+
flags := &RootFlags{Account: "synthetic@example.com"}
355+
var jsonOut bytes.Buffer
356+
ctx := withGmailTestService(newCmdRuntimeJSONOutputContext(t, &jsonOut, io.Discard), svc)
357+
if resultsOnly {
358+
ctx = outfmt.WithJSONTransform(ctx, outfmt.JSONTransform{ResultsOnly: true})
359+
}
360+
if err := runKong(t, &GmailDraftsCreateCmd{}, []string{
361+
"--to", "recipient@example.com",
362+
"--subject", "Synthetic subject",
363+
"--body", "Synthetic body",
364+
"--attach", attachmentPath,
365+
}, ctx, flags); err != nil {
366+
t.Fatalf("execute: %v", err)
367+
}
368+
return jsonOut.Bytes()
369+
}
370+
371+
baseline := run(false)
372+
got := run(true)
373+
var wantValue any
374+
if err := json.Unmarshal(baseline, &wantValue); err != nil {
375+
t.Fatalf("baseline json parse: %v", err)
376+
}
377+
var gotValue any
378+
if err := json.Unmarshal(got, &gotValue); err != nil {
379+
t.Fatalf("results-only json parse: %v", err)
380+
}
381+
if !reflect.DeepEqual(gotValue, wantValue) {
382+
t.Fatalf("--results-only changed the complete Gmail draft-create result:\ngot:\n%s\nwant:\n%s", got, baseline)
383+
}
384+
385+
parsed, ok := gotValue.(map[string]any)
386+
if !ok {
387+
t.Fatalf("expected complete Gmail draft-create object, got %T: %s", gotValue, got)
388+
}
389+
expectedKeys := []string{
390+
"draftId",
391+
"message",
392+
"threadId",
393+
"attachments",
394+
"inReplyTo",
395+
"references",
396+
"replyContextSource",
397+
}
398+
for _, key := range expectedKeys {
399+
if _, ok := parsed[key]; !ok {
400+
t.Fatalf("synthetic pre-transform shape is missing %q: %s", key, got)
401+
}
402+
}
403+
if len(parsed) != len(expectedKeys) {
404+
t.Fatalf("unexpected synthetic pre-transform keys: %s", got)
405+
}
406+
}
407+
327408
func TestGmailDraftsCreateCmd_BodyHTMLFile(t *testing.T) {
328409
htmlPath := filepath.Join(t.TempDir(), "body.html")
329410
if err := os.WriteFile(htmlPath, []byte("<h1>Hello</h1>\n<p>from file</p>"), 0o600); err != nil {

internal/cmd/gmail_get.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
113113
payload["attachments"] = attachmentOutputs(attachments)
114114
}
115115
}
116-
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), payload)
116+
return outfmt.WriteJSON(ctx, stdoutWriter(ctx), outfmt.PrimaryResult(payload))
117117
}
118118

119119
u.Out().Linef("id\t%s%s", msg.Id, gmailHumanMessageStatusMarker(ctx, msg.LabelIds))

internal/cmd/gmail_get_cmd_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"reflect"
89
"strings"
910
"testing"
1011
)
@@ -271,6 +272,56 @@ func gmailGetAttachmentHandler(subject, body string) http.Handler {
271272
})
272273
}
273274

275+
func TestGmailGetCmd_JSON_ResultsOnlyPreservesCompleteAttachmentResult(t *testing.T) {
276+
srv := httptest.NewServer(gmailGetAttachmentHandler("Synthetic attachment", "synthetic body"))
277+
defer srv.Close()
278+
279+
run := func(resultsOnly bool) []byte {
280+
t.Helper()
281+
args := []string{"--json"}
282+
if resultsOnly {
283+
args = append(args, "--results-only")
284+
}
285+
args = append(args,
286+
"--account", "synthetic@example.com",
287+
"gmail", "get", "m1",
288+
"--format", "full",
289+
)
290+
result := executeWithGmailTestService(t, args, newGmailServiceFromServer(t, srv))
291+
if result.err != nil {
292+
t.Fatalf("execute: %v\nstderr=%q", result.err, result.stderr)
293+
}
294+
return []byte(result.stdout)
295+
}
296+
297+
baseline := run(false)
298+
got := run(true)
299+
var wantValue any
300+
if err := json.Unmarshal(baseline, &wantValue); err != nil {
301+
t.Fatalf("baseline json parse: %v", err)
302+
}
303+
var gotValue any
304+
if err := json.Unmarshal(got, &gotValue); err != nil {
305+
t.Fatalf("results-only json parse: %v", err)
306+
}
307+
if !reflect.DeepEqual(gotValue, wantValue) {
308+
t.Fatalf("--results-only changed the complete Gmail get result:\ngot:\n%s\nwant:\n%s", got, baseline)
309+
}
310+
311+
parsed, ok := gotValue.(map[string]any)
312+
if !ok {
313+
t.Fatalf("expected complete Gmail get object, got %T: %s", gotValue, got)
314+
}
315+
for _, key := range []string{"message", "headers", "body", "attachments"} {
316+
if _, ok := parsed[key]; !ok {
317+
t.Fatalf("synthetic pre-transform shape is missing %q: %s", key, got)
318+
}
319+
}
320+
if len(parsed) != 4 {
321+
t.Fatalf("unexpected synthetic pre-transform keys: %s", got)
322+
}
323+
}
324+
274325
func TestGmailGetCmd_Text_Full_WithAttachments(t *testing.T) {
275326
srv := httptest.NewServer(gmailGetAttachmentHandler("Test", "hello"))
276327
defer srv.Close()

internal/outfmt/outfmt.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"os"
9+
"sort"
910
"strconv"
1011
"strings"
1112
)
@@ -71,6 +72,17 @@ type JSONTransform struct {
7172

7273
type jsonTransformKey struct{}
7374

75+
type primaryResult struct {
76+
value any
77+
}
78+
79+
// PrimaryResult marks a complete JSON value as already being the command's
80+
// primary result. WriteJSON still applies field selection, but --results-only
81+
// preserves the complete value instead of inferring a nested collection.
82+
func PrimaryResult(value any) any {
83+
return primaryResult{value: value}
84+
}
85+
7486
func WithJSONTransform(ctx context.Context, t JSONTransform) context.Context {
7587
return context.WithValue(ctx, jsonTransformKey{}, t)
7688
}
@@ -87,7 +99,18 @@ func JSONTransformFromContext(ctx context.Context) (JSONTransform, bool) {
8799
}
88100

89101
func WriteJSON(ctx context.Context, w io.Writer, v any) error {
102+
alreadyPrimary := false
103+
104+
if result, ok := v.(primaryResult); ok {
105+
v = result.value
106+
alreadyPrimary = true
107+
}
108+
90109
if t, ok := JSONTransformFromContext(ctx); ok && (t.ResultsOnly || len(t.Select) > 0) {
110+
if alreadyPrimary {
111+
t.ResultsOnly = false
112+
}
113+
91114
transformed, err := applyJSONTransform(v, t)
92115
if err != nil {
93116
return fmt.Errorf("transform json: %w", err)
@@ -174,6 +197,8 @@ func unwrapPrimary(v any) any {
174197
candidates = append(candidates, k)
175198
}
176199

200+
sort.Strings(candidates)
201+
177202
if len(candidates) == 1 {
178203
return m[candidates[0]]
179204
}

internal/outfmt/outfmt_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,110 @@ func TestWriteJSON_ResultsOnlyDoesNotPreferScalarKnownKeyOverArray(t *testing.T)
127127
}
128128
}
129129

130+
func TestWriteJSON_ResultsOnlyPreservesExplicitPrimaryResult(t *testing.T) {
131+
ctx := WithJSONTransform(context.Background(), JSONTransform{ResultsOnly: true})
132+
input := map[string]any{
133+
"draftId": "d1",
134+
"message": map[string]any{
135+
"id": "m1",
136+
"threadId": "t1",
137+
},
138+
"threadId": "t1",
139+
"attachments": []map[string]any{{
140+
"filename": "fixture.txt",
141+
"size": 7,
142+
}},
143+
}
144+
145+
var baseline bytes.Buffer
146+
if err := WriteJSON(context.Background(), &baseline, input); err != nil {
147+
t.Fatalf("WriteJSON baseline: %v", err)
148+
}
149+
150+
var got bytes.Buffer
151+
if err := WriteJSON(ctx, &got, PrimaryResult(input)); err != nil {
152+
t.Fatalf("WriteJSON primary result: %v", err)
153+
}
154+
155+
if got.String() != baseline.String() {
156+
t.Fatalf("complete primary result changed:\ngot:\n%s\nwant:\n%s", got.String(), baseline.String())
157+
}
158+
}
159+
160+
func TestWriteJSON_ResultsOnlyGenericArraySelectionIsDeterministic(t *testing.T) {
161+
ctx := WithJSONTransform(context.Background(), JSONTransform{ResultsOnly: true})
162+
input := map[string]any{
163+
"zItems": []map[string]any{{"id": "z"}},
164+
"aItems": []map[string]any{{"id": "a"}},
165+
}
166+
167+
for range 50 {
168+
var buf bytes.Buffer
169+
if err := WriteJSON(ctx, &buf, input); err != nil {
170+
t.Fatalf("WriteJSON: %v", err)
171+
}
172+
173+
var got []map[string]any
174+
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
175+
t.Fatalf("unmarshal: %v (out=%q)", err, buf.String())
176+
}
177+
178+
if len(got) != 1 || got[0]["id"] != "a" {
179+
t.Fatalf("generic selection was not deterministic: %s", strings.TrimSpace(buf.String()))
180+
}
181+
}
182+
}
183+
184+
func TestWriteJSON_ResultsOnlyAttachmentCommandStillReturnsAttachments(t *testing.T) {
185+
ctx := WithJSONTransform(context.Background(), JSONTransform{ResultsOnly: true})
186+
input := map[string]any{
187+
"threadId": "t1",
188+
"attachments": []map[string]any{{
189+
"messageId": "m1",
190+
"filename": "fixture.txt",
191+
}},
192+
}
193+
194+
var buf bytes.Buffer
195+
if err := WriteJSON(ctx, &buf, input); err != nil {
196+
t.Fatalf("WriteJSON: %v", err)
197+
}
198+
199+
var got []map[string]any
200+
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
201+
t.Fatalf("unmarshal: %v (out=%q)", err, buf.String())
202+
}
203+
204+
if len(got) != 1 || got[0]["messageId"] != "m1" || got[0]["filename"] != "fixture.txt" {
205+
t.Fatalf("attachment-specific result changed: %s", strings.TrimSpace(buf.String()))
206+
}
207+
}
208+
209+
func TestWriteJSON_ResultsOnlyListCommandStillReturnsItems(t *testing.T) {
210+
ctx := WithJSONTransform(context.Background(), JSONTransform{ResultsOnly: true})
211+
input := map[string]any{
212+
"messages": []map[string]any{
213+
{"id": "m1", "threadId": "t1"},
214+
{"id": "m2", "threadId": "t2"},
215+
},
216+
"nextPageToken": "next-page",
217+
}
218+
219+
var buf bytes.Buffer
220+
if err := WriteJSON(ctx, &buf, input); err != nil {
221+
t.Fatalf("WriteJSON: %v", err)
222+
}
223+
224+
var got []map[string]any
225+
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
226+
t.Fatalf("unmarshal: %v (out=%q)", err, buf.String())
227+
}
228+
229+
if len(got) != 2 || got[0]["id"] != "m1" || got[1]["id"] != "m2" {
230+
t.Fatalf("list result changed: %s", strings.TrimSpace(buf.String()))
231+
}
232+
}
233+
130234
func TestWriteJSONTransformPreservesLargeNumbers(t *testing.T) {
131235
ctx := WithJSONTransform(context.Background(), JSONTransform{Select: []string{"id"}})
132236

0 commit comments

Comments
 (0)