diff --git a/internal/cmd/docs.go b/internal/cmd/docs.go index 91727cd3..7864ea6c 100644 --- a/internal/cmd/docs.go +++ b/internal/cmd/docs.go @@ -29,7 +29,8 @@ type DocsCmd struct { Info DocsInfoCmd `cmd:"" name:"info" aliases:"get,show" help:"Get Google Doc metadata"` Create DocsCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a Google Doc"` Copy DocsCopyCmd `cmd:"" name:"copy" aliases:"cp,duplicate" help:"Copy a Google Doc"` - Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text,read" help:"Print a Google Doc as plain text"` + Cat DocsCatCmd `cmd:"" name:"cat" aliases:"text" help:"Print a Google Doc as plain text"` + Read DocsReadCmd `cmd:"" name:"read" help:"Read document content with character offset/limit for pagination. Use --offset and --limit to page through large documents."` Comments DocsCommentsCmd `cmd:"" name:"comments" help:"Manage comments on files"` ListTabs DocsListTabsCmd `cmd:"" name:"list-tabs" help:"List all tabs in a Google Doc"` Write DocsWriteCmd `cmd:"" name:"write" help:"Write content to a Google Doc"` @@ -37,7 +38,7 @@ type DocsCmd struct { Delete DocsDeleteCmd `cmd:"" name:"delete" help:"Delete text range from document"` FindReplace DocsFindReplaceCmd `cmd:"" name:"find-replace" help:"Find and replace text in document"` Update DocsUpdateCmd `cmd:"" name:"update" help:"Insert text at a specific index in a Google Doc"` - Edit DocsEditCmd `cmd:"" name:"edit" help:"Find and replace text in a Google Doc"` + Edit DocsEditCmd `cmd:"" name:"edit" help:"Replace, insert, or delete text in a Google Doc. Use --new '' to delete. Use \\n in --new to insert paragraphs. Requires unique match unless --replace-all. Use --dry-run to preview."` Sed DocsSedCmd `cmd:"" name:"sed" help:"Regex find/replace (sed-style: s/pattern/replacement/g)"` Clear DocsClearCmd `cmd:"" name:"clear" help:"Clear all content from a Google Doc"` Structure DocsStructureCmd `cmd:"" name:"structure" aliases:"struct" help:"Show document structure with numbered paragraphs"` @@ -692,6 +693,150 @@ func (c *DocsListTabsCmd) Run(ctx context.Context, flags *RootFlags) error { return nil } +// --- Read command (character-based pagination) --- + +type DocsReadCmd struct { + DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` + Tab string `name:"tab" help:"Read a specific tab by title or ID"` + Offset int `name:"offset" help:"Character offset to start from (0-indexed)" default:"0"` + Limit int `name:"limit" help:"Max characters to return (default 100000, 0 = all)" default:"100000"` +} + +func (c *DocsReadCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + id := strings.TrimSpace(c.DocID) + if id == "" { + return usage("empty docId") + } + + svc, err := newDocsService(ctx, account) + if err != nil { + return err + } + + var text string + if c.Tab != "" { + doc, docErr := svc.Documents.Get(id). + IncludeTabsContent(true). + Context(ctx). + Do() + if docErr != nil { + if isDocsNotFound(docErr) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return docErr + } + if doc == nil { + return errors.New("doc not found") + } + tabs := flattenTabs(doc.Tabs) + tab := findTab(tabs, c.Tab) + if tab == nil { + return fmt.Errorf("tab not found: %s", c.Tab) + } + text = tabPlainText(tab, 0) + } else { + doc, docErr := svc.Documents.Get(id). + Context(ctx). + Do() + if docErr != nil { + if isDocsNotFound(docErr) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", id) + } + return docErr + } + if doc == nil { + return errors.New("doc not found") + } + text = docsPlainText(doc, 0) + } + + totalChars := len(text) + text = applyCharWindow(text, c.Offset, c.Limit) + + if outfmt.IsJSON(ctx) { + result := map[string]any{ + "text": text, + "totalChars": totalChars, + } + if c.Offset > 0 { + result["offset"] = c.Offset + } + if c.Limit > 0 { + result["limit"] = c.Limit + } + return outfmt.WriteJSON(ctx, os.Stdout, result) + } + + _, err = io.WriteString(os.Stdout, text) + return err +} + +// applyCharWindow returns a substring of text based on character offset and limit. +// offset is 0-indexed. limit=0 means unlimited. +func applyCharWindow(text string, offset, limit int) string { + if offset >= len(text) { + return "" + } + if offset > 0 { + text = text[offset:] + } + if limit > 0 && len(text) > limit { + text = text[:limit] + } + return text +} + +// plural returns "s" if n != 1, for use in occurrence(s) messages. +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +// unescapeString interprets common escape sequences (\n, \t, \\) in s. +func unescapeString(s string) string { + if !strings.ContainsRune(s, '\\') { + return s + } + + var buf strings.Builder + buf.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + switch s[i+1] { + case 'n': + buf.WriteByte('\n') + i++ + case 't': + buf.WriteByte('\t') + i++ + case '\\': + buf.WriteByte('\\') + i++ + default: + buf.WriteByte(s[i]) + } + } else { + buf.WriteByte(s[i]) + } + } + return buf.String() +} + +// countOccurrences counts non-overlapping occurrences of substr in text. +func countOccurrences(text, substr string, matchCase bool) int { + if matchCase { + return strings.Count(text, substr) + } + return strings.Count(strings.ToLower(text), strings.ToLower(substr)) +} + // --- Write / Insert / Delete / Find-Replace commands --- type DocsInsertCmd struct { diff --git a/internal/cmd/docs_edit.go b/internal/cmd/docs_edit.go index 251798f4..2e00fc45 100644 --- a/internal/cmd/docs_edit.go +++ b/internal/cmd/docs_edit.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "strings" @@ -12,12 +13,13 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -// DocsEditCmd does find/replace in a Google Doc +// DocsEditCmd replaces, inserts, or deletes text in a Google Doc. type DocsEditCmd struct { - DocID string `arg:"" name:"docId" help:"Doc ID"` - Find string `name:"find" short:"f" help:"Text to find" required:""` - ReplaceStr string `name:"replace" short:"r" help:"Text to replace with" required:""` - MatchCase bool `name:"match-case" help:"Case-sensitive matching" default:"true"` + DocID string `arg:"" name:"docId" help:"Google Doc ID or URL"` + Old string `name:"old" required:"" help:"Text to find (must be unique unless --replace-all)"` + New string `name:"new" required:"" help:"Replacement text (use '' to delete, use \\n to insert paragraphs)"` + ReplaceAll bool `name:"replace-all" help:"Replace all occurrences (required if --old matches more than once)"` + MatchCase bool `name:"match-case" help:"Case-sensitive matching (use --no-match-case for case-insensitive)" default:"true" negatable:""` } func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -27,58 +29,90 @@ func (c *DocsEditCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - id := strings.TrimSpace(c.DocID) - if id == "" { + docID := strings.TrimSpace(c.DocID) + if docID == "" { return usage("empty docId") } + oldText := unescapeString(c.Old) + newText := unescapeString(c.New) - if c.Find == "" { - return usage("empty find text") + if oldText == "" { + return usage("--old cannot be empty") } - // Create Docs service - docsSvc, err := newDocsService(ctx, account) + svc, err := newDocsService(ctx, account) if err != nil { - return fmt.Errorf("create docs service: %w", err) + return err + } + + // Fetch document text to validate uniqueness. + doc, err := svc.Documents.Get(docID). + Context(ctx). + Do() + if err != nil { + if isDocsNotFound(err) { + return fmt.Errorf("doc not found or not a Google Doc (id=%s)", docID) + } + return err + } + if doc == nil { + return errors.New("doc not found") + } + + plainText := docsPlainText(doc, 0) + occurrences := countOccurrences(plainText, oldText, c.MatchCase) + + if occurrences == 0 { + return fmt.Errorf("%q not found", oldText) + } + if !c.ReplaceAll && occurrences > 1 { + return fmt.Errorf("%q is not unique (found %d occurrences). Use --replace-all to replace all.", oldText, occurrences) } - // Build replace request - requests := []*docs.Request{ - { + if flags.DryRun { + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "dry_run": true, + "documentId": docID, + "old": oldText, + "new": newText, + "occurrences": occurrences, + }) + } + u.Out().Printf("would replace %d occurrence%s", occurrences, plural(occurrences)) + return nil + } + + result, err := svc.Documents.BatchUpdate(docID, &docs.BatchUpdateDocumentRequest{ + Requests: []*docs.Request{{ ReplaceAllText: &docs.ReplaceAllTextRequest{ ContainsText: &docs.SubstringMatchCriteria{ - Text: c.Find, + Text: oldText, MatchCase: c.MatchCase, }, - ReplaceText: c.ReplaceStr, + ReplaceText: newText, }, - }, - } - - // Execute batch update - resp, err := docsSvc.Documents.BatchUpdate(id, &docs.BatchUpdateDocumentRequest{ - Requests: requests, + }}, }).Context(ctx).Do() if err != nil { - return fmt.Errorf("update document: %w", err) + return fmt.Errorf("edit document: %w", err) } - // Get count of replacements - replaced := int64(0) - if resp != nil && len(resp.Replies) > 0 && resp.Replies[0].ReplaceAllText != nil { - replaced = resp.Replies[0].ReplaceAllText.OccurrencesChanged + replacements := int64(0) + if len(result.Replies) > 0 && result.Replies[0].ReplaceAllText != nil { + replacements = result.Replies[0].ReplaceAllText.OccurrencesChanged } if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "status": "ok", - "docId": id, - "replaced": replaced, + "documentId": result.DocumentId, + "old": oldText, + "new": newText, + "replacements": replacements, + "matchCase": c.MatchCase, }) } - u.Out().Printf("status\tok") - u.Out().Printf("docId\t%s", id) - u.Out().Printf("replaced\t%d", replaced) + u.Out().Printf("replaced %d occurrence%s", replacements, plural(int(replacements))) return nil } diff --git a/internal/cmd/docs_edit_test.go b/internal/cmd/docs_edit_test.go index 0358cb71..d6196863 100644 --- a/internal/cmd/docs_edit_test.go +++ b/internal/cmd/docs_edit_test.go @@ -439,9 +439,9 @@ func TestDocsEditCmd_EmptyDocId(t *testing.T) { ctx := ui.WithUI(context.Background(), u) cmd := &DocsEditCmd{ - DocID: " ", - Find: "foo", - ReplaceStr: "bar", + DocID: " ", + Old: "foo", + New: "bar", } flags := &RootFlags{Account: "test@example.com"} @@ -459,9 +459,9 @@ func TestDocsEditCmd_EmptyFind(t *testing.T) { ctx := ui.WithUI(context.Background(), u) cmd := &DocsEditCmd{ - DocID: "doc123", - Find: "", - ReplaceStr: "bar", + DocID: "doc123", + Old: "", + New: "bar", } flags := &RootFlags{Account: "test@example.com"} diff --git a/internal/cmd/docs_read_edit_test.go b/internal/cmd/docs_read_edit_test.go new file mode 100644 index 00000000..f5014cce --- /dev/null +++ b/internal/cmd/docs_read_edit_test.go @@ -0,0 +1,523 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/docs/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// newReadEditTestServer creates a mock Docs API server for read/edit tests. +// It handles GET (document fetch) and POST (batchUpdate) requests. +func newReadEditTestServer(t *testing.T, handler http.HandlerFunc) (*docs.Service, func()) { + t.Helper() + srv := httptest.NewServer(handler) + docSvc, err := docs.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + require.NoError(t, err) + return docSvc, srv.Close +} + +func docResponse(id, content string) map[string]any { + return map[string]any{ + "documentId": id, + "body": map[string]any{ + "content": []any{ + map[string]any{ + "paragraph": map[string]any{ + "elements": []any{ + map[string]any{ + "textRun": map[string]any{"content": content}, + }, + }, + }, + }, + }, + }, + } +} + +func readEditContext(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + require.NoError(t, err) + return ui.WithUI(context.Background(), u) +} + +func readEditContextWithStdout(t *testing.T) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: io.Discard, Color: "never"}) + require.NoError(t, err) + return ui.WithUI(context.Background(), u) +} + +// --- Helper unit tests --- + +func TestApplyCharWindow(t *testing.T) { + text := "abcdefghij" // 10 chars + + tests := []struct { + name string + offset int + limit int + want string + }{ + {"no window", 0, 0, "abcdefghij"}, + {"offset only", 3, 0, "defghij"}, + {"limit only", 0, 4, "abcd"}, + {"offset and limit", 2, 3, "cde"}, + {"offset past end", 15, 0, ""}, + {"limit past end", 8, 100, "ij"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyCharWindow(text, tt.offset, tt.limit) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCountOccurrences(t *testing.T) { + tests := []struct { + name string + text string + substr string + matchCase bool + want int + }{ + {"exact match", "hello world hello", "hello", true, 2}, + {"case sensitive miss", "Hello world hello", "hello", true, 1}, + {"case insensitive", "Hello world hello", "hello", false, 2}, + {"not found", "abc", "xyz", true, 0}, + {"empty text", "", "hello", true, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, countOccurrences(tt.text, tt.substr, tt.matchCase)) + }) + } +} + +func TestUnescapeString(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"no escapes", "hello world", "hello world"}, + {"newline", `hello\nworld`, "hello\nworld"}, + {"tab", `hello\tworld`, "hello\tworld"}, + {"backslash", `hello\\world`, "hello\\world"}, + {"multiple", `a\nb\tc\\d`, "a\nb\tc\\d"}, + {"unknown escape kept", `hello\xworld`, `hello\xworld`}, + {"trailing backslash", `hello\`, `hello\`}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, unescapeString(tt.in)) + }) + } +} + +// --- DocsReadCmd tests --- + +func TestDocsRead_PlainText(t *testing.T) { + docContent := "hello world" + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Equal(t, docContent, out) +} + +func TestDocsRead_CharOffset(t *testing.T) { + docContent := "abcdefghij" // 10 chars + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + // --offset 3 --limit 4: chars at index 3..6 = "defg" + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1", "--offset", "3", "--limit", "4"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Equal(t, "defg", out) +} + +func TestDocsRead_OffsetOnly(t *testing.T) { + docContent := "abcdefghij" + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + // --offset 7 --limit 0: chars from index 7 onward = "hij" + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1", "--offset", "7", "--limit", "0"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Equal(t, "hij", out) +} + +func TestDocsRead_LimitOnly(t *testing.T) { + docContent := "abcdefghij" + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + // --offset 0 --limit 5: first 5 chars = "abcde" + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1", "--offset", "0", "--limit", "5"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Equal(t, "abcde", out) +} + +func TestDocsRead_OffsetPastEnd(t *testing.T) { + docContent := "short" + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1", "--offset", "999", "--limit", "0"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Equal(t, "", out) +} + +func TestDocsRead_JSON(t *testing.T) { + docContent := "hello world" // 11 chars + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", docContent)) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + ctx := outfmt.WithMode(readEditContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + cmd := &DocsReadCmd{} + err := runKong(t, cmd, []string{"doc1", "--offset", "6", "--limit", "5"}, ctx, flags) + require.NoError(t, err) + }) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "world", result["text"]) + assert.Equal(t, float64(11), result["totalChars"]) +} + +// --- DocsEditCmd tests --- + +func TestDocsEdit_UniqueReplacement(t *testing.T) { + var gotBatch docs.BatchUpdateDocumentRequest + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello foo world")) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&gotBatch) + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 1}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "bar"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + + assert.Contains(t, out, "replaced 1 occurrence") + require.Len(t, gotBatch.Requests, 1) + require.NotNil(t, gotBatch.Requests[0].ReplaceAllText) + r := gotBatch.Requests[0].ReplaceAllText + assert.Equal(t, "foo", r.ContainsText.Text) + assert.Equal(t, "bar", r.ReplaceText) +} + +func TestDocsEdit_NotUnique_Error(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", "foo bar foo baz foo")) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "bar"}, readEditContext(t), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "not unique") + assert.Contains(t, err.Error(), "3 occurrences") +} + +func TestDocsEdit_NotFound_Error(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello world")) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "xyz", "--new", "abc"}, readEditContext(t), flags) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestDocsEdit_ReplaceAll(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "foo bar foo baz foo")) + case http.MethodPost: + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 3}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "bar", "--replace-all"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Contains(t, out, "replaced 3 occurrences") +} + +func TestDocsEdit_JSON(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello foo world")) + case http.MethodPost: + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 1}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + ctx := outfmt.WithMode(readEditContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "bar"}, ctx, flags) + require.NoError(t, err) + }) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, "foo", result["old"]) + assert.Equal(t, "bar", result["new"]) + assert.Equal(t, float64(1), result["replacements"]) +} + +func TestDocsEdit_DryRun(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + t.Fatal("dry-run should not call batchUpdate") + } + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello foo world")) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com", DryRun: true} + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "bar"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Contains(t, out, "would replace 1 occurrence") +} + +func TestDocsEdit_DryRun_JSON(t *testing.T) { + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + t.Fatal("dry-run should not call batchUpdate") + } + _ = json.NewEncoder(w).Encode(docResponse("doc1", "foo bar foo")) + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com", DryRun: true} + ctx := outfmt.WithMode(readEditContextWithStdout(t), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "foo", "--new", "baz", "--replace-all"}, ctx, flags) + require.NoError(t, err) + }) + + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &result)) + assert.Equal(t, true, result["dry_run"]) + assert.Equal(t, float64(2), result["occurrences"]) + assert.Equal(t, "foo", result["old"]) + assert.Equal(t, "baz", result["new"]) +} + +func TestDocsEdit_Deletion(t *testing.T) { + var gotBatch docs.BatchUpdateDocumentRequest + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello removeme world")) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&gotBatch) + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 1}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "removeme", "--new", ""}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Contains(t, out, "replaced 1 occurrence") + assert.Equal(t, "", gotBatch.Requests[0].ReplaceAllText.ReplaceText) +} + +func TestDocsEdit_EscapeSequences(t *testing.T) { + var gotBatch docs.BatchUpdateDocumentRequest + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "hello world")) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&gotBatch) + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 1}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + captureStdout(t, func() { + cmd := &DocsEditCmd{} + // \n in --new should be unescaped to actual newline + err := runKong(t, cmd, []string{"doc1", "--old", "hello world", "--new", `hello\nworld`}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + r := gotBatch.Requests[0].ReplaceAllText + assert.Equal(t, "hello\nworld", r.ReplaceText) +} + +func TestDocsEdit_NoMatchCase(t *testing.T) { + var gotBatch docs.BatchUpdateDocumentRequest + svc, cleanup := newReadEditTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(docResponse("doc1", "Hello HELLO hello")) + case r.Method == http.MethodPost: + _ = json.NewDecoder(r.Body).Decode(&gotBatch) + _ = json.NewEncoder(w).Encode(map[string]any{ + "documentId": "doc1", + "replies": []any{map[string]any{"replaceAllText": map[string]any{"occurrencesChanged": 3}}}, + }) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + mockDocsService(t, svc) + + flags := &RootFlags{Account: "a@b.com"} + out := captureStdout(t, func() { + cmd := &DocsEditCmd{} + err := runKong(t, cmd, []string{"doc1", "--old", "hello", "--new", "hi", "--no-match-case", "--replace-all"}, readEditContextWithStdout(t), flags) + require.NoError(t, err) + }) + assert.Contains(t, out, "replaced 3 occurrences") + assert.False(t, gotBatch.Requests[0].ReplaceAllText.ContainsText.MatchCase) +}