diff --git a/internal/cmd/gmail_labels.go b/internal/cmd/gmail_labels.go index bb54835a..935e4896 100644 --- a/internal/cmd/gmail_labels.go +++ b/internal/cmd/gmail_labels.go @@ -16,6 +16,7 @@ type GmailLabelsCmd struct { List GmailLabelsListCmd `cmd:"" name:"list" aliases:"ls" help:"List labels"` Get GmailLabelsGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get label details (including counts)"` Create GmailLabelsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new label"` + Rename GmailLabelsRenameCmd `cmd:"" name:"rename" aliases:"mv" help:"Rename a label"` Modify GmailLabelsModifyCmd `cmd:"" name:"modify" aliases:"update,edit,set" help:"Modify labels on threads"` Delete GmailLabelsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del" help:"Delete a label"` } @@ -112,6 +113,82 @@ func createLabel(ctx context.Context, svc *gmail.Service, name string) (*gmail.L }).Context(ctx).Do() } +type GmailLabelsRenameCmd struct { + Label string `arg:"" name:"labelIdOrName" help:"Current label ID or name"` + NewName string `arg:"" name:"newName" help:"New label name"` +} + +func (c *GmailLabelsRenameCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + oldRaw := strings.TrimSpace(c.Label) + if oldRaw == "" { + return usage("label is required") + } + newName := strings.TrimSpace(c.NewName) + if newName == "" { + return usage("new name is required") + } + + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + // Resolve the label: try exact ID first, then fall back to name lookup. + label, err := svc.Users.Labels.Get("me", oldRaw).Context(ctx).Do() + if err != nil { + if !isNotFoundAPIError(err) { + return err + } + idMap, mapErr := fetchLabelNameToID(svc) + if mapErr != nil { + return mapErr + } + id, ok := idMap[strings.ToLower(oldRaw)] + if !ok { + return fmt.Errorf("label not found: %s", oldRaw) + } + label, err = svc.Users.Labels.Get("me", id).Context(ctx).Do() + if err != nil { + return err + } + } + + if label.Type == "system" { + return fmt.Errorf("cannot rename system label %q", label.Name) + } + + if err := ensureLabelNameAvailable(svc, newName); err != nil { + return err + } + + if exit := dryRunExit(ctx, flags, "labels.patch", map[string]string{ + "id": label.Id, + "oldName": label.Name, + "newName": newName, + }); exit != nil { + return exit + } + + updated, err := svc.Users.Labels.Patch("me", label.Id, &gmail.Label{ + Name: newName, + }).Context(ctx).Do() + if err != nil { + return mapLabelCreateError(err, newName) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"label": updated}) + } + u.Out().Printf("Renamed label: %s → %s (id: %s)", label.Name, updated.Name, updated.Id) + return nil +} + type GmailLabelsListCmd struct{} func (c *GmailLabelsListCmd) Run(ctx context.Context, flags *RootFlags) error { diff --git a/internal/cmd/gmail_labels_rename_cmd_test.go b/internal/cmd/gmail_labels_rename_cmd_test.go new file mode 100644 index 00000000..c8e0492d --- /dev/null +++ b/internal/cmd/gmail_labels_rename_cmd_test.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func newLabelsRenameService(t *testing.T, handler http.HandlerFunc) { + t.Helper() + + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } +} + +func newLabelsRenameContext(t *testing.T, jsonMode bool) context.Context { + t.Helper() + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + if jsonMode { + ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true}) + } + return ctx +} + +func TestGmailLabelsRenameCmd_JSON_ExactID(t *testing.T) { + patchCalled := false + + newLabelsRenameService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + if pathTail(r.URL.Path) != "Label_1" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "Label_1", "name": "Old Name", "type": "user"}) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{ + {"id": "Label_1", "name": "Old Name", "type": "user"}, + }}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + if pathTail(r.URL.Path) != "Label_1" { + http.Error(w, "wrong patch id", http.StatusBadRequest) + return + } + var body struct { + Name string `json:"name"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Name != "New Name" { + http.Error(w, "unexpected name", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "Label_1", + "name": "New Name", + "type": "user", + }) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsRenameContext(t, true) + + out := captureStdout(t, func() { + if err := runKong(t, &GmailLabelsRenameCmd{}, []string{"Label_1", "New Name"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + }) + + if !patchCalled { + t.Fatal("expected patch call") + } + + var parsed struct { + Label struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"label"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("json parse: %v\nout=%q", err, out) + } + if parsed.Label.ID != "Label_1" || parsed.Label.Name != "New Name" { + t.Fatalf("unexpected output: %#v", parsed.Label) + } +} + +func TestGmailLabelsRenameCmd_NameFallback(t *testing.T) { + patchCalled := false + listCalled := false + + newLabelsRenameService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + id := pathTail(r.URL.Path) + if id == "old+name" || id == "old%20name" || id == "old name" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": 404, "message": "Requested entity was not found."}}) + return + } + if id == "Label_5" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "Label_5", "name": "Old Name", "type": "user"}) + return + } + http.NotFound(w, r) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + listCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{ + {"id": "Label_5", "name": "Old Name", "type": "user"}, + }}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "Label_5", + "name": "New Name", + "type": "user", + }) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsRenameContext(t, false) + + var buf strings.Builder + u, err := ui.New(ui.Options{Stdout: &buf, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx = ui.WithUI(ctx, u) + + if err := runKong(t, &GmailLabelsRenameCmd{}, []string{"Old Name", "New Name"}, ctx, flags); err != nil { + t.Fatalf("execute: %v", err) + } + if !listCalled { + t.Fatal("expected list call for name fallback") + } + if !patchCalled { + t.Fatal("expected patch call") + } + out := buf.String() + if !strings.Contains(out, "Renamed label:") { + t.Fatalf("missing 'Renamed label:' in output: %q", out) + } +} + +func TestGmailLabelsRenameCmd_SystemLabelBlocked(t *testing.T) { + patchCalled := false + + newLabelsRenameService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "INBOX", "name": "INBOX", "type": "system"}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{}) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsRenameContext(t, false) + err := runKong(t, &GmailLabelsRenameCmd{}, []string{"INBOX", "MyInbox"}, ctx, flags) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), `cannot rename system label "INBOX"`) { + t.Fatalf("unexpected error: %v", err) + } + if patchCalled { + t.Fatal("patch should not run for system labels") + } +} + +func TestGmailLabelsRenameCmd_DuplicateNewName(t *testing.T) { + patchCalled := false + + newLabelsRenameService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + if pathTail(r.URL.Path) == "Label_1" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "Label_1", "name": "Source", "type": "user"}) + return + } + http.NotFound(w, r) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{ + {"id": "Label_1", "name": "Source", "type": "user"}, + {"id": "Label_2", "name": "Taken", "type": "user"}, + }}) + return + case r.Method == http.MethodPatch && isLabelsItemPath(r.URL.Path): + patchCalled = true + http.Error(w, "should not patch", http.StatusInternalServerError) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsRenameContext(t, false) + err := runKong(t, &GmailLabelsRenameCmd{}, []string{"Label_1", "Taken"}, ctx, flags) + if err == nil { + t.Fatal("expected error for duplicate name") + } + if !strings.Contains(err.Error(), "label already exists") { + t.Fatalf("unexpected error: %v", err) + } + if patchCalled { + t.Fatal("patch should not run when new name is taken") + } +} + +func TestGmailLabelsRenameCmd_NotFound(t *testing.T) { + newLabelsRenameService(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && isLabelsItemPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"code": 404, "message": "Requested entity was not found."}}) + return + case r.Method == http.MethodGet && isLabelsListPath(r.URL.Path): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"labels": []map[string]any{}}) + return + default: + http.NotFound(w, r) + } + }) + + flags := &RootFlags{Account: "a@b.com"} + ctx := newLabelsRenameContext(t, false) + err := runKong(t, &GmailLabelsRenameCmd{}, []string{"NoSuchLabel", "Whatever"}, ctx, flags) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "label not found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGmailLabelsRenameCmd_EmptyArgs(t *testing.T) { + origNew := newGmailService + t.Cleanup(func() { newGmailService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("API should not be called for empty args") + })) + defer srv.Close() + + svc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + flags := &RootFlags{Account: "a@b.com"} + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := ui.WithUI(context.Background(), u) + + cmd := &GmailLabelsRenameCmd{Label: " ", NewName: "New"} + err = cmd.Run(ctx, flags) + if err == nil { + t.Fatal("expected error for empty label") + } + if !strings.Contains(err.Error(), "label is required") { + t.Fatalf("unexpected error: %v", err) + } + + cmd = &GmailLabelsRenameCmd{Label: "Old", NewName: " "} + err = cmd.Run(ctx, flags) + if err == nil { + t.Fatal("expected error for empty new name") + } + if !strings.Contains(err.Error(), "new name is required") { + t.Fatalf("unexpected error: %v", err) + } +}